Compare commits
93 Commits
| 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 | |||
| 2985d9ade8 | |||
| bff810c1eb | |||
| 904fe85249 | |||
| cda488c565 | |||
| 9ab9a28458 | |||
| d175b31207 | |||
| 13e67599c4 | |||
| 7551947466 | |||
| a7d6a7d235 | |||
| d619a6e2ec | |||
| 2abfee141b | |||
| c346a106a3 | |||
| 8de0fad776 | |||
| ed49b8d97a | |||
| 9eff3f3c48 | |||
| a0abacaafb | |||
| da7d70825e | |||
| 41c2c2b716 | |||
| 5c08bfc8a8 | |||
| 1371584634 | |||
| d902b2d056 | |||
| c48abf41d1 | |||
| 777d53354f | |||
| 52fe09d79d | |||
| c9534c639f | |||
| 4ed6764323 | |||
| f8da7890df | |||
| b17dc1fa1f | |||
| 3eec9bc23c | |||
| 4744f05232 | |||
| 314c3767a8 | |||
| 05e88c5c75 | |||
| 7f67c36a1c | |||
| ab5ff8ec56 | |||
| 421558477d | |||
| b655faab9f | |||
| 3b0335f34e | |||
| f91dff6090 | |||
| 9ebb0643c7 | |||
| 7d8ee4cea1 | |||
| a78e59fb5b | |||
| cf5c60a342 | |||
| edd6be1737 | |||
| efd254f4e6 | |||
| 8b69b3625b | |||
| b55b44e7b6 | |||
| c1154f1fa6 |
@@ -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
|
||||
|
||||
@@ -1,27 +1,332 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
# Two-phase split-base build pipeline for pi-devbox.
|
||||
# Adapted from opencode-devbox/.gitea/workflows/docker-publish-split.yml
|
||||
# (commit before v1.16.2). pi-devbox v1.0.0 introduces a self-contained
|
||||
# build chain — base + variant Dockerfiles in this repo — so this
|
||||
# workflow no longer depends on opencode-devbox CI.
|
||||
#
|
||||
# Pipeline shape:
|
||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||
# + entrypoints; probe Docker Hub for existing tag.
|
||||
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
|
||||
# to commit SHAs (defeats registry-buildcache
|
||||
# cache-hit footgun on byte-identical build args).
|
||||
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
|
||||
# 4. smoke amd64-only build of the variant FROMing the base
|
||||
# tag; runs scripts/smoke-test.sh.
|
||||
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
|
||||
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
|
||||
# 7. update-description patch Docker Hub description.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag to publish (e.g. v1.0.0). Used only for workflow_dispatch runs.'
|
||||
required: false
|
||||
default: ''
|
||||
promote_latest:
|
||||
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
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
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
||||
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: decide whether base needs rebuilding ──────────────────
|
||||
base-decide:
|
||||
needs: [resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
base_tag: ${{ steps.compute.outputs.base_tag }}
|
||||
need_build: ${{ steps.probe.outputs.need_build }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Guard — base *_REF args must be folded into the base hash
|
||||
run: bash scripts/check-base-hash.sh
|
||||
|
||||
- name: Compute base tag from Dockerfile.base + dependencies
|
||||
id: compute
|
||||
run: |
|
||||
# Hash inputs that determine the base image's contents.
|
||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
||||
# Junk filters: __pycache__/*.pyc and macOS metadata are gitignored
|
||||
# locally but still picked up by `find rootfs -type f` on a clean CI
|
||||
# checkout. Exclude them defensively.
|
||||
HASH=$(
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
||||
# resolves to a SHA; fold it in so base_tag changes when the
|
||||
# toolkit moves (otherwise a toolkit-only fix never lands).
|
||||
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
BASE_TAG="base-${HASH}"
|
||||
echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed base tag: ${BASE_TAG}"
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Probe Docker Hub for existing base tag
|
||||
id: probe
|
||||
run: |
|
||||
set +e
|
||||
docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \
|
||||
> /dev/null 2>&1
|
||||
PROBE_RC=$?
|
||||
set -e
|
||||
if [ "${PROBE_RC}" = "0" ]; then
|
||||
echo "need_build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild."
|
||||
else
|
||||
echo "need_build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
|
||||
fi
|
||||
|
||||
# ── Phase 1b: resolve floating versions to concrete refs ────────────
|
||||
# Without this, when PI_VERSION defaults to 'latest', the build-arg string
|
||||
# is byte-identical across builds → identical layer hash → registry
|
||||
# buildcache silently reuses the layer from whatever pi version was
|
||||
# current when the cache was first populated. Same class of bug as
|
||||
# pi-devbox v0.74.0..v0.75.5 (fixed in v0.75.5b 2026-05-23).
|
||||
resolve-versions:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||
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
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AUTH_HEADER="Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
|
||||
# Fail loud rather than silently shipping a floating branch. A
|
||||
# transient network/API failure must ABORT the release, not bake
|
||||
# an unpinned ref that defeats both cache-busting AND after-the-
|
||||
# fact reproducibility. (Previously each lookup fell back to
|
||||
# `main`/`master` via `|| echo`.)
|
||||
require_sha() { # $1=label $2=value
|
||||
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
|
||||
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<empty>}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
|
||||
# reliably on PATH in act_runner job containers, so query directly).
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true)
|
||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
|
||||
exit 1
|
||||
fi
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||
require_sha PI_FORK_REF "$FORK_REF"
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
||||
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
|
||||
# requires auth even for public-repo commit listing.
|
||||
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
||||
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
||||
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# mempalace-toolkit (Gitea) → commit SHA. UNLIKE the others this
|
||||
# is cloned in Dockerfile.base, so the SAME SHA is ALSO folded
|
||||
# into the base-decide hash (see that job) to force a base rebuild
|
||||
# when the toolkit moves — otherwise a toolkit-only fix silently
|
||||
# fails to land unless Dockerfile.base itself changes.
|
||||
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
||||
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 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} (pi-studio ${STUDIO_TAG})"
|
||||
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide, resolve-versions]
|
||||
if: needs.base-decide.outputs.need_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push base (multi-arch) — with retry
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
# registry-1.docker.io blips. Does NOT mask deterministic failures.
|
||||
# Registry cache disabled: buildkit cache-export hits HTTP 400 from
|
||||
# Hub CDN since ~2026-05-23. Image push itself works; we pay full
|
||||
# base build on Dockerfile.base change, but the base tag is content-
|
||||
# addressed so unchanged bases short-circuit at the probe step.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.base \
|
||||
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
|
||||
--push \
|
||||
--tag "${BASE_TAG_FULL}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 3: amd64 smoke (gates the multi-arch publish) ─────────────
|
||||
smoke:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
@@ -29,42 +334,99 @@ jobs:
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
|
||||
# Derive PI_VERSION from the tag (e.g. v0.78.0 -> 0.78.0; v0.78.0b -> 0.78.0).
|
||||
# Since the refactor to FROM opencode-devbox:latest-with-pi, this repo no
|
||||
# longer installs pi itself — pi comes from the base image. We still resolve
|
||||
# the tag version and feed it to the smoke test as EXPECTED_PI_VERSION: the
|
||||
# smoke asserts the inherited base actually carries this pi version, which
|
||||
# turns the version coupling into an enforced publish-ordering guard (it
|
||||
# fails loudly if latest-with-pi is stale relative to this tag).
|
||||
- name: Resolve PI_VERSION from tag
|
||||
id: resolve
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
PI_VERSION="${TAG#v}"
|
||||
PI_VERSION=$(echo "$PI_VERSION" | sed 's/[a-z]*$//')
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved PI_VERSION=${PI_VERSION} from tag ${TAG}"
|
||||
|
||||
- name: Build (amd64, load to local daemon)
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build amd64 variant for smoke
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: pi-devbox:smoke
|
||||
|
||||
- name: Smoke test
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
RELEASE_TAG=smoke
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ steps.resolve.outputs.pi_version }}
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
run: bash scripts/smoke-test.sh pi-devbox:smoke
|
||||
|
||||
publish:
|
||||
needs: smoke
|
||||
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
|
||||
# Additive + independent of the core `smoke` job: gates ONLY
|
||||
# build-variant-studio, never the core build-variant. A studio build or
|
||||
# smoke failure therefore cannot block the :latest / :vX.Y.Z release.
|
||||
smoke-studio:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build amd64 studio variant for smoke
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: pi-devbox:smoke-studio
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
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 }}
|
||||
- name: Smoke test studio (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
|
||||
|
||||
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
||||
build-variant:
|
||||
needs: [base-decide, smoke, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -79,7 +441,6 @@ jobs:
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
@@ -88,50 +449,52 @@ jobs:
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}"
|
||||
echo "${IMAGE}:latest"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# See the smoke job for why the tag version is resolved (now used only for
|
||||
# the base-freshness smoke guard; pi is no longer installed in this repo).
|
||||
- name: Resolve PI_VERSION from tag
|
||||
id: resolve
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
PI_VERSION="${TAG#v}"
|
||||
PI_VERSION=$(echo "$PI_VERSION" | sed 's/[a-z]*$//')
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved PI_VERSION=${PI_VERSION} from tag ${TAG}"
|
||||
|
||||
- name: Build and push (amd64 + arm64) — with retry
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Convert newline-delimited TAGS env var (build-push-action's native
|
||||
# format from the `Compute tags` step) into a bash array of -t flags.
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
# registry-1.docker.io blips (rate limits, CDN flap, brief 5xx).
|
||||
# The build itself is now trivial (FROM opencode-devbox:latest-with-pi
|
||||
# + an empty layer) so it is fast even without registry cache.
|
||||
# Registry cache stays disabled (buildkit mode=max cache-export hits a
|
||||
# reproducible HTTP 400 from Hub CDN since ~2026-05-23; image push is
|
||||
# unaffected). See opencode-devbox CHANGELOG v1.15.12.
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--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:-}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
@@ -146,21 +509,226 @@ jobs:
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 4b: multi-arch publish of the studio variant ───────────────
|
||||
# Additive: publishes :vX.Y.Z-studio (+ :latest-studio on release). Gated
|
||||
# on its own smoke-studio, NOT on the core build-variant, so it can ship
|
||||
# or fail independently of the core release.
|
||||
build-variant-studio:
|
||||
needs: [base-decide, smoke-studio, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute studio version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-studio"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-studio"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push studio variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
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
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--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:-}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
|
||||
promote-base-latest:
|
||||
needs:
|
||||
- base-decide
|
||||
- build-variant
|
||||
# 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')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
|
||||
# action's bootstrap script periodically rate-limits on
|
||||
# api.github.com/.../releases/latest. Pinning removes the runtime
|
||||
# dependency on GitHub API entirely.
|
||||
- name: Install crane (pinned)
|
||||
env:
|
||||
CRANE_VERSION: v0.21.6
|
||||
run: |
|
||||
set -eux
|
||||
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin crane
|
||||
crane version
|
||||
- name: Login (crane)
|
||||
run: |
|
||||
crane auth login docker.io \
|
||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
- 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: |
|
||||
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:
|
||||
needs: publish
|
||||
needs: [build-variant, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant.result == 'success' &&
|
||||
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update Docker Hub description
|
||||
env:
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n --rawfile desc DOCKER_HUB.md '{"full_description": $desc}')
|
||||
TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/auth/token" \
|
||||
# Substitute {{PI_VERSION}} placeholders in DOCKER_HUB.md so the
|
||||
# Hub page always shows which pi version is in :latest. The
|
||||
# placeholder lives in DOCKER_HUB.md (committed); CI fills it
|
||||
# at publish time using the same resolved version that was
|
||||
# baked into the variant image. No drift between page and image.
|
||||
if [ -z "${PI_VERSION}" ]; then
|
||||
echo "::error::PI_VERSION env var is empty. Likely cause: the"
|
||||
echo "::error::update-description job is missing 'resolve-versions'"
|
||||
echo "::error::in its needs: list, so needs.resolve-versions.outputs.pi_version"
|
||||
echo "::error::resolves to an empty string instead of the actual version."
|
||||
exit 1
|
||||
fi
|
||||
cp DOCKER_HUB.md /tmp/hub-full.md
|
||||
sed -i "s/{{PI_VERSION}}/${PI_VERSION}/g" /tmp/hub-full.md
|
||||
if grep -q '{{PI_VERSION}}' /tmp/hub-full.md; then
|
||||
echo "::error::DOCKER_HUB.md still contains unsubstituted {{PI_VERSION}} markers"
|
||||
exit 1
|
||||
fi
|
||||
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${{ vars.DOCKERHUB_USERNAME }}\",\"password\":\"${{ secrets.DOCKERHUB_TOKEN }}\"}" \
|
||||
| jq -r '.token')
|
||||
curl -s -X PATCH "https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" | jq -r '.full_description | if . then "✅ description updated (\(. | length) chars)" else "❌ update failed" end'
|
||||
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
|
||||
| jq -r .access_token)
|
||||
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "::error::Failed to authenticate with Docker Hub API"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(jq -n \
|
||||
--rawfile full /tmp/hub-full.md \
|
||||
--arg short "Linux container with the pi coding-agent, MemPalace, and curated dev tooling." \
|
||||
'{"full_description": $full, "description": $short}' | \
|
||||
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
|
||||
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-)
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/hub-response.txt
|
||||
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Description updated (pi version: ${PI_VERSION})."
|
||||
|
||||
@@ -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
|
||||
@@ -1,65 +1,239 @@
|
||||
# AGENTS.md — pi-devbox
|
||||
|
||||
Container image that re-brands the opencode-devbox **pi-only** variant as a
|
||||
pi-focused image. As of 2026-06-03 it no longer installs pi itself.
|
||||
Self-contained Docker image for the **pi coding-agent**. Decoupled from
|
||||
opencode-devbox at v1.0.0 (2026-06-09); previously pi-devbox was a thin
|
||||
re-brand of opencode-devbox's `pi-only` variant.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `Dockerfile` — thin re-brand: `FROM joakimp/pi-devbox:base-pi-only` (overridable via `BASE_IMAGE` arg). No install logic of its own — pi + companions are inherited from the pi-only build (built `INSTALL_OPENCODE=false`, so **no opencode** — that's the distinction from `opencode-devbox:latest-with-pi`). The `base-pi-only` tag is produced by opencode-devbox CI (from `opencode-devbox/Dockerfile.variant`) but published into THIS repo as an internal building-block tag. This refactor removed the install-logic duplication that used to drift against `opencode-devbox/Dockerfile.variant`.
|
||||
- `docker-compose.yml` — compose file for local use
|
||||
- `.env.example` — environment variable template
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Docker Hub
|
||||
- `.gitea/workflows/docker-publish.yml` — CI pipeline: smoke amd64 → multi-arch push → update Hub description
|
||||
- `Dockerfile.base` — multi-arch base layer with system packages,
|
||||
GitHub-binary tools (fzf, eza, zoxide, neovim, bat, gosu, gitleaks,
|
||||
git-lfs, uv, gitea-mcp, tealdeer), AWS CLI v2, mempalace + toolkit,
|
||||
Node.js, Python toolchain, locales, ssh ControlMaster defaults, and
|
||||
`/etc/tmux.conf` with 0-indexed sessions.
|
||||
- `Dockerfile.variant` — `FROM base-<hash>`, adds pi + companions
|
||||
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
|
||||
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
|
||||
(`-studio` variant). Also appends the pi-devbox managed block from
|
||||
`pi-global-AGENTS.append.md` onto pi-toolkit's `pi-global-AGENTS.md` (the
|
||||
single global instruction slot pi loads) so containers proactively load the
|
||||
baked `pi-devbox-environment` skill. Idempotent via a marker grep. After the
|
||||
pinned clones it also refreshes the vendored `pi-extensions` fallback skill
|
||||
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: 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, `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`)
|
||||
symlinked into `~/.agents/skills/` by the entrypoint, available with or
|
||||
without a mounted skillset — plus
|
||||
`usr/local/share/pi-devbox/pi-global-AGENTS.append.md` (the global-AGENTS
|
||||
pointer concatenated in `Dockerfile.variant`).
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
|
||||
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
|
||||
build-base → smoke → build-variant → promote-base-latest →
|
||||
update-description). The `-studio` variant adds independent
|
||||
`smoke-studio` + `build-variant-studio` jobs that gate only the
|
||||
`-studio` tags (never the core `:latest` release).
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
- Tags follow the pi npm version: `v{pi_version}[letter]`
|
||||
- The image inherits pi from `base-pi-only`, so the **publish ordering matters**: rebuild opencode-devbox first so `joakimp/pi-devbox:base-pi-only` carries the target pi version, *then* tag this repo. The smoke test asserts `pi --version` matches the tag (`EXPECTED_PI_VERSION`) and fails loudly if the base is stale.
|
||||
- Docker Hub: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
- Tags follow semver. **v1.0.0** is the first decoupled release; future
|
||||
minor bumps add variants (`-studio`, `-studio-tex`) or significant base
|
||||
additions (e.g. v1.2.0 image-baked agent skills); patch bumps follow
|
||||
pi npm version updates and small fixes.
|
||||
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
|
||||
`joakimp/pi-devbox:latest-studio`.
|
||||
Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) +
|
||||
`joakimp/pi-devbox:base-latest` (alias of most recent base).
|
||||
|
||||
## Release-day checklist
|
||||
|
||||
1. Ensure opencode-devbox has been released so `joakimp/pi-devbox:base-pi-only` carries the target pi version (and the fork/recall extensions). This is the hard prerequisite — the smoke guard enforces it.
|
||||
2. Update `CHANGELOG.md`: promote `Unreleased` → `vX.Y.Z — YYYY-MM-DD`
|
||||
3. Add fresh `## Unreleased` section
|
||||
4. Commit, tag `vX.Y.Z`, push tag → CI fires automatically
|
||||
1. Confirm `pi --version` resolves from npm to the expected version
|
||||
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
|
||||
Check release notes at https://github.com/earendil-works/pi/releases for
|
||||
the upstream changelog to include in `CHANGELOG.md`.
|
||||
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
3. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version. Then run the
|
||||
**post-recreate sanity check** inside the running container to confirm
|
||||
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
||||
that the container booted):
|
||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
|
||||
(or just `pi-devbox-sanity --expected-version X.Y.Z` if `cli_utils/bin` is
|
||||
on PATH). This is the runtime peer of the build-time `smoke-test.sh` gate.
|
||||
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. **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
|
||||
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
|
||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||
its lifecycle is managed host-side, nothing to revoke.
|
||||
|
||||
When drafting CHANGELOG entries, pull pi's release notes from the
|
||||
`CHANGELOG.md` shipped inside the npm tarball:
|
||||
## Gitea API access (env token)
|
||||
|
||||
```bash
|
||||
cd /tmp && npm pack @earendil-works/pi-coding-agent@<version>
|
||||
tar -xzf earendil-works-pi-coding-agent-<version>.tgz package/CHANGELOG.md
|
||||
head -40 package/CHANGELOG.md
|
||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||
host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
||||
`${GITEA_HOST:-}`), primarily to enable the `gitea-mcp` server. They are
|
||||
**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=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
|
||||
```
|
||||
|
||||
Pi's CHANGELOG has rich New Features / Added / Changed / Fixed sections
|
||||
per version. Don't try to derive notes from the npm registry metadata
|
||||
(`npm view`) — it doesn't include the changelog body.
|
||||
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.
|
||||
|
||||
## Key facts
|
||||
**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:
|
||||
|
||||
- **Base image**: `joakimp/pi-devbox:base-pi-only` — an internal building-block tag (produced by opencode-devbox CI from `Dockerfile.variant`, the single source of truth for the pi install + companions; published into this repo, not under opencode-devbox). Rebuilt whenever opencode-devbox releases. Not for end users — they pull `joakimp/pi-devbox:latest` or a `vX.Y.Z` tag.
|
||||
- **Inherited content**: pi (`/usr/bin/pi`), pi-toolkit, pi-extensions, pi-fork (`fork`), pi-observational-memory (`recall`), the mempalace bridge, the LAN-access helper, entrypoints, and all base dev tooling. The pi-only variant is built `INSTALL_OPENCODE=false`, so the image does **not** contain opencode.
|
||||
- **Companion repos**: cloned to `/opt/` by the pi-only build; `entrypoint-user.sh` (inherited) deploys/registers them on container start.
|
||||
- **MemPalace**: fully operational — inherited from base; bridge extension deployed by entrypoint.
|
||||
```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}'
|
||||
```
|
||||
|
||||
## Conventions
|
||||
**Watcher config for this repo** (`ci-release-watcher` skill, hub-only shape —
|
||||
pi-devbox has no downstream host to deploy to):
|
||||
|
||||
- This repo no longer installs pi or clones companion repos — do **not** re-add that logic here. Change it in `opencode-devbox/Dockerfile.variant` (the single source of truth) instead.
|
||||
- The smoke test threshold is 2750 MB (tracks the pi-only variant) — update if the image legitimately grows past it.
|
||||
- The CI still resolves the tag's pi version, but only to feed `EXPECTED_PI_VERSION` to the smoke base-freshness guard — it is no longer passed as a build-arg (nothing in the Dockerfile consumes it).
|
||||
- To pin a specific base build instead of tracking `base-pi-only`, override the `BASE_IMAGE` arg (a `base-pi-only-vX.Y.Z` tag or a digest).
|
||||
- `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.
|
||||
|
||||
## Documentation drift sweep
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo:
|
||||
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
||||
resolve it to a concrete version string** before passing as a build-arg.
|
||||
Otherwise the build-arg string is byte-identical across releases →
|
||||
identical layer hash → registry buildcache silently reuses the old
|
||||
layer. `resolve-versions` job in the workflow handles this.
|
||||
|
||||
- `README.md` — quick-start examples, env-var table, base-image reference (must match `FROM` in `Dockerfile`), "what's inside" (fork/recall; no opencode).
|
||||
- `AGENTS.md` (this file) — `Key facts` block (base-image tag, inherited content), smoke-test threshold number.
|
||||
- `CHANGELOG.md` — promote `Unreleased` only on tag, but record post-release fixes in a fresh `Unreleased` block.
|
||||
- `DOCKER_HUB.md` — hand-maintained slim Hub description; sync anything user-facing that changes (env vars, run command, base image).
|
||||
- `.env.example` — hand-updated, must match Dockerfile/entrypoint env vars (including the inherited LAN-access knobs).
|
||||
- `Dockerfile` `BASE_IMAGE` ARG default — the pi-only tag this image tracks.
|
||||
Discovered in pi-devbox 2026-05-23 (every release v0.74.0..v0.75.5
|
||||
shipped the same image bytes); preventatively fixed for `PI_VERSION` +
|
||||
`PI_FORK_REF` + `PI_OBSMEM_REF`.
|
||||
|
||||
Quick triage: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md CHANGELOG.md .env.example`.
|
||||
## Smoke-test gate
|
||||
|
||||
`scripts/smoke-test.sh` runs amd64-only against a freshly-built variant
|
||||
image. Verifies binaries, repo clones, runtime deployment (waits for
|
||||
keybindings + mempalace bridge + ≥4 extensions before sampling — fixes
|
||||
the parallel-build-load race documented in opencode-devbox c6f9d11
|
||||
2026-06-08), and image size threshold (3500 MB; revisit after a few
|
||||
releases as actuals settle).
|
||||
|
||||
If smoke fails on size threshold but build is otherwise fine: bump
|
||||
`SIZE_THRESHOLD_MB` in scripts/smoke-test.sh in a follow-up commit and
|
||||
re-run. The threshold exists to catch *runaway* growth (an accidental
|
||||
texlive bake-in, a forgotten chrome dependency), not to block ordinary
|
||||
upstream bumps.
|
||||
|
||||
## Build pipeline notes
|
||||
|
||||
- **Two-phase**: base + variant. Base is rebuilt only when
|
||||
`Dockerfile.base`, `rootfs/`, or `entrypoint*.sh` change (CI computes
|
||||
a content hash and probes Hub for an existing `base-<hash>` tag).
|
||||
- **`base-latest` alias** is promoted from `base-<hash>` via `crane copy`
|
||||
(manifest copy, no rebuild) only when the base actually changed.
|
||||
- **`docker buildx build --push` retry**: 3 attempts with backoff for
|
||||
transient Hub blips. Deterministic failures fail all 3 and the job
|
||||
fails as expected.
|
||||
- **Registry buildcache disabled**: buildkit's cache-export hits HTTP 400
|
||||
on Hub CDN since ~2026-05-23. Image push works fine; we pay the full
|
||||
base build on Dockerfile.base change, but base tags are content-
|
||||
addressed so unchanged bases short-circuit at the probe step.
|
||||
|
||||
## Decoupling history (briefly)
|
||||
|
||||
Pre-v1.0.0 pi-devbox was `FROM joakimp/pi-devbox:base-pi-only`, where
|
||||
`base-pi-only` was a tag built by **opencode-devbox CI** (with
|
||||
`INSTALL_OPENCODE=false` in their variant Dockerfile) and pushed under
|
||||
the pi-devbox repo as an internal building-block tag. This setup
|
||||
required rebuilding opencode-devbox before pi-devbox could be tagged
|
||||
and meant pi-devbox docs needed cross-referencing into opencode-devbox.
|
||||
|
||||
v1.0.0 brings pi install logic into this repo, drops the cross-repo
|
||||
dependency, and the `base-pi-only*` tags from opencode-devbox become
|
||||
deprecated artifacts (to be removed in opencode-devbox v2.0.0).
|
||||
|
||||
## What we DON'T install (and why)
|
||||
|
||||
- **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`).
|
||||
The default `:latest` image stays studio-free. Note: pi-studio binds
|
||||
`127.0.0.1` inside the container, so browser access needs host
|
||||
networking or the bundled `studio-expose` bridge (socat; auto-starts
|
||||
when `STUDIO_EXPOSE=1`) — see README "Using pi-studio".
|
||||
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
|
||||
Python REPLs; `apt install` other-language runtimes ad-hoc per
|
||||
container if needed.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- The host `~/.mempalace` bind-mount path is unchanged.
|
||||
- Volume names (`devbox-pi-config`, `devbox-ssh-local`,
|
||||
`devbox-shell-history`, `devbox-zoxide`, `devbox-nvim-data`,
|
||||
`devbox-uv`; optional `devbox-palace`, `devbox-chroma-cache`) are
|
||||
unchanged.
|
||||
- `~/.pi/agent/` layout inside the container is unchanged; existing
|
||||
named volumes work without recreation.
|
||||
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base + pi"
|
||||
image. Same tag, same shape, just built differently.
|
||||
|
||||
+1316
-2
File diff suppressed because it is too large
Load Diff
+83
-34
@@ -1,15 +1,21 @@
|
||||
# pi-devbox
|
||||
|
||||
A Docker container with [pi coding-agent](https://github.com/earendil-works/pi) pre-installed, built on top of [opencode-devbox](https://hub.docker.com/r/joakimp/opencode-devbox)'s base image. Pi gets a fully-loaded development environment in one `docker run`.
|
||||
A self-contained Docker container for the [pi coding-agent](https://github.com/earendil-works/pi) — pi + companion repos + MemPalace + a curated set of dev tooling, ready to run.
|
||||
|
||||
> **Current `:latest` ships pi `{{PI_VERSION}}`** (resolved at build time; see [Versioning](#versioning)).
|
||||
|
||||
## Image variants
|
||||
|
||||
| Tag | Size (compressed) | What you get |
|
||||
|---|---|---|
|
||||
| `joakimp/pi-devbox:latest` | ~700 MB | Pi + companion repos, on top of the opencode-devbox base |
|
||||
| `joakimp/pi-devbox:vX.Y.Z` | same | Pinned pi version (tracks the [pi npm package version](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) |
|
||||
| Tag | Architectures | Size (compressed) | What you get |
|
||||
|---|---|---|---|
|
||||
| `joakimp/pi-devbox:latest` | amd64, arm64 | ~1.1 GB | Self-contained: base + pi `{{PI_VERSION}}` + companions |
|
||||
| `joakimp/pi-devbox:vX.Y.Z` | amd64, arm64 | same | Pinned semver release |
|
||||
| `joakimp/pi-devbox:latest-studio` | amd64, arm64 | ~1.15 GB | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio): browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs |
|
||||
| `joakimp/pi-devbox:vX.Y.Z-studio` | amd64, arm64 | same | Pinned semver studio release |
|
||||
| `joakimp/pi-devbox:base-latest` | amd64, arm64 | ~1.0 GB | Base layer alias (internal building block; pull `:latest` instead) |
|
||||
| `joakimp/pi-devbox:base-<hash>` | amd64, arm64 | ~1.0 GB | Content-addressed base; immutable. Stable parent for variant rebuilds. |
|
||||
|
||||
Multi-arch: `linux/amd64`, `linux/arm64`.
|
||||
> **pi-studio (`-studio` tags):** launch with `/studio --no-browser --port 8765` inside a pi session. The server binds `127.0.0.1` **inside the container**, so reach it via host networking or a loopback bridge (and `ssh -L` for a remote host; mosh needs a parallel `ssh -L`). Full recipe: [README → Using pi-studio](https://gitea.jordbo.se/joakimp/pi-devbox#using-pi-studio--studio-variant).
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -38,42 +44,83 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
||||
|
||||
## What's inside
|
||||
|
||||
pi-devbox is a re-brand of the **pi-only build** — it builds
|
||||
`FROM joakimp/pi-devbox:base-pi-only` and adds no layers of its own. That
|
||||
building-block tag is produced by opencode-devbox's CI (from
|
||||
`Dockerfile.variant` with `INSTALL_OPENCODE=false`) but published here, in the
|
||||
pi-devbox repo, so an opencode-devbox tag never ships without opencode.
|
||||
The pi-only build is lean
|
||||
and pi-focused (no opencode — use `opencode-devbox:latest-with-pi` if you want
|
||||
both).
|
||||
Everything below is inherited from that single source of truth.
|
||||
### pi and companions
|
||||
|
||||
Base tooling:
|
||||
|
||||
- **Debian trixie** (latest stable)
|
||||
- **Node.js** (LTS), **uv** (Python tooling), **rustup** (Rust on-demand)
|
||||
- **AWS CLI v2** + AWS Bedrock-ready config
|
||||
- **MemPalace** + MCP server — persistent agent memory across sessions, queryable via `mempalace_*` tools inside pi
|
||||
- **Gitea MCP** server
|
||||
- **Dev tools**: neovim (LazyVim defaults), tmux, bat, eza, fzf, zoxide, ripgrep, git-lfs, make
|
||||
- **Shell**: bash with history tuning, prefix-search bindings, fzf/zoxide integration
|
||||
- **Host-OS-agnostic LAN access** — on VM-backed hosts (macOS OrbStack / Docker Desktop) the host is set up as an SSH jump to reach LAN peers (`dssh` alias; `DEVBOX_LAN_ACCESS`/`HOST_SSH_USER`). No-op on native Linux.
|
||||
|
||||
pi and companions:
|
||||
|
||||
- **pi** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — baked at `/usr/bin/pi`, version set by the pi-only base build
|
||||
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`
|
||||
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
|
||||
- **mempalace bridge** — MCP extension auto-symlinked so pi can read/write the same palace as opencode-devbox
|
||||
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
|
||||
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
|
||||
|
||||
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||
|
||||
### MemPalace (persistent agent memory)
|
||||
|
||||
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
|
||||
- ChromaDB ONNX embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
|
||||
- Bind-mount your host's `~/.mempalace` and the host-pi and container-pi share one brain
|
||||
|
||||
### Document and image tooling
|
||||
|
||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
||||
- **graphviz** (`dot`) — diagram rendering pipelines
|
||||
- **imagemagick** (`magick`) — image conversion / resizing
|
||||
|
||||
### Modern CLI tooling
|
||||
|
||||
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
|
||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||
- **Display**: bat, eza, htop, tree
|
||||
- **Data**: jq, yq
|
||||
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
|
||||
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
|
||||
- **Build**: gcc, g++, make, patch
|
||||
- **Misc**: gosu, age, rsync, less
|
||||
|
||||
### Language toolchains
|
||||
|
||||
- **Python**: system Python 3 + **uv** (preferred) for fast Python package management. Run any Python REPL/notebook stack on demand without bloating the image:
|
||||
```bash
|
||||
uv run --with ipython ipython
|
||||
uv run --with jupyterlab jupyter lab --no-browser --port 8888
|
||||
uv run --with marimo marimo edit
|
||||
```
|
||||
- **Node.js** v22 + npm (used by pi itself)
|
||||
- **Rust** — `rustup-init` is on PATH; install toolchains on demand
|
||||
- **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source
|
||||
|
||||
### Cloud + secrets
|
||||
|
||||
- **AWS CLI v2** — for SSO + Bedrock auth (pi's preferred LLM provider for the maintainer's setup)
|
||||
- **Gitea MCP** server — for Gitea API access from inside pi
|
||||
- **age**, **git-crypt** — encryption tooling
|
||||
|
||||
### SSH and networking
|
||||
|
||||
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
|
||||
- A **LAN-access helper** that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers (`dssh <peer>` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`).
|
||||
|
||||
## Versioning
|
||||
|
||||
Tags follow the pi npm version: `v0.74.0`, `v0.75.0`, etc. `latest` always points at the most recent release. The pi binary is inherited from `joakimp/pi-devbox:base-pi-only`, so each release follows an opencode-devbox release that bakes the target pi version. (`base-pi-only` is an internal building-block tag — pull `latest` or a `vX.Y.Z` tag instead.)
|
||||
From v1.0.0 onward, pi-devbox uses **semver**:
|
||||
|
||||
For container-level rebuilds on the same pi version (security updates, base bumps, fixes) the tag gets a letter suffix: `v0.74.0b`, `v0.74.0c`, …
|
||||
- **Major** — architectural changes. v1.0.0 is the first decoupled release, where pi-devbox got its own self-contained build chain (previously it was a thin re-brand of opencode-devbox's `pi-only` variant).
|
||||
- **Minor** — new image variants, significant base additions.
|
||||
- **Patch** — pi version bumps, smaller fixes.
|
||||
|
||||
The pi binary version inside any given release is shown in this description (currently **`{{PI_VERSION}}`** for `:latest`) and asserted by smoke tests to match what's documented — version drift is caught at CI time, not on user pull.
|
||||
|
||||
> **Pre-v1.0.0 history.** Tags v0.74.0…v0.79.0 followed the pi npm version directly (`v{pi_version}[letter]`). Those images remain on Hub but are deprecated in favor of `:latest` / `:v1.X.Y`. The legacy `:base-pi-only*` tags were CI artifacts of the old opencode-devbox-based build pipeline; they will be removed in a future opencode-devbox v2.0.0.
|
||||
|
||||
### Build pipeline
|
||||
|
||||
pi-devbox is built in two phases:
|
||||
|
||||
1. **Base** (`Dockerfile.base`) → `base-<hash>` tag, content-addressed over `Dockerfile.base` + `rootfs/` + `entrypoint*.sh`. Rebuilt only when those change.
|
||||
2. **Variant** (`Dockerfile.variant`) → `:latest` and `:vX.Y.Z`. FROMs the base, adds the pi install + companions.
|
||||
|
||||
`base-latest` is an alias of the most recent base.
|
||||
|
||||
## Persistent state
|
||||
|
||||
@@ -86,6 +133,7 @@ User edits and pi-installed packages survive container recreation when you mount
|
||||
| `devbox-zoxide` | `/home/developer/.local/share/zoxide` | zoxide directory jump database |
|
||||
| `devbox-nvim-data` | `/home/developer/.local/share/nvim` | neovim plugin & Mason package state |
|
||||
| `devbox-uv` | `/home/developer/.local/share/uv` | uv Python installs and tool cache |
|
||||
| `devbox-ssh-local` | `/home/developer/.ssh-local` | LAN-jump key (one-time host authorization survives recreate) |
|
||||
|
||||
Optional volumes for MemPalace (commented out by default — uncomment in `docker-compose.yml` to persist conversation memory across restarts):
|
||||
|
||||
@@ -101,11 +149,12 @@ Optional volumes for MemPalace (commented out by default — uncomment in `docke
|
||||
## Source
|
||||
|
||||
- **This image**: https://gitea.jordbo.se/joakimp/pi-devbox
|
||||
- **Base image**: https://gitea.jordbo.se/joakimp/opencode-devbox (Hub: `joakimp/opencode-devbox`)
|
||||
- **pi**: https://github.com/earendil-works/pi
|
||||
- **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit
|
||||
- **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions
|
||||
- **MemPalace**: https://github.com/MemPalace/mempalace
|
||||
|
||||
## 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).
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# pi-devbox — pi coding-agent container
|
||||
#
|
||||
# As of 2026-06-03 this image is a thin re-brand of the "pi-only" build, which
|
||||
# is the SINGLE SOURCE OF TRUTH for the pi install and its companion repos
|
||||
# (pi-toolkit, pi-extensions, pi-fork, pi-observational-memory). That build is
|
||||
# produced by opencode-devbox's CI (from opencode-devbox/Dockerfile.variant
|
||||
# with INSTALL_OPENCODE=false), but is published as an INTERNAL building-block
|
||||
# tag in THIS repo — joakimp/pi-devbox:base-pi-only — NOT under opencode-devbox.
|
||||
# Rationale: an "opencode-devbox" tag containing no opencode confuses
|
||||
# opencode-devbox users, so the pi-only artifact lives here instead.
|
||||
# Previously pi-devbox/Dockerfile duplicated the install logic, which drifted
|
||||
# from opencode-devbox/Dockerfile.variant; this refactor eliminates the dup.
|
||||
#
|
||||
# The pi-only build uses INSTALL_OPENCODE=false, so this image does NOT contain
|
||||
# opencode — it stays a lean, pi-focused image, distinct from
|
||||
# opencode-devbox:latest-with-pi (which carries both).
|
||||
#
|
||||
# Everything is inherited from the pi-only build:
|
||||
# pi + pi-toolkit + pi-extensions + pi-fork (fork) + pi-observational-memory
|
||||
# (recall), the mempalace bridge, the LAN-access helper, entrypoints, and
|
||||
# all base dev tooling.
|
||||
#
|
||||
# NOTE on PUBLISH ORDERING: rebuild opencode-devbox (so `base-pi-only` carries
|
||||
# the target pi version) BEFORE tagging this repo. The smoke test asserts
|
||||
# `pi --version` matches this repo's tag and fails loudly if the base is stale
|
||||
# — turning the version coupling into an enforced ordering check.
|
||||
#
|
||||
# base-pi-only is an internal building-block alias (existence-only, not for
|
||||
# end users — pull joakimp/pi-devbox:latest or a vX.Y.Z tag instead). Override
|
||||
# BASE_IMAGE to pin a specific pi-only build (a version tag or a digest).
|
||||
ARG BASE_IMAGE=joakimp/pi-devbox:base-pi-only
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD and all tooling inherited from the base.
|
||||
# No additional layers — the value here is the single-source-of-truth refactor.
|
||||
+683
@@ -0,0 +1,683 @@
|
||||
# pi-devbox — base image (variant-independent layers)
|
||||
#
|
||||
# This Dockerfile produces an image tagged base-<hash>, used as the parent
|
||||
# for all published variants of pi-devbox. It contains everything that does
|
||||
# not depend on variant-specific build-args (the pi install moves to
|
||||
# Dockerfile.variant).
|
||||
#
|
||||
# The base is rebuilt only when this file or anything it COPYs in changes
|
||||
# (rootfs/, entrypoint*.sh). Version bumps to PI_VERSION etc. do NOT
|
||||
# trigger a base rebuild.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
# content-addressed over this file, so any byte change invalidates the
|
||||
# cache. Recommended cadence: once per release for security updates.
|
||||
#
|
||||
# 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).
|
||||
# pi-devbox was previously a thin re-brand of opencode-devbox's pi-only
|
||||
# variant; this file is the start of an independent build chain. The
|
||||
# opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does
|
||||
# not appear here. The base is otherwise broadly equivalent so generic
|
||||
# upstream improvements (CVE updates, new dev tooling) can be cherry-
|
||||
# picked between repos.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="pi-devbox — base image (variant-independent)"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
#
|
||||
# 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. NOTE: pandoc is
|
||||
# only the front-end — PDF output needs a back-end engine.
|
||||
# We ship `typst` (installed further down) as the
|
||||
# lightweight default engine (`pandoc --pdf-engine=typst`)
|
||||
# instead of a ~600 MB TeX Live install.
|
||||
# xz-utils — `xz` decompressor. tar shells out to it for `.tar.xz`
|
||||
# assets (typst ships .tar.xz). ~0.5 MB. Also generally
|
||||
# useful for extracting xz-compressed archives.
|
||||
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
||||
# See the bundled `dot-watch` helper for live .dot -> PNG
|
||||
# re-render (handy with pi-studio's image preview).
|
||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||
# (yq is NOT apt-installed: Debian's `yq` is the unrelated Python tool;
|
||||
# mikefarah's Go yq is installed as a pinned binary further down.)
|
||||
# socat — TCP relay. Powers `studio-expose`, which bridges
|
||||
# pi-studio's container-loopback server to the container's
|
||||
# external interface so a published port can reach it.
|
||||
# ~1 MB; generally useful for any port-forwarding need.
|
||||
# nano — small, non-modal terminal editor for users who don't want
|
||||
# a vi-based editor. ~2.8 MB installed; its deps (libc6,
|
||||
# libncursesw6, libtinfo6) are already pulled in by nvim/less/
|
||||
# htop/tmux, so it adds no extra packages. Companion to nvim
|
||||
# and the `micro` binary installed further down. EDITOR stays
|
||||
# nvim; users opt in via `export EDITOR=nano`.
|
||||
# kitty-terminfo — terminfo entry for the kitty terminal (TERM=xterm-kitty).
|
||||
# ~77 KB, terminfo file only (no kitty binary). Without it,
|
||||
# ncurses apps fall back and Neovim can't reliably detect
|
||||
# true-colour from kitty over ssh; installing it makes
|
||||
# TERM=xterm-kitty understood. Pairs with the system-wide
|
||||
# Neovim termguicolors default (etc/xdg/nvim/sysinit.vim).
|
||||
# ncurses-term — broad terminfo bundle (wezterm, alacritty, foot, st, the
|
||||
# base `ghostty` entry, and many more) so SSHing in from a
|
||||
# modern emulator resolves its TERM instead of degrading to a
|
||||
# dumb fallback. xterm-kitty is NOT in it (hence kitty-terminfo
|
||||
# above); TERM=xterm-ghostty is compiled from an alias further
|
||||
# down (ncurses ships `ghostty`, not `xterm-ghostty`). iTerm2
|
||||
# defaults to xterm-256color (ncurses-base), so needs nothing.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
less \
|
||||
htop \
|
||||
tmux \
|
||||
make \
|
||||
patch \
|
||||
diffutils \
|
||||
git-crypt \
|
||||
age \
|
||||
file \
|
||||
sudo \
|
||||
locales \
|
||||
procps \
|
||||
unzip \
|
||||
gcc \
|
||||
g++ \
|
||||
rsync \
|
||||
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/*
|
||||
|
||||
# ── tmux defaults: 0-indexed windows and panes ───────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) hard-codes its tmux send target to
|
||||
# `<session>:0.0`. Containers that ship tmux with default options are
|
||||
# already 0-indexed; this file makes the assumption explicit so future
|
||||
# /etc/tmux.conf consumers can read it. Users can override per-user
|
||||
# in ~/.tmux.conf if they want 1-indexing — pi-studio will then fail
|
||||
# to find its REPL session.
|
||||
RUN printf '%s\n' \
|
||||
'# pi-devbox baked default — see Dockerfile.base.' \
|
||||
'# pi-studio targets tmux session :0.0; do not change these here.' \
|
||||
'set -g base-index 0' \
|
||||
'set -g pane-base-index 0' \
|
||||
> /etc/tmux.conf
|
||||
|
||||
# ── SSH client defaults: ControlMaster on a writable socket path ──────
|
||||
# Why this exists: the devbox typically mounts ~/.ssh from the host as
|
||||
# read-only (security: keys are readable, but agents can't tamper with
|
||||
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
|
||||
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
|
||||
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
|
||||
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
# kex_exchange_identification: Connection closed by remote host
|
||||
# The latter manifests downstream of CGNAT per-destination flow caps
|
||||
# (~4 concurrent flows on most European residential ISPs) which silently
|
||||
# drop further SYNs once exceeded — making fresh ssh attempts fail with
|
||||
# banner-exchange timeouts that look like a remote problem.
|
||||
#
|
||||
# Fix: set a system-wide default ControlPath in /tmp (per-container,
|
||||
# tmpfs-friendly, always writable) so multiplexing Just Works without
|
||||
# touching the read-only ~/.ssh mount. Per-host overrides in user's
|
||||
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# CAVEAT (and why it is handled elsewhere): a user per-host override that
|
||||
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
|
||||
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
|
||||
# failure — a system drop-in here can never override a user's per-host value.
|
||||
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
|
||||
# unwritable system ControlPath and falling back to its own /tmp master; for
|
||||
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
|
||||
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
# (mode 700) on each container start.
|
||||
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
||||
printf '%s\n' \
|
||||
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
||||
'# Override per-host in ~/.ssh/config if the master socket location' \
|
||||
'# needs to differ.' \
|
||||
'Host *' \
|
||||
' ControlMaster auto' \
|
||||
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
||||
' ControlPersist 10m' \
|
||||
' ServerAliveInterval 30' \
|
||||
' ServerAliveCountMax 6' \
|
||||
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
||||
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy: default is `latest` — resolved at build time by
|
||||
# following the /releases/latest redirect and reading the tag from the
|
||||
# Location header. Every base rebuild picks up the newest upstream
|
||||
# release. Explicit pins still work via build-args (e.g.
|
||||
# --build-arg GOSU_VERSION=1.19).
|
||||
|
||||
# gosu — privilege de-escalation
|
||||
ARG GOSU_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GOSU_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gosu ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder
|
||||
ARG FZF_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${FZF_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing fzf ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs
|
||||
ARG GIT_LFS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GIT_LFS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing git-lfs ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${V} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks
|
||||
ARG GITLEAKS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
||||
V="${GITLEAKS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitleaks ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
||||
chmod +x /usr/local/bin/gitleaks && \
|
||||
gitleaks version
|
||||
|
||||
# neovim
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${NVIM_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing neovim ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||
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) && \
|
||||
V="${BAT_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing bat ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||
rm -rf /tmp/bat-v${V}-* && \
|
||||
bat --version
|
||||
|
||||
# eza
|
||||
ARG EZA_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${EZA_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing eza ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||
eza --version | head -1
|
||||
|
||||
# zoxide
|
||||
ARG ZOXIDE_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${ZOXIDE_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing zoxide ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||
zoxide --version
|
||||
|
||||
# uv — fast Python package manager. Note: uv tags don't prefix with "v".
|
||||
ARG UV_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${UV_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing uv ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||
rm -rf /tmp/uv-* && \
|
||||
uv --version
|
||||
|
||||
# ── MemPalace — local-first AI memory system ─────────────────────────
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
||||
# time to shave ~300 MB.
|
||||
#
|
||||
# Stall protection (fixed 2026-06-13; self-heal added 2026-06-25):
|
||||
# mempalace-mcp is launched by the `mempalace.ts` pi extension from
|
||||
# mempalace-toolkit (cloned below). That extension applies a per-REQUEST
|
||||
# timeout in its JSON-RPC client and kills the child on stall, so a virtiofs
|
||||
# cold-open of chroma.sqlite3 / HNSW load can no longer hang the pi TUI
|
||||
# uninterruptibly. A stall-kill is no longer a permanent latch either: the
|
||||
# next tool call respawns the server with capped exponential backoff (the
|
||||
# budget resets on any successful response). Tunables:
|
||||
# MEMPALACE_MCP_TIMEOUT_MS (default 60000), MEMPALACE_MCP_INIT_TIMEOUT_MS
|
||||
# (default 300000 — generous so a genuine first cold-open isn't killed),
|
||||
# MEMPALACE_MCP_MAX_RESPAWNS (default 2; 0 disables self-heal),
|
||||
# MEMPALACE_MCP_RESPAWN_BACKOFF_MS (default 1000); timeouts of 0 disable.
|
||||
# Defaults live in the extension, so no ENV is needed here. A standalone
|
||||
# stdio-watchdog shim is NOT needed — the extension already owns
|
||||
# request/response correlation. See CHANGELOG.md "Unreleased > Fixed".
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
# Pin to a known-good version. Bump deliberately, not implicitly: an
|
||||
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
|
||||
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff
|
||||
# rather than a surprise.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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 \
|
||||
mkdir -p /opt/uv-tools && \
|
||||
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
|
||||
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||
fi
|
||||
|
||||
# (The mempalace diary_write top-level-anyOf workaround that patched
|
||||
# mcp_server.py here was removed in v1.2.2 — fixed upstream in mempalace
|
||||
# 3.5.0 via issue #1728 / PR #1717 (merged 2026-06-14). See CHANGELOG.md.)
|
||||
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is
|
||||
# overridable so a relocated/forked build can clone from a mirror or a
|
||||
# different host without editing this Dockerfile (mirrors the
|
||||
# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant).
|
||||
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
|
||||
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
|
||||
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
|
||||
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
|
||||
# --branch <40-char-SHA>` fails ("Remote branch not found") — the same
|
||||
# footgun fixed in Dockerfile.variant (v1.0.0-rerun, run 374) — so use
|
||||
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
|
||||
git -C /opt/mempalace-toolkit init -q && \
|
||||
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
|
||||
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
|
||||
chmod +x /usr/local/bin/rustup-init
|
||||
|
||||
# gitea-mcp — MCP server for Gitea API
|
||||
ARG GITEA_MCP_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${GITEA_MCP_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitea-mcp ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
||||
chmod +x /usr/local/bin/gitea-mcp && \
|
||||
gitea-mcp --version
|
||||
|
||||
# Locales
|
||||
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
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) ──
|
||||
ARG NODE_VERSION=22
|
||||
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
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.
|
||||
ARG TEALDEER_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${TEALDEER_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing tealdeer ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
|
||||
chmod +x /usr/local/bin/tldr && \
|
||||
tldr --version
|
||||
|
||||
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
|
||||
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
|
||||
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
|
||||
# a single ~30 MB static Rust binary with no LaTeX dependency. pi-studio's
|
||||
# PDF export (studio_export_pdf) and pandoc invocations use it via
|
||||
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-
|
||||
# fidelity fallback for anyone who needs LaTeX-exact output (not shipped
|
||||
# here — install on demand or in a future variant).
|
||||
#
|
||||
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
|
||||
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
|
||||
# that extracts to typst-<arch>-unknown-linux-musl/typst. Pin a specific
|
||||
# tag with --build-arg TYPST_VERSION=vX.Y.Z.
|
||||
#
|
||||
# We also patch pandoc's bundled typst template
|
||||
# (/usr/share/pandoc/data/templates/template.typst): its conf() defaults the
|
||||
# document font to an empty tuple (`font: ()`), so a naked
|
||||
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
|
||||
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
|
||||
# (typst's own bundled default font) so PDF export works out-of-the-box.
|
||||
ARG TYPST_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${TYPST_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing typst ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/download/v${V}/typst-${ARCH}-unknown-linux-musl.tar.xz" | tar -xJ -C /tmp && \
|
||||
install /tmp/typst-${ARCH}-unknown-linux-musl/typst /usr/local/bin/typst && \
|
||||
rm -rf /tmp/typst-${ARCH}-unknown-linux-musl && \
|
||||
typst --version && \
|
||||
sed -i 's/^ font: (),$/ font: ("Libertinus Serif",),/' /usr/share/pandoc/data/templates/template.typst && \
|
||||
grep -q 'font: ("Libertinus Serif",),' /usr/share/pandoc/data/templates/template.typst
|
||||
|
||||
# ── yq (mikefarah) — YAML processor, jq's companion for YAML ─────────
|
||||
# Installed as the mikefarah Go binary — NOT Debian's `yq` apt package, which
|
||||
# is the unrelated Python kislyuk/yq (a jq wrapper with different syntax and
|
||||
# version line, e.g. 3.x). The cloud-init repo's deploy.sh/provision.sh
|
||||
# require mikefarah yq v4 (the unrelated Debian python yq is v3.x). Follows
|
||||
# the repo's `latest` convention (like tealdeer/uv/etc.); the smoke test pins
|
||||
# the contract to major v4, so a future yq v5 fails CI instead of silently
|
||||
# breaking provision.sh. Pin a specific tag with --build-arg YQ_VERSION=vX.Y.Z.
|
||||
ARG YQ_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${YQ_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing mikefarah yq ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${V}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
|
||||
chmod +x /usr/local/bin/yq && \
|
||||
yq --version
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
arm64) echo "aarch64" ;; \
|
||||
*) echo "x86_64" ;; \
|
||||
esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||
/tmp/aws/install && \
|
||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||
aws --version
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
ARG USER_NAME=developer
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Standard directories
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
||||
ef = ONNXMiniLM_L6_V2(); \
|
||||
_ = ef(['warmup']); \
|
||||
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# Build-time installs use NPM_CONFIG_PREFIX=/usr (see Dockerfile.variant).
|
||||
# Runtime npm/pi installs use this prefix → land on the named volume.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.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/
|
||||
# Image-baked skills + the global-AGENTS append snippet. Under /usr/local so a
|
||||
# named volume over a home dir can't shadow them; linked into ~/.agents/skills
|
||||
# by entrypoint-user.sh, and the snippet is concatenated onto the global
|
||||
# AGENTS.md in Dockerfile.variant (after pi-toolkit, which owns that file).
|
||||
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
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["bash", "-l"]
|
||||
@@ -0,0 +1,275 @@
|
||||
# pi-devbox — variant image
|
||||
#
|
||||
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
||||
# the variant-specific tools — currently just the pi install. Kept as a
|
||||
# separate file (rather than collapsed into Dockerfile.base) so future
|
||||
# variants (e.g. studio, studio-tex) can FROM the variant or extend
|
||||
# this Dockerfile with additional build args without rebuilding the
|
||||
# base on every pi version bump.
|
||||
#
|
||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||
# CI computes the base hash from Dockerfile.base + rootfs/ +
|
||||
# entrypoint*.sh and feeds it in.
|
||||
#
|
||||
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
||||
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
||||
# `npm install -g` by the developer user lands on the named volume.
|
||||
# At BUILD time we want the baked binaries on /usr so they survive the
|
||||
# volume mount. Each `npm install -g` below therefore prefixes the
|
||||
# command with `NPM_CONFIG_PREFIX=/usr`.
|
||||
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG USER_NAME=developer
|
||||
|
||||
# ── pi coding-agent + companions ─────────────────────────────────────
|
||||
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
||||
# runs each repo's install.sh on container start so symlinks land under
|
||||
# ~/.pi/agent/ on the named volume.
|
||||
#
|
||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
||||
# (resolved from `npm view @earendil-works/pi-coding-agent version`).
|
||||
# The default `latest` is for local dev convenience only — it has a
|
||||
# known cache-hit footgun in registry-cached CI builds: the resulting
|
||||
# build-arg string is byte-identical across builds, the layer-hash is
|
||||
# identical, and the registry buildcache silently reuses the layer
|
||||
# from whatever pi version was current when the cache was first
|
||||
# populated. CI MUST pass a resolved concrete version. See pi-devbox
|
||||
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
# relocated/forked build can clone from a mirror or a different host
|
||||
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
|
||||
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
|
||||
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
|
||||
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
||||
# cache-hit footgun that affects PI_VERSION.
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
# OR a commit SHA as $ref. Uses `git fetch <ref> + checkout FETCH_HEAD`
|
||||
# which (a) works with both name and SHA forms uniformly, and (b) defeats
|
||||
# the registry-buildcache footgun when CI passes a resolved SHA. The
|
||||
# earlier helper `git_clone_retry` (using `git clone --branch`) only
|
||||
# worked with branch names — a SHA-resolved build-arg made `git clone
|
||||
# --branch <40-char-SHA>` fail with "Remote branch not found". Surfaced
|
||||
# in pi-devbox v1.0.0-rerun (run 374) 2026-06-10 and fixed by switching
|
||||
# all four clones to git_fetch_ref. Both Gitea and GitHub allow fetching
|
||||
# arbitrary commits by default (uploadpack.allowReachableSHA1InWant).
|
||||
git_fetch_ref() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
rm -rf "$dest"; mkdir -p "$dest"; \
|
||||
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
||||
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
|
||||
|
||||
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
||||
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
||||
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
||||
# skill is always in the image). The pi-extensions PACKAGE repo now co-locates
|
||||
# the canonical skill under skill/, so here — after the pinned clone — we copy
|
||||
# that over the snapshot. Result: a normal build ships the fresh, package-owned
|
||||
# copy (pinned + recorded in the manifest via PI_EXTENSIONS_REF); a build whose
|
||||
# ref predates the skill, or a fork pointing at a mirror without it, still ships
|
||||
# the committed snapshot. The skill calls ./evaluate-extension-usage.py, so it
|
||||
# is copied alongside. Idempotent and cache-safe (depends only on the clone).
|
||||
RUN if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
|
||||
cp /opt/pi-extensions/skill/SKILL.md \
|
||||
/usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md && \
|
||||
if [ -f /opt/pi-extensions/skill/evaluate-extension-usage.py ]; then \
|
||||
cp /opt/pi-extensions/skill/evaluate-extension-usage.py \
|
||||
/usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py ; \
|
||||
fi && \
|
||||
echo "refreshed pi-extensions skill from package @ $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
||||
else \
|
||||
echo "pi-extensions package has no skill/ at this ref — keeping vendored snapshot" ; \
|
||||
fi
|
||||
|
||||
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
|
||||
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
|
||||
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
|
||||
# every container start. There is no second global slot, and that file is
|
||||
# root-owned (not writable by the runtime user), so we compose at BUILD time:
|
||||
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
|
||||
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
|
||||
# makes every container proactively aware of the pi-devbox-environment skill;
|
||||
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
|
||||
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
|
||||
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
|
||||
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
||||
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
||||
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
|
||||
else \
|
||||
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
|
||||
fi
|
||||
|
||||
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
|
||||
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
|
||||
# preview, and tmux-backed literate REPLs. Off by default; the studio
|
||||
# variant sets INSTALL_STUDIO=true.
|
||||
#
|
||||
# Vendored to /opt/pi-studio and registered at container start by
|
||||
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
|
||||
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
|
||||
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
|
||||
# which is a named volume, so a build-time install collides with / is
|
||||
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
|
||||
# + a runtime local-path install keeps it on the image and idempotent.
|
||||
#
|
||||
# No build step is needed: pi-studio ships its browser bundle prebuilt in
|
||||
# git (client/studio-client.js) and pi loads index.ts directly; its
|
||||
# package.json scripts are only test/typecheck. So we just fetch + install
|
||||
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
|
||||
#
|
||||
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
|
||||
# buildcache cache-hit footgun (see the PI_VERSION note above).
|
||||
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 && \
|
||||
git -C /opt/pi-studio init -q && \
|
||||
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
|
||||
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
||||
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
||||
fi
|
||||
|
||||
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
|
||||
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
|
||||
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
|
||||
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
|
||||
# so reaching it from a browser needs a loopback bridge or host networking —
|
||||
# see the "Using pi-studio" section in README.md.
|
||||
ENV STUDIO_PORT=8765
|
||||
|
||||
# ── Optional: Go toolchain ───────────────────────────────────────────
|
||||
# Off by default; opt in for users who run Go tools inside the devbox.
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=latest
|
||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
||||
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing Go ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
||||
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Build provenance: OCI labels + on-disk build manifest ────────────
|
||||
# Records exactly which pi version and companion-repo commits were baked
|
||||
# into THIS image, so a published tag is self-describing and reproducible
|
||||
# after the fact (CI logs rotate; a released image must not depend on
|
||||
# them). Previously the resolved SHAs only ever reached the CI build log.
|
||||
#
|
||||
# These ARGs are declared LAST, immediately before the layer that uses
|
||||
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
|
||||
# invalidates the expensive pi-install / clone layers above.
|
||||
ARG RELEASE_TAG=dev
|
||||
ARG BUILD_DATE=
|
||||
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-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
|
||||
# build-args. That way it also exposes a clone that silently resolved to
|
||||
# something other than the requested ref. pi-studio is present only in the
|
||||
# studio variant (JSON null otherwise).
|
||||
RUN set -e; \
|
||||
mkdir -p /etc/pi-devbox; \
|
||||
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
||||
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
||||
STUDIO_REV='null'; \
|
||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||
{ \
|
||||
echo '{'; \
|
||||
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||
echo " \"pi_version\": \"${PI_V}\","; \
|
||||
echo " \"components\": {"; \
|
||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
||||
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
||||
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
||||
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
||||
echo " }"; \
|
||||
echo '}'; \
|
||||
} > /etc/pi-devbox/build-manifest.json; \
|
||||
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+18
-6
@@ -16,9 +16,14 @@ services:
|
||||
# To build from source instead of pulling from Docker Hub:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.variant
|
||||
# args:
|
||||
# # Pin a specific pi-only build instead of tracking base-pi-only:
|
||||
# BASE_IMAGE: "joakimp/pi-devbox:base-pi-only-v1.15.13c"
|
||||
# # Pin a specific base build by hash instead of tracking base-latest:
|
||||
# BASE_IMAGE: "joakimp/pi-devbox:base-<hash>"
|
||||
# # PI_VERSION must be a concrete version, not 'latest', to defeat
|
||||
# # the registry-buildcache cache-hit footgun. CI resolves this from
|
||||
# # the npm registry; for a local build you can set it manually.
|
||||
# PI_VERSION: "0.79.1"
|
||||
container_name: pi-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -26,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
|
||||
@@ -67,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)
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# Design: single-writer MemPalace broker (cross-host serialization)
|
||||
|
||||
> **Status:** DRAFT / RFC — not yet implemented. Captures the design so it can be
|
||||
> picked up later. Authored 2026-06-14.
|
||||
> **Owner:** unassigned. **Tracking:** queue item #4 ("host-side mempalace-mcp
|
||||
> daemon over a UNIX/shared socket").
|
||||
|
||||
## Problem
|
||||
|
||||
The pi-devbox container's `~/.mempalace` (`/home/developer/.mempalace`) is a
|
||||
**virtiofs bind-mount of the host's `/Users/joakim/.mempalace`** (verified
|
||||
2026-06-14 via `/proc/mounts`: `mac /home/developer/.mempalace virtiofs rw`).
|
||||
Container pi and host-native pi therefore **read and write ONE shared palace** —
|
||||
full memory parity already exists; nothing needs to be built to *enable* sharing.
|
||||
|
||||
The actual hazard is the opposite of sharing: **concurrency**. Two pi processes
|
||||
(one native on the host, one in the container) can open the same
|
||||
`chroma.sqlite3` / `knowledge_graph.sqlite3` and write at the same time. The
|
||||
palace directory already shows the scars of this:
|
||||
|
||||
- `chroma.sqlite3.broken-20260505`
|
||||
- many `*.corrupt-20260528`
|
||||
- a long run of `*.drift-2026*`
|
||||
- `locks/` with `mine_palace_*.lock` files, including a **stale** one.
|
||||
|
||||
These are mempalace's defensive lock + auto-snapshot/repair machinery firing
|
||||
under concurrent access.
|
||||
|
||||
### Why a shared lock file is NOT sufficient
|
||||
|
||||
The container runs inside a Linux VM (OrbStack / Docker Desktop on macOS); the
|
||||
palace bytes live on the macOS host, surfaced into the VM via virtiofs.
|
||||
Consequences:
|
||||
|
||||
- A **UNIX-domain socket file** visible at `~/.mempalace/broker.sock` inside the
|
||||
container is a *host-kernel* object. The container's kernel can see the inode
|
||||
but **cannot connect to it** across the VM boundary.
|
||||
- **flock / advisory lockfiles are not coherent across the host↔VM boundary.**
|
||||
A lock taken on the host is not reliably seen in the container and vice-versa.
|
||||
(The stale `mine_palace_*.lock` is direct evidence the existing lock scheme is
|
||||
not bulletproof across this boundary.)
|
||||
|
||||
**Therefore the only trustworthy serialization is to route every write through a
|
||||
single process.** That single process is the broker. The design question is *not*
|
||||
"how do we lock" — it's "**where does the one writer live, and how does every pi
|
||||
(host or container) reach it across the VM boundary?**"
|
||||
|
||||
## Goals
|
||||
|
||||
1. Exactly one process opens the palace SQLite files at any time (single writer;
|
||||
concurrent reads are fine).
|
||||
2. Works in all three topologies on a given host:
|
||||
- native pi only,
|
||||
- native pi + container pi,
|
||||
- container pi only.
|
||||
3. pi configuration is **identical** in every topology (no per-environment MCP
|
||||
config divergence).
|
||||
4. No new corruption pathway introduced; degrade safely when the broker is
|
||||
genuinely unreachable and there are no peers.
|
||||
|
||||
### Non-goals (for this iteration)
|
||||
|
||||
- opencode / opencode-devbox co-existence (see "Co-existence with opencode"
|
||||
below — deferred until the pi case is solved).
|
||||
- Multi-host palace replication. This is about one host's local palace.
|
||||
- Changing mempalace's on-disk format or its public MCP tool surface.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pi (host) ─stdio─► mp-shim ─┐
|
||||
├─► mempalace-broker ─► chroma.sqlite3
|
||||
pi (ctr) ─stdio─► mp-shim ─┘ (SINGLE owner; knowledge_graph.sqlite3
|
||||
serialized writer, + in-memory HNSW index
|
||||
concurrent readers)
|
||||
```
|
||||
|
||||
### `mempalace-broker`
|
||||
|
||||
A long-lived process that is the **only** opener of the palace SQLite files. It:
|
||||
|
||||
- runs the real mempalace engine,
|
||||
- holds the HNSW index in memory,
|
||||
- pushes all mutations through a single writer queue (reads may fan out),
|
||||
- exposes the mempalace MCP JSON-RPC surface over one or more transports,
|
||||
- is the canonical owner of palace state for the lifetime of the host session.
|
||||
|
||||
**Bonus:** a single always-resident owner also eliminates the stale-HNSW-index
|
||||
problem that `mempalace_reconnect` exists to work around — there is never an
|
||||
external writer to desync the in-memory index against.
|
||||
|
||||
### `mp-shim`
|
||||
|
||||
A tiny stdio↔transport adapter. pi's mempalace MCP config points at the shim
|
||||
**everywhere, unchanged**. pi still believes it is speaking stdio MCP to a local
|
||||
server; the shim forwards JSON-RPC to the broker over whichever transport is
|
||||
available, and handles all discovery / startup / election complexity. Keeping
|
||||
pi's config identical across topologies is a hard requirement (goal #3) and the
|
||||
shim is what makes it possible.
|
||||
|
||||
## Canonical owner = the host
|
||||
|
||||
The broker's home is **always the host**, because:
|
||||
|
||||
1. The palace bytes physically live there (`/Users/joakim/.mempalace`).
|
||||
2. The host outlives any container — ownership does not evaporate on
|
||||
`docker compose down`.
|
||||
3. Containers already have a route back to it (`host.docker.internal` and the
|
||||
verified dssh ControlMaster bridge).
|
||||
|
||||
The broker binds **two listeners feeding one queue**:
|
||||
|
||||
- **AF_UNIX** at `$MEMPALACE_PATH/broker.sock` — for host-native pi (fast,
|
||||
filesystem-perms-secured).
|
||||
- a **cross-boundary** transport for container clients (below).
|
||||
|
||||
## Transport matrix
|
||||
|
||||
| Topology | Broker runs on | Host pi reaches it via | Container pi reaches it via |
|
||||
|---|---|---|---|
|
||||
| native only | host | AF_UNIX socket | — |
|
||||
| native + container | host | AF_UNIX socket | SSH-forwarded socket (preferred) or TCP |
|
||||
| container only | host (started via bridge) | — | SSH-forwarded socket or TCP |
|
||||
|
||||
### Cross-boundary transport options
|
||||
|
||||
**(a) SSH-forwarded UNIX socket over the existing dssh ControlMaster — PREFERRED.**
|
||||
The container's `setup-lan-access.sh` already establishes a ControlMaster to the
|
||||
host with `ControlPersist 4h`. The container shim forwards the host broker socket
|
||||
over that master:
|
||||
|
||||
```
|
||||
ssh -F ~/.ssh-local/config \
|
||||
-L "$XDG_RUNTIME_DIR/mp.sock:$HOME/.mempalace/broker.sock" host
|
||||
```
|
||||
|
||||
then connects to the local forwarded socket. Auth = SSH key; nothing is
|
||||
LAN-exposed; no extra shared secret needed; rides the persistent master so setup
|
||||
cost is near-zero. Most portable across non-OrbStack hosts.
|
||||
|
||||
**(b) TCP on `host.docker.internal:PORT` — fallback.** Simpler, but the broker
|
||||
must bind a routable interface (not just `127.0.0.1`), which requires a
|
||||
**shared-secret token** to prevent other local/LAN processes from talking to it.
|
||||
The token is written to `broker.json` in the virtiofs-mounted palace dir
|
||||
(readable from both sides). More care required to get the bind + auth right.
|
||||
|
||||
## Discovery + on-demand start (the shim's algorithm)
|
||||
|
||||
Run by the shim on every pi session start, so it is correct regardless of who is
|
||||
already running:
|
||||
|
||||
```
|
||||
1. If $MEMPALACE_BROKER is set → use it verbatim (escape hatch).
|
||||
2. Read $MEMPALACE_PATH/broker.json → endpoint + pid + token.
|
||||
Try to connect (UNIX if host; forwarded-sock / TCP if container).
|
||||
If connected & healthy → done.
|
||||
3. Broker not reachable → START IT:
|
||||
- On host: flock($MEMPALACE_PATH/broker.lock, non-blocking)
|
||||
win → exec broker, wait for broker.json, connect.
|
||||
lose → someone else is starting it; backoff + retry connect.
|
||||
- In container: run `ssh host 'mempalace-broker --ensure'` (idempotent;
|
||||
performs the SAME flock election ON THE HOST), then forward +
|
||||
connect.
|
||||
4. Last-resort fallback (no broker, cannot start one):
|
||||
open the palace DIRECTLY — but ONLY after asserting this process is the sole
|
||||
writer (no other live broker/pid recorded in broker.json). Degrades to
|
||||
today's behaviour for the genuinely-alone case; never used when a broker
|
||||
exists.
|
||||
```
|
||||
|
||||
**Key trick:** host-side election uses `flock` on the host, where it is coherent
|
||||
(same kernel) — bulletproof. The cross-boundary case **never relies on cross-VM
|
||||
locking**; it relies on `ssh host 'broker --ensure'`, which runs the election on
|
||||
the host where flock works. That is what makes the design topology-independent.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- Broker writes `broker.json` (endpoint + pid + token) **atomically** after
|
||||
binding.
|
||||
- Broker holds `broker.lock` for its entire lifetime → at most one host broker.
|
||||
- Idle-exit after N minutes with no connected clients; the next client
|
||||
re-elects. (Or keep-alive; idle-exit is friendlier on resources.)
|
||||
- Clients reclaim a stale lock if the pid recorded in `broker.json` is dead.
|
||||
- Clients retry with backoff while a broker is mid-startup.
|
||||
|
||||
## Engine vs. shim — what the image must still ship
|
||||
|
||||
The component bundled in the images today is really **two separable pieces**:
|
||||
|
||||
- the **mempalace engine** — opens the SQLite files, computes embeddings, owns
|
||||
the HNSW index (the heavy part: chromadb, embedding model, etc.), and
|
||||
- the thin client surface pi actually talks to.
|
||||
|
||||
In the brokered design these split cleanly:
|
||||
|
||||
- the **broker** is the only thing that runs the *engine*;
|
||||
- the **shim** is **engine-free** — it just forwards MCP JSON-RPC. It needs no
|
||||
chromadb, no embedding model, no heavy deps. Embeddings/search happen
|
||||
broker-side. (Potential image-slimming opportunity, though see below for why
|
||||
we keep the engine bundled anyway.)
|
||||
|
||||
Whether the bundled engine is "used as-is" or merely fronted by the broker
|
||||
**depends on who owns the broker**:
|
||||
|
||||
**A) Host runs the broker (native, or native+container — the common case).**
|
||||
The *host's* engine is authoritative and used as-is. The broker is purely an
|
||||
intermediate step so writes can't collide; the host engine does the read/write.
|
||||
The container's **bundled engine is dormant** — the container uses only its shim
|
||||
to reach the host broker. The engine in the image is not needed for this path.
|
||||
|
||||
**B) Container lands on a host with no mempalace (fresh-host case).**
|
||||
The bundled engine earns its keep — you cannot conjure an engine onto the host
|
||||
without installing one. Either the container runs the broker *itself*
|
||||
(in-container ownership, bundled engine used as-is) or it falls back to degraded
|
||||
direct mode (single writer, bundled engine used directly).
|
||||
|
||||
**Decision: keep shipping the engine in the images** — but for three specific
|
||||
reasons, not because the brokered path needs it:
|
||||
|
||||
1. **Self-containedness** — pi-devbox's promise is "works on any host." A
|
||||
container with no memory unless the host pre-installed mempalace breaks that,
|
||||
especially for the Docker Hub audience.
|
||||
2. **Fresh-host bootstrap** (case B) — no host engine to borrow.
|
||||
3. **Degraded fallback** — the no-broker-reachable path opens the DB locally and
|
||||
needs the engine present.
|
||||
|
||||
In the host-managed common case the bundled engine is just dormant insurance;
|
||||
the shim is the only piece the container actively uses.
|
||||
|
||||
### Version-coherence note
|
||||
|
||||
Because **only the broker's engine ever writes**, its version defines the
|
||||
on-disk format. Host-vs-bundled engine version skew is therefore **harmless in
|
||||
the brokered path** (only one engine ever touches the bytes). Skew only bites in
|
||||
**degraded direct mode**, where the container writes with a possibly-different
|
||||
engine version than the host would. This argues for the broker pinning/owning
|
||||
the authoritative engine version and treating the bundled engine as
|
||||
fallback-only.
|
||||
|
||||
> Partially resolves the "where the broker binary ships" open question below:
|
||||
> the **shim** must ship on both sides; the **engine** must ship on the host
|
||||
> (to run the broker) and stays bundled in the image as fallback/bootstrap
|
||||
> insurance, not as the authoritative writer in the common case.
|
||||
|
||||
## The genuinely hard case
|
||||
|
||||
**Container-only with no SSH bridge configured** (e.g. plain Linux Docker,
|
||||
`HOST_SSH_USER` unset, no `host.docker.internal`). The container cannot start or
|
||||
reach a host broker. Options, none free:
|
||||
|
||||
1. **Require the bridge** for multi-writer container setups, and document it as a
|
||||
precondition. Reasonable: pi-devbox already ships `setup-lan-access.sh` and
|
||||
the bridge is the supported path.
|
||||
2. **Run the broker inside the container**, publishing a Docker port the host can
|
||||
later reach. Works, but inverts ownership and the broker dies with the
|
||||
container — only acceptable if containers are the *sole* writers on that host.
|
||||
3. **Accept degraded mode** (algorithm step 4): a lone container with no peers
|
||||
has no concurrency, so direct access is safe *as long as* nothing else opens
|
||||
the palace concurrently. The host shim also checks `broker.json` before
|
||||
opening directly, so a later host pi will not silently start a second
|
||||
uncoordinated writer.
|
||||
|
||||
**Summary:** fully robust for native-only, native+container, and
|
||||
container-only-with-bridge. The only residual sharp edge is container-only
|
||||
*without* a bridge *and* a future concurrent host writer — intrinsic (no shared
|
||||
coherent lock exists across that boundary), best handled by mandating the bridge
|
||||
rather than pretending file locks work.
|
||||
|
||||
## Co-existence with opencode / opencode-devbox (DEFERRED — context only)
|
||||
|
||||
The palace is shared by more than pi. opencode (native) and opencode-devbox
|
||||
(container) also write to the same `~/.mempalace`. **Assumption to verify:**
|
||||
opencode sessions write to **different wings** than pi sessions (pi uses
|
||||
`wing_pi`, diaries per-agent, etc.), so cross-tool intermixing into the *same*
|
||||
destination may be a non-issue at the application level.
|
||||
|
||||
However, the corruption risk here is at the **SQLite-file level, not the wing
|
||||
level** — two processes writing different wings of the *same* `chroma.sqlite3`
|
||||
concurrently is still a concurrent write to one file. So the broker, once it
|
||||
exists, is the right serialization point for opencode too: opencode's mempalace
|
||||
client would route through the same broker via the same shim mechanism.
|
||||
|
||||
**Decision:** do not design for opencode co-existence yet. Resolve the pi case
|
||||
first; then revisit whether opencode clients adopt the same shim. The residual
|
||||
risk in the interim is native + container *opencode* sessions writing the same
|
||||
palace simultaneously — explicitly deferred ("cross that bridge later").
|
||||
|
||||
## Open questions / TODO before implementation
|
||||
|
||||
- Does the mempalace engine expose an embeddable entrypoint suitable for running
|
||||
inside a long-lived broker, or does the broker wrap the existing MCP server
|
||||
binary and multiplex stdio clients onto it? (Affects whether reads can truly
|
||||
fan out or are also serialized.)
|
||||
- Idle-exit timeout default + whether to expose it via env.
|
||||
- `broker.json` schema + atomic-write + stale-pid-reclaim details.
|
||||
- TCP-path token handling and safe bind interface selection on Linux Docker
|
||||
(`--add-host=host.docker.internal:host-gateway`).
|
||||
- Where the broker binary ships: baked into `Dockerfile.base`? host install via
|
||||
pi-toolkit / mempalace-toolkit? Both, since both sides need the shim and the
|
||||
host needs the broker.
|
||||
- Smoke-test plan: prove single-writer invariant under a deliberate concurrent
|
||||
host+container write storm (should produce zero `.corrupt`/`.drift` snapshots).
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/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
|
||||
# creates the directory with the right permissions on every container
|
||||
# start. /tmp is per-container so the dir doesn't survive recreation;
|
||||
# baking it into a Dockerfile layer would be wrong.
|
||||
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
||||
# others can write to.
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
|
||||
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
|
||||
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
|
||||
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
|
||||
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
|
||||
# SSH-jump-via-host block so the container can reach the host's
|
||||
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
|
||||
# jump block is omitted but the sidecar is still rendered. Controlled by
|
||||
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
|
||||
# script header.
|
||||
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
|
||||
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
|
||||
fi
|
||||
|
||||
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
||||
# Respects host bind-mounts and user customizations — existing files
|
||||
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
||||
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
|
||||
# directly.
|
||||
SKEL_DIR="/etc/skel-devbox"
|
||||
if [ -d "$SKEL_DIR" ]; then
|
||||
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
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
|
||||
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
|
||||
# made available regardless of whether a skillset repo is mounted. Done EARLY
|
||||
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
|
||||
# the time anything gates on "container ready": the smoke-test readiness probe
|
||||
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
|
||||
# AFTER this point, so linking here closes a sample-too-early race that failed
|
||||
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
||||
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
||||
# anything baked under a home dir, which a named volume would shadow). Created
|
||||
# only when absent, so a same-named skillset skill (deployed later, at the end
|
||||
# of this script) or a user override is never clobbered; the skillset deploy
|
||||
# classifies these as foreign-links and its --prune-stale pass leaves them
|
||||
# alone (only dangling symlinks are pruned).
|
||||
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
||||
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
||||
mkdir -p "$HOME/.agents/skills"
|
||||
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
|
||||
[ -d "$_sk" ] || continue
|
||||
_skname=$(basename "$_sk")
|
||||
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
||||
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||
# Creates the palace directory structure on first run. Idempotent — skips
|
||||
# if palace already exists, so upgrades from older versions preserve
|
||||
# existing data. `--yes` auto-accepts detected entities so the init is
|
||||
# non-interactive.
|
||||
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
PALACE_DIR="${HOME}/.mempalace"
|
||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
# Without redirected stdin, the process blocks here forever when run
|
||||
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
||||
# makes the prompt fall through to its default (skip).
|
||||
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git config defaults ──────────────────────────────────────────────
|
||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
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.
|
||||
# Each install.sh is idempotent and backs up real files before linking,
|
||||
# so re-running across container restarts is safe.
|
||||
#
|
||||
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
||||
# and writes the AWS env loader), then pi-extensions (symlinks our
|
||||
# extensions), then settings.json bootstrap from the toolkit template,
|
||||
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
||||
# install_skill is intentionally skipped to avoid racing with skillset
|
||||
# auto-deploy below).
|
||||
if command -v pi &>/dev/null; then
|
||||
if [ -d /opt/pi-toolkit ]; then
|
||||
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
||||
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
if [ -d /opt/pi-extensions ]; then
|
||||
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
_pi_settings="$HOME/.pi/agent/settings.json"
|
||||
_pi_template=/opt/pi-toolkit/settings.example.json
|
||||
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
|
||||
cp "$_pi_template" "$_pi_settings"
|
||||
echo "pi settings.json bootstrapped from template"
|
||||
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
|
||||
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
|
||||
# Non-destructive merge: a settings.json on a PRESERVED volume never
|
||||
# otherwise sees new template keys (the bootstrap above only fires when
|
||||
# the file is absent), so config added in an image upgrade — e.g. the
|
||||
# observational-memory / pi-fork blocks or a newly-enabled model — never
|
||||
# reaches existing users. Deep-merge with the template FIRST and the
|
||||
# live file SECOND ('.[0] * .[1]') so the user's values always win and
|
||||
# only keys MISSING from the live file are filled in from the template.
|
||||
# Arrays are treated as leaves (the user's array is kept verbatim, so a
|
||||
# model they deliberately removed is not re-added). Only rewrite when the
|
||||
# merge actually changes something, and back up the original first.
|
||||
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
|
||||
# never clobber.
|
||||
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
|
||||
if [ -n "$_pi_merged" ] && \
|
||||
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
||||
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
printf '%s\n' "$_pi_merged" > "$_pi_settings"
|
||||
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
|
||||
fi
|
||||
else
|
||||
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||
command -v mempalace &>/dev/null && \
|
||||
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) + (in the
|
||||
# :latest-studio variant only) pi-studio (/studio command + studio_*
|
||||
# tools + theme). These are pi packages (not symlink-style extensions):
|
||||
# they're cloned to /opt with node_modules baked at BUILD time, then
|
||||
# registered here via `pi install <local-path>`. A local-path install is
|
||||
# instant + in-place (pi loads the extension directly from /opt) +
|
||||
# idempotent (no duplicate package entry on re-run), and stores a relative
|
||||
# path that resolves into the image-layer /opt so it survives volume
|
||||
# recreate. The tools/command register on the NEXT pi start (extensions
|
||||
# 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 ! _pi_pkg_registered "$_name"; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
|
||||
# pi-studio binds its server to 127.0.0.1 inside the container, which a
|
||||
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
|
||||
# compose), start the `studio-expose` socat bridge in the background so a
|
||||
# published port + `ssh -L` tunnel can reach Studio once the user runs
|
||||
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
|
||||
# (its secure default) unless explicitly opted in. Guarded on the studio
|
||||
# variant (/opt/pi-studio) so it is a no-op in the plain image.
|
||||
case "${STUDIO_EXPOSE:-}" in
|
||||
1|true|TRUE|yes|on)
|
||||
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
|
||||
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
|
||||
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
|
||||
else
|
||||
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||
# run the deploy script to create relative symlinks for skills and instructions.
|
||||
# This ensures skills resolve correctly inside the container regardless of
|
||||
# where the repo lives on the host. Idempotent — second run is a no-op.
|
||||
#
|
||||
# Detection order:
|
||||
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
||||
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
||||
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
||||
SKILLSET_DEPLOY=""
|
||||
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
||||
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
||||
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
||||
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
||||
fi
|
||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── Execute command ──────────────────────────────────────────────────
|
||||
exec "$@"
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
USER_NAME="developer"
|
||||
CURRENT_UID=$(id -u "$USER_NAME")
|
||||
CURRENT_GID=$(id -g "$USER_NAME")
|
||||
|
||||
# ── UID/GID adjustment ───────────────────────────────────────────────
|
||||
# Priority per dimension: env var > auto-detect from /workspace > no-op
|
||||
# UID and GID are detected independently so a GID-only mismatch (e.g. host
|
||||
# user has UID 1000 but primary group at GID 1001) is still corrected.
|
||||
TARGET_UID="${USER_UID:-}"
|
||||
TARGET_GID="${USER_GID:-}"
|
||||
|
||||
if [ -d /workspace ]; then
|
||||
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
|
||||
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
|
||||
# Adopt workspace UID if env var not set and workspace is non-root-owned
|
||||
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
|
||||
TARGET_UID="$WORKSPACE_UID"
|
||||
fi
|
||||
# Adopt workspace GID if env var not set and workspace group differs
|
||||
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
|
||||
TARGET_GID="$WORKSPACE_GID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Apply UID/GID changes if needed
|
||||
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
|
||||
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer GID to $TARGET_GID"
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
|
||||
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer UID to $TARGET_UID"
|
||||
fi
|
||||
|
||||
# ── SSH key permissions ──────────────────────────────────────────────
|
||||
# If SSH keys are mounted, fix permissions (skip if read-only mount)
|
||||
if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then
|
||||
if touch "/home/$USER_NAME/.ssh/.perm_test" 2>/dev/null; then
|
||||
rm -f "/home/$USER_NAME/.ssh/.perm_test"
|
||||
chmod 700 "/home/$USER_NAME/.ssh"
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
[ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts"
|
||||
[ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fix ownership of named volume mount points ──────────────────────
|
||||
# Named volumes are created as root on first use. Fix ownership so the
|
||||
# developer user can write to them.
|
||||
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
|
||||
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
|
||||
|
||||
# First, fix parent dirs that Docker auto-creates as root:root when it
|
||||
# materializes nested mount points (e.g. mounting a volume at
|
||||
# .local/state/opencode creates .local/state as root). Non-recursive —
|
||||
# we only need the dir node itself; children are handled below or were
|
||||
# created by the user.
|
||||
for parent in \
|
||||
/home/"$USER_NAME"/.local \
|
||||
/home/"$USER_NAME"/.local/share \
|
||||
/home/"$USER_NAME"/.local/state \
|
||||
/home/"$USER_NAME"/.cache \
|
||||
/home/"$USER_NAME"/.config; do
|
||||
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
for dir in \
|
||||
/home/"$USER_NAME"/.local/share/opencode \
|
||||
/home/"$USER_NAME"/.local/state/opencode \
|
||||
/home/"$USER_NAME"/.local/share/uv \
|
||||
/home/"$USER_NAME"/.local/share/zoxide \
|
||||
/home/"$USER_NAME"/.local/share/nvim \
|
||||
/home/"$USER_NAME"/.mempalace \
|
||||
/home/"$USER_NAME"/.cache/bash \
|
||||
/home/"$USER_NAME"/.cache/chroma \
|
||||
/home/"$USER_NAME"/.rustup \
|
||||
/home/"$USER_NAME"/.cargo \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
# Sentinel-file fast path: on volumes with thousands of files (nvim
|
||||
# plugins, palace data) the recursive chown used to cost multiple
|
||||
# seconds on every container start even when ownership was already
|
||||
# correct. Now we write a sentinel after a successful chown and skip
|
||||
# the walk when the sentinel matches the target UID:GID.
|
||||
#
|
||||
# If USER_UID changes between runs (user switches hosts, different
|
||||
# workspace owner), the sentinel won't match and the full chown runs.
|
||||
sentinel="$dir/.devbox-owner"
|
||||
expected="$FINAL_UID:$FINAL_GID"
|
||||
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Recursive chown needed. Only do it when the top-level differs too
|
||||
# (covers the common case of fresh root-owned named volumes).
|
||||
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write sentinel so subsequent starts skip the recursive walk.
|
||||
# Suppress errors — a read-only mount would fail here, but that would
|
||||
# already have failed above on the chown itself.
|
||||
echo "$expected" > "$sentinel" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── Drop to developer user for remaining setup ──────────────────────
|
||||
exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
|
||||
@@ -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
|
||||
@@ -0,0 +1,144 @@
|
||||
# opencode-devbox bash aliases and customizations
|
||||
# Sourced by the Debian-default ~/.bashrc on shell startup.
|
||||
# To override, bind-mount your host's ~/.bash_aliases over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
|
||||
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
|
||||
# recommended pattern for sharing aliases/PATH/utilities between host
|
||||
# and container), source the bash_aliases file from it. This survives
|
||||
# --force-recreate because it's baked into the image's skel, not the
|
||||
# container's writable layer. Hosts that don't use this pattern are
|
||||
# unaffected — the test silently skips if the file doesn't exist.
|
||||
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
|
||||
|
||||
# ── History persistence and quality ──────────────────────────────────
|
||||
# The named volume devbox-shell-history is mounted at ~/.cache/bash
|
||||
# so history survives container recreation.
|
||||
export HISTFILE="${HOME}/.cache/bash/history"
|
||||
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
|
||||
|
||||
# Large, time-stamped, deduplicated history. Append rather than overwrite.
|
||||
export HISTSIZE=100000
|
||||
export HISTFILESIZE=200000
|
||||
export HISTCONTROL=ignoreboth:erasedups
|
||||
export HISTTIMEFORMAT='%F %T '
|
||||
shopt -s histappend 2>/dev/null
|
||||
shopt -s cmdhist 2>/dev/null
|
||||
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
|
||||
# after zoxide's init runs. Installing it here would create a
|
||||
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
|
||||
# as its separator and prepends itself; two adjacent ';' breaks the
|
||||
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
|
||||
|
||||
# ── Common aliases ───────────────────────────────────────────────────
|
||||
# Prefer eza (modern ls) when available
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --group-directories-first'
|
||||
alias ll='eza -lh --group-directories-first --git'
|
||||
alias la='eza -lha --group-directories-first --git'
|
||||
alias tree='eza --tree'
|
||||
else
|
||||
alias ll='ls -lh'
|
||||
alias la='ls -lha'
|
||||
fi
|
||||
|
||||
# Prefer bat (syntax-highlighted cat) when available
|
||||
if command -v bat >/dev/null 2>&1; then
|
||||
alias cat='bat --style=plain --paging=never'
|
||||
alias less='bat --paging=always'
|
||||
fi
|
||||
|
||||
# Git shortcuts
|
||||
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
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
alias cp='cp -i'
|
||||
|
||||
# ── Shell integrations ───────────────────────────────────────────────
|
||||
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
|
||||
if command -v zoxide >/dev/null 2>&1; then
|
||||
eval "$(zoxide init bash)"
|
||||
fi
|
||||
|
||||
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
|
||||
# We install fzf from GitHub releases (not apt), so sourcing from the
|
||||
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
|
||||
# binary's own --bash flag (available since fzf 0.48) for setup.
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
eval "$(fzf --bash)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
|
||||
# Installed AFTER zoxide init so zoxide's hook is already in place;
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
#
|
||||
# The guard MUST stay shell-local (NOT exported): if it leaks into child
|
||||
# processes, every nested shell -- crucially each tmux pane, which inherits
|
||||
# the tmux server's env -- skips installing `history -a` and only persists
|
||||
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
|
||||
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
|
||||
# means each new interactive shell re-installs its own per-prompt flush.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
# Preserves the default Debian PS1 logic but prefixes with a container marker.
|
||||
# We check for the literal '[devbox]' substring in PS1 rather than relying on
|
||||
# an exported guard variable — otherwise `exec bash` inherits the guard but
|
||||
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
|
||||
# re-added in the new shell.
|
||||
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
|
||||
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
|
||||
fi
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
# opencode-devbox readline defaults
|
||||
# To override, bind-mount your host's ~/.inputrc over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# Inherit system-wide defaults (colour, 8-bit input, …) if present
|
||||
$include /etc/inputrc
|
||||
|
||||
# ── History search on Up/Down ────────────────────────────────────────
|
||||
# Type a prefix, press Up, and walk through previous commands starting
|
||||
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
|
||||
"\e[A": history-search-backward
|
||||
"\e[B": history-search-forward
|
||||
"\e[1;5A": previous-history
|
||||
"\e[1;5B": next-history
|
||||
|
||||
# ── Completion quality ───────────────────────────────────────────────
|
||||
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
|
||||
set completion-ignore-case on # case-insensitive file/dir completion
|
||||
set colored-stats on # colour ls-style completion list entries
|
||||
set colored-completion-prefix on # highlight the matched prefix
|
||||
set visible-stats on # append /*@ type indicators in completion
|
||||
set mark-symlinked-directories on # add trailing / to symlinks to dirs
|
||||
set skip-completed-text on # don't re-insert already-typed text
|
||||
|
||||
# Treat hyphens and underscores as equivalent when completing (e.g.
|
||||
# typing `foo-` matches both `foo-bar` and `foo_bar`).
|
||||
set completion-map-case on
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# dot-watch — auto-rerender a graphviz .dot file to PNG on every save.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio renders mermaid natively but has no graphviz/DOT renderer.
|
||||
# Its markdown preview DOES render local image links (.png/.jpg/.gif/.webp),
|
||||
# and the editor offers "refresh from disk". This helper closes the loop:
|
||||
# edit a .dot file -> dot-watch regenerates <name>.png -> hit refresh in
|
||||
# Studio to see the update. Uses mtime polling (no inotify dependency,
|
||||
# which isn't in the trixie-slim base).
|
||||
#
|
||||
# USAGE
|
||||
# dot-watch <file.dot> [layout] [dpi]
|
||||
# layout: dot|neato|fdp|circo|twopi (default: dot)
|
||||
# dpi: output resolution (default: 150)
|
||||
# env: DOT_WATCH_INTERVAL=<seconds> poll interval (default: 1)
|
||||
#
|
||||
# EXAMPLES
|
||||
# dot-watch /workspace/graph.dot
|
||||
# dot-watch graph.dot neato 200
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${1:?usage: dot-watch <file.dot> [layout] [dpi]}"
|
||||
LAYOUT="${2:-dot}"
|
||||
DPI="${3:-150}"
|
||||
|
||||
[[ -f "$SRC" ]] || { echo "error: no such file: $SRC" >&2; exit 1; }
|
||||
command -v "$LAYOUT" >/dev/null || { echo "error: layout engine '$LAYOUT' not found" >&2; exit 1; }
|
||||
|
||||
OUT="${SRC%.dot}.png"
|
||||
INTERVAL="${DOT_WATCH_INTERVAL:-1}" # seconds between polls
|
||||
ERRLOG="$(mktemp -t dot-watch.XXXXXX.err)"
|
||||
trap 'rm -f "$ERRLOG"' EXIT
|
||||
|
||||
render() {
|
||||
if "$LAYOUT" -Tpng -Gdpi="$DPI" "$SRC" -o "$OUT" 2> "$ERRLOG"; then
|
||||
printf '[%s] rendered -> %s\n' "$(date +%H:%M:%S)" "$OUT"
|
||||
else
|
||||
printf '[%s] DOT error:\n' "$(date +%H:%M:%S)"
|
||||
sed 's/^/ /' "$ERRLOG"
|
||||
fi
|
||||
}
|
||||
|
||||
# portable mtime (GNU stat, fallback to BSD stat)
|
||||
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
|
||||
|
||||
echo "watching $SRC ($LAYOUT, ${DPI}dpi) -> $OUT [Ctrl-C to stop]"
|
||||
render
|
||||
last="$(mtime "$SRC")"
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
[[ -f "$SRC" ]] || continue
|
||||
now="$(mtime "$SRC")"
|
||||
if [[ "$now" != "$last" ]]; then
|
||||
last="$now"
|
||||
render
|
||||
fi
|
||||
done
|
||||
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"
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# studio-expose — make a container-loopback pi-studio server reachable
|
||||
# through a published Docker port.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
|
||||
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
|
||||
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
|
||||
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
|
||||
# reach Studio. This helper runs a socat TCP relay that listens on the
|
||||
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
|
||||
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
|
||||
#
|
||||
# SECURITY
|
||||
# This intentionally exposes Studio beyond loopback — anything that can
|
||||
# reach the container's network interface (and the host port you publish)
|
||||
# can connect. Studio's tokenized URL is the only auth. Mitigate by
|
||||
# publishing the host port on localhost only:
|
||||
# ports: ["127.0.0.1:${STUDIO_PORT}:${STUDIO_PORT}"]
|
||||
# and use `ssh -L` for remote access. Bridge nothing you don't intend to.
|
||||
#
|
||||
# USAGE
|
||||
# studio-expose [PORT] # bridge PORT (default: $STUDIO_PORT or 8765)
|
||||
# studio-expose --help
|
||||
#
|
||||
# Typically: inside a pi session run `/studio --no-browser --port 8765`,
|
||||
# then in a container shell run `studio-expose` (or set STUDIO_EXPOSE=1 in
|
||||
# compose to auto-start it on container boot — see entrypoint-user.sh).
|
||||
#
|
||||
# Runs in the foreground; Ctrl-C to stop. The entrypoint auto-start path
|
||||
# runs it backgrounded.
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${1:-${STUDIO_PORT:-8765}}"
|
||||
|
||||
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
|
||||
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$PORT" in
|
||||
''|*[!0-9]*) echo "studio-expose: invalid port '$PORT'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if ! command -v socat >/dev/null 2>&1; then
|
||||
echo "studio-expose: socat not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Container's primary egress IPv4. In Docker the container hostname resolves
|
||||
# to its eth0 address, so `hostname -I` lists it; we take the first
|
||||
# non-loopback IPv4. We must bind this specific address rather than 0.0.0.0
|
||||
# — binding 0.0.0.0 would collide with Studio's own 127.0.0.1:PORT listener
|
||||
# (0.0.0.0 includes loopback) and fail with EADDRINUSE. `ip route get` is a
|
||||
# fallback only when iproute2 happens to be present (not in the base image).
|
||||
BIND_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -n1)"
|
||||
if [ -z "${BIND_IP:-}" ] && command -v ip >/dev/null 2>&1; then
|
||||
BIND_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
|
||||
fi
|
||||
[ -n "${BIND_IP:-}" ] || BIND_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
|
||||
if [ -z "${BIND_IP:-}" ]; then
|
||||
echo "studio-expose: could not determine container egress IP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "studio-expose: bridging ${BIND_IP}:${PORT} -> 127.0.0.1:${PORT}"
|
||||
echo "studio-expose: open the tokenized URL pi-studio printed; if the host"
|
||||
echo "studio-expose: publishes ${PORT}, reach it at http://127.0.0.1:${PORT}/?token=..."
|
||||
echo "studio-expose: (remote host: ssh -L ${PORT}:127.0.0.1:${PORT} user@host)"
|
||||
|
||||
# fork: one child per connection (handles concurrent + long-lived WebSocket
|
||||
# connections). reuseaddr: survive quick restarts. Studio need not be up yet
|
||||
# — connections simply fail until `/studio --port ${PORT}` is running.
|
||||
exec socat "TCP-LISTEN:${PORT},bind=${BIND_IP},fork,reuseaddr" "TCP:127.0.0.1:${PORT}"
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
|
||||
#
|
||||
# THE PROBLEM
|
||||
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
|
||||
# container runs inside a Linux VM behind the host's network stack. The
|
||||
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
|
||||
# are NOT bridged into the container by default — only the host itself and
|
||||
# *routed* subnets are reachable. On native Linux Docker the default bridge
|
||||
# already NATs container egress onto the host's LAN, so LAN peers are usually
|
||||
# reachable directly and no workaround is needed.
|
||||
#
|
||||
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we render the same writable
|
||||
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
|
||||
# jump block, since LAN peers are reachable directly there.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
|
||||
# is pulled in via the `Include ~/.ssh/config` line below.
|
||||
#
|
||||
# WHY A WRITABLE SIDECAR (~/.ssh-local)
|
||||
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
|
||||
# can read keys for git but can't tamper with config/known_hosts/authorized_
|
||||
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
|
||||
# So everything generated here lives under the writable ~/.ssh-local, used
|
||||
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the host jump only on VM-backed hosts. The writable
|
||||
# sidecar config (ControlPath redirect + Include) is always
|
||||
# rendered, on every OS.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
# jump to authenticate. If unset we still generate the config but print
|
||||
# a hint with the public key to authorize on the host.
|
||||
# DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1
|
||||
# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP
|
||||
# through the host. Lets bare `dssh user@<private-IP>` work on whatever
|
||||
# LAN the (roaming) host is currently joined to, without naming peers.
|
||||
# Matches by the address you TYPE, not the resolved HostName, so it never
|
||||
# overrides named hosts that already carry their own ProxyJump.
|
||||
#
|
||||
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
|
||||
# Named LAN peers are facts about a *specific* host's network, not about the
|
||||
# image — a roaming laptop sees different LANs. So we never bake peer names
|
||||
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
|
||||
# (the same devbox-shell bridge dir used for shared aliases), we Include it
|
||||
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
|
||||
# Host pve pve-2 pbs-vm
|
||||
# ProxyJump host
|
||||
# First-value-wins means ProxyJump is taken from there while HostName/User/
|
||||
# IdentityFile are inherited from the matching block in ~/.ssh/config.
|
||||
#
|
||||
# SCOPING NOTE (important)
|
||||
# `Include` is scoped to the enclosing Host/Match block. So every Include
|
||||
# below is preceded by a bare `Host *` to reset the active context to
|
||||
# match-all — otherwise the included config would only apply when targeting
|
||||
# `host`/`mac` and named peers like `pve` would silently fall back to ssh
|
||||
# defaults.
|
||||
#
|
||||
# Idempotent: re-renders the config every run (cheap); never regenerates the
|
||||
# key. Always non-fatal — never blocks container startup.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${DEVBOX_LAN_ACCESS:-auto}"
|
||||
[ "$MODE" = "off" ] && exit 0
|
||||
|
||||
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
|
||||
SSH_LOCAL="${HOME}/.ssh-local"
|
||||
CONFIG="${SSH_LOCAL}/config"
|
||||
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
|
||||
|
||||
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
|
||||
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
|
||||
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
|
||||
# in which case the jump is still harmless / usable, and they can force it
|
||||
# with DEVBOX_LAN_ACCESS=jump).
|
||||
is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
|
||||
# The ControlPath redirect in the generated config needs a writable directory
|
||||
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
|
||||
# master socket lives under the writable ~/.ssh-local. We create it and render
|
||||
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
|
||||
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
|
||||
# would otherwise still break ControlMaster sockets.
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Decide whether to set up the host jump ────────────────────────────
|
||||
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
|
||||
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
|
||||
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
|
||||
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
|
||||
# we still render the config for the ControlPath redirect + Include.
|
||||
NEED_JUMP=0
|
||||
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
|
||||
NEED_JUMP=1
|
||||
fi
|
||||
|
||||
# ── Jump key (only when a jump is needed; generated once, preserved) ──
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
|
||||
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
|
||||
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
|
||||
# Linux they are all empty and only the ControlPath redirect + Include remain.
|
||||
JUMP_BLOCK=""
|
||||
LAN_CONF_BLOCK=""
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
JUMP_BLOCK=$(cat <<EOF
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
EOF
|
||||
)
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
# and go direct via the container's NAT egress. NOTE: also matches the
|
||||
# container's own bridge subnet and any private IP the host can't actually
|
||||
# reach — for non-LAN private hosts behind a different jump, use their named
|
||||
# entry (which matches first by name and keeps its own ProxyJump).
|
||||
Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.*
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
if [ -r "${HOME}/.ssh/config" ]; then
|
||||
INCLUDE_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Your own target hosts. Scope reset to match-all so this Include applies to
|
||||
# every target (an Include is otherwise scoped to the enclosing Host block).
|
||||
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
|
||||
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
|
||||
# here in ~/.ssh/config, which is typically bind-mounted read-only.
|
||||
Host *
|
||||
Include ~/.ssh/config
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
cat > "$CONFIG" <<EOF
|
||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
||||
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||
# (or the dssh / dscp aliases). See the script header for the full rationale.
|
||||
|
||||
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
|
||||
# Also redirect ControlPath into the writable sidecar: the bind-mounted
|
||||
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
|
||||
# but ~/.ssh is read-only here so the master socket can't be created and those
|
||||
# hosts fail to connect. First-value-wins: setting it here (before the Include)
|
||||
# overrides the read-only path for every host. Harmless when ControlMaster is off.
|
||||
Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
${JUMP_BLOCK}
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
EOF
|
||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
|
||||
# ── Authorize hints ───────────────────────────────────────────────────
|
||||
# Print the copy-paste authorize line whenever we either (a) can't yet
|
||||
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
|
||||
HOST_SSH_USER is unset so it can't authenticate to the host yet.
|
||||
To enable container -> host -> LAN-peer access:
|
||||
1. Set HOST_SSH_USER=<your host username> in the container env.
|
||||
2. Authorize this key on the host (run ON THE HOST, once):
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
3. Ensure the host's SSH server (Remote Login) is enabled.
|
||||
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
|
||||
EOF
|
||||
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
cat <<EOF
|
||||
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
|
||||
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
(Ensure the host's SSH server / Remote Login is enabled.)
|
||||
This key is persisted in the ~/.ssh-local volume, so you won't need to
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- pi-devbox:managed-block — appended to the global AGENTS.md at image build
|
||||
time (Dockerfile.variant), after pi-toolkit is cloned. Keep this short:
|
||||
it is a pointer, the depth lives in the skill. -->
|
||||
|
||||
## Running inside pi-devbox
|
||||
|
||||
If the directory `/usr/local/lib/pi-devbox/` exists (or your shell prompt is
|
||||
prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in a
|
||||
**pi-devbox container** — a Docker environment whose persistence, networking,
|
||||
DNS, host/LAN reachability, tmux, and Python/REPL behaviour differ from a normal
|
||||
workstation. Before any task that touches **reaching the host or its LAN, SSH,
|
||||
DNS/name resolution, what survives container recreate, running Python/REPLs,
|
||||
tmux, or pi-studio**, read `~/.agents/skills/pi-devbox-environment/SKILL.md`.
|
||||
|
||||
Key reflex from that skill: **the deployment specifics are not universal** — the
|
||||
host OS, hostnames, internal domains, and nameservers vary per instance and must
|
||||
be discovered at runtime, never assumed. And interactive shell aliases
|
||||
(`dssh`, `dscp`, `cat`→`bat`) do **not** exist in your non-interactive bash
|
||||
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
|
||||
your tool list, **read `~/.agents/skills/mempalace/SKILL.md` before doing
|
||||
non-trivial work** and follow its protocol: search the palace before answering
|
||||
about past work, and write a diary entry before the session ends. This is
|
||||
especially load-bearing here — a pi-devbox container is frequently recreated, so
|
||||
the palace is your only memory across recreates. Without the habit it is just
|
||||
storage, not memory. (The skill is the consumer side; feeding the palace is the
|
||||
separate `opencode-mempalace-bridge` skill, if present.)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Vendored fallback skills
|
||||
|
||||
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
||||
symlinks into `~/.agents/skills/` on container start (only when a skill of the
|
||||
same name is not already present, so a mounted `skillset` repo or a user
|
||||
override always wins).
|
||||
|
||||
| skill | owner | how it gets here |
|
||||
|-------|-------|------------------|
|
||||
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
|
||||
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
|
||||
|
||||
## Why fallbacks exist
|
||||
|
||||
The pi-toolkit global `AGENTS.md` tells every pi session to read
|
||||
`~/.agents/skills/pi-extensions/SKILL.md` at start (to fix fork/recall
|
||||
under-utilisation). That pointer dangles in a container started **without** the
|
||||
private `skillset` repo mounted. Baking the skill closes that *availability*
|
||||
gap. `mempalace` is baked for the same reason (memory continuity); since
|
||||
nothing in pi-toolkit's `AGENTS.md` points to it, the pi-devbox managed block
|
||||
(`pi-global-AGENTS.append.md`) also adds the matching *proactive-load*
|
||||
directive ("load the mempalace skill at session start") so a new container
|
||||
actually picks it up rather than relying on description-matching.
|
||||
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
|
||||
its skill file needed baking.
|
||||
|
||||
## Freshness model (layered — see Dockerfile.variant)
|
||||
|
||||
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
|
||||
*floor*; at build time `Dockerfile.variant` copies `/opt/pi-extensions/skill/`
|
||||
(the pinned, package-owned source) over it, so a normal build ships the fresh
|
||||
package copy and a stale-ref / mirror build still ships the snapshot. Keep
|
||||
`evaluate-extension-usage.py` alongside `SKILL.md` — the skill calls it via
|
||||
`./`.
|
||||
- **`mempalace`** — Option 2 only. The `mempalace` *consumer* skill lives only
|
||||
in the private `skillset` repo (the `mempalace-toolkit` repo ships a
|
||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||
package source to copy from. This snapshot is refreshed manually per release.
|
||||
|
||||
## Refreshing the snapshots
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.
|
||||
---
|
||||
|
||||
# MemPalace Agent Memory Protocol
|
||||
|
||||
## Overview
|
||||
|
||||
MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.
|
||||
|
||||
**Core principle:** Storage is not memory. Storage + protocol = memory.
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
- At the **start of every session** (proactively, before the user asks)
|
||||
- When the user mentions **past conversations, decisions, or work**
|
||||
- When working on a **new project or repository** for the first time
|
||||
- When the user asks about **people, projects, or relationships**
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
### Phase 1: Wake Up (session start)
|
||||
|
||||
Run these immediately when a session begins, before responding to the user:
|
||||
|
||||
1. **Load palace overview:**
|
||||
```
|
||||
mempalace_status
|
||||
```
|
||||
This returns wing/room counts, the AAAK spec, and the memory protocol reminder.
|
||||
|
||||
2. **Read your recent diary:**
|
||||
```
|
||||
mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
|
||||
```
|
||||
Scan for context about recent sessions — what was worked on, what matters, what's pending.
|
||||
|
||||
3. **Check the knowledge graph** for the user or active project if relevant:
|
||||
```
|
||||
mempalace_kg_query(entity="<project_or_person>")
|
||||
```
|
||||
|
||||
Do NOT announce this to the user. Just do it silently to orient yourself.
|
||||
|
||||
### Temporal grounding — compute time deltas, don't guess
|
||||
|
||||
Diary entries and drawers carry real timestamps (`timestamp`, `created_at`).
|
||||
Before describing *when* something happened — "yesterday", "earlier today",
|
||||
"last week", "a while back" — **establish the current date/time first and
|
||||
compute the delta against the actual timestamp.** Get "now" from the injected
|
||||
session date or by running `date` in a shell; never infer it.
|
||||
|
||||
**A container recreate or a fresh session is NOT a day boundary.** A 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
|
||||
about to write "yesterday" / "last week", subtract `now − entry.timestamp` and
|
||||
state the computed result. (Remember timestamps may be UTC while the wall clock
|
||||
is local — reconcile the offset before computing the delta.) Note too that
|
||||
session feeders can lag up to a week (see *Multi-harness palace*), so a recent
|
||||
absence in `wing_conversations` is not proof nothing happened.
|
||||
|
||||
### Phase 2: Active Session (during work)
|
||||
|
||||
#### Search Before You Speak
|
||||
|
||||
Before answering questions about past work, decisions, people, or projects:
|
||||
|
||||
```
|
||||
mempalace_search(query="<keywords>", wing="<project>")
|
||||
```
|
||||
|
||||
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
|
||||
|
||||
#### Mine New Projects
|
||||
|
||||
When working on a new codebase for the first time:
|
||||
|
||||
1. Check if it's already mined:
|
||||
```
|
||||
mempalace_list_wings
|
||||
```
|
||||
|
||||
2. **Decide what to mine — docs first, code never (by default).**
|
||||
|
||||
The palace is for *context and intent*, not code recall. Code is better read from the working tree via `Read`/`Grep`/`glob` — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. `def __init__(self, ...)` across every class) that pollute search for years.
|
||||
|
||||
**Mine by default:**
|
||||
- `*.md`, `*.rst`, `*.txt` — docs, READMEs, CHANGELOGs, architecture notes
|
||||
- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, design/decision docs — highest signal per byte
|
||||
- `*.sh`, `Dockerfile`, `Makefile`, entrypoints — small, intent-bearing
|
||||
- `*.yml`, `*.yaml`, `*.toml`, selective `*.json` (`docker-compose`, `pyproject`, `mkdocs.yml`, CI workflows) — skip lockfiles
|
||||
|
||||
**Do NOT mine by default:**
|
||||
- `*.py`, `*.ts`, `*.tsx`, `*.js`, `*.go`, `*.rs`, `*.java`, `*.cpp`, `*.c`, `*.rb` — raw source code
|
||||
- Test files, fixtures, generated code
|
||||
- `node_modules/`, `.venv/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` (the miner respects `.gitignore` but double-check)
|
||||
|
||||
Exception: if a code file *is* the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via `mempalace_add_drawer`.
|
||||
|
||||
3. **Before mining**, inspect the repo to estimate drawer count:
|
||||
```bash
|
||||
# Quick audit — what will actually get mined?
|
||||
find <dir> -type f \
|
||||
-not -path '*/.git/*' -not -path '*/node_modules/*' \
|
||||
-not -path '*/.venv/*' -not -path '*/__pycache__/*' \
|
||||
\( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
|
||||
-o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
|
||||
```
|
||||
A docs-heavy repo should produce ~5–10 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.
|
||||
|
||||
4. Run the mine:
|
||||
```bash
|
||||
mempalace init --yes <directory>
|
||||
mempalace mine <directory> --agent <your_agent_name>
|
||||
```
|
||||
|
||||
The miner currently lacks a `--docs-only` or `--exclude-ext` flag (as of v3.3.3). Until it does, either:
|
||||
- (a) Add a `mempalace.yaml` at the repo root with explicit include globs, OR
|
||||
- (b) Mine everything, then surgically remove code-sourced drawers via SQL on `~/.mempalace/palace/chroma.sqlite3` (delete by `embedding_metadata.source_file LIKE '%.py'`), followed by `mempalace repair --yes`.
|
||||
|
||||
5. If the CLI miner misses a file you *do* want (e.g., `.zsh`, an undocumented extension), file it manually:
|
||||
```
|
||||
mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
|
||||
```
|
||||
|
||||
6. After mining, reconnect to pick up the new embeddings:
|
||||
```
|
||||
mempalace_reconnect
|
||||
```
|
||||
If search errors occur after mining ("Error finding id"), repair the index:
|
||||
```bash
|
||||
mempalace repair --yes
|
||||
```
|
||||
|
||||
#### Track Facts in the Knowledge Graph
|
||||
|
||||
When you learn new facts about people, projects, or relationships:
|
||||
|
||||
```
|
||||
mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
|
||||
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")
|
||||
```
|
||||
|
||||
When facts change (ended, no longer true):
|
||||
|
||||
```
|
||||
mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")
|
||||
```
|
||||
|
||||
#### Cross-Reference with Tunnels
|
||||
|
||||
When content in one project relates to another, create a tunnel:
|
||||
|
||||
```
|
||||
mempalace_create_tunnel(
|
||||
source_wing="project_api", source_room="endpoints",
|
||||
target_wing="project_db", target_room="schema",
|
||||
label="API endpoints map to these DB tables"
|
||||
)
|
||||
```
|
||||
|
||||
#### Feeding opencode session history (opencode + mempalace-toolkit only)
|
||||
|
||||
MemPalace has no upstream integration with [opencode](https://github.com/anomalyco/opencode) as of v3.3.3 — `hooks_cli.py` only supports `claude-code` and `codex` harnesses. Opencode persists every turn in a local SQLite DB at `~/.local/share/opencode/opencode.db`, but nothing moves that data into the palace automatically.
|
||||
|
||||
On a machine with opencode + the [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) installed, session history is fed into `wing_conversations` via `mempalace-session` — either manually, or on a weekly systemd user timer / cron schedule shipped in `mempalace-toolkit/contrib/`. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to `mempalace_search`.
|
||||
|
||||
**How to tell if it's set up:**
|
||||
|
||||
```
|
||||
mempalace_list_wings
|
||||
```
|
||||
|
||||
If `wing_conversations` exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:
|
||||
|
||||
1. Check if the toolkit is installed: `which mempalace-session`.
|
||||
2. If installed, suggest running `mempalace-session --dry-run` to preview and `mempalace-session` to file.
|
||||
3. If not installed, point the user at `gitea.jordbo.se/joakimp/mempalace-toolkit` for setup.
|
||||
|
||||
**Don't try to paper over the gap by dumping turn-level content into the palace manually via `mempalace_add_drawer`** — that reinvents what `mempalace-session` does with normalization and dedup. Use the tool.
|
||||
|
||||
Full routine (triggers, cadence, automation) is in the [`opencode-mempalace-bridge`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) skill and the toolkit's `ARCHITECTURE.md` §5. The two skills pair: this one (`mempalace`) covers using the palace; that one (`opencode-mempalace-bridge`) covers feeding it from opencode.
|
||||
|
||||
### Phase 3: Wind Down (session end)
|
||||
|
||||
**Always write a diary entry before the session ends.** This is the most important habit.
|
||||
|
||||
```
|
||||
mempalace_diary_write(
|
||||
agent_name="<your_agent_name>",
|
||||
entry="<AAAK compressed summary>",
|
||||
topic="session-summary"
|
||||
)
|
||||
```
|
||||
|
||||
#### Why still write diaries when sessions may be mined automatically?
|
||||
|
||||
On machines running opencode + `mempalace-toolkit`, every session is mined into `wing_conversations` on a weekly (or user-defined) schedule. A common and incorrect conclusion: *"since every turn is captured automatically, writing a diary entry is redundant."* It isn't.
|
||||
|
||||
Session mining captures **what was said** (every turn, verbatim). A diary captures **what the session meant** — editorial judgment by the agent who lived it:
|
||||
|
||||
- Lessons learned, patterns noticed, pending items rolled forward
|
||||
- Meta-observations that were never said aloud during the session
|
||||
- Aggregate counts (commits shipped, bugs fixed, hours spent)
|
||||
- A compressed, recency-scannable summary for the *next* agent's wake-up
|
||||
|
||||
Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as *release notes* (diary) vs. *git log with diffs* (session mine): a repo keeps both because they answer different questions. So does the palace.
|
||||
|
||||
**Practical rule:** automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see [`mempalace-toolkit/ARCHITECTURE.md` §5 → "Diary vs session mine: why keep both?"](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/ARCHITECTURE.md#diary-vs-session-mine-why-keep-both).
|
||||
|
||||
#### AAAK Diary Format
|
||||
|
||||
Write diary entries in compressed AAAK format for efficiency. Structure:
|
||||
|
||||
```
|
||||
SESSION:<date>|<what.you.worked.on>|
|
||||
TASKS:
|
||||
1.<task.description>→<outcome>|
|
||||
2.<task.description>→<outcome>|
|
||||
DISCOVERED:<unexpected.findings>|
|
||||
ENTITIES:<people.or.projects.encountered>|
|
||||
<importance: one to five stars>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
SESSION:2026-04-28|api.refactor+db.migration|
|
||||
TASKS:
|
||||
1.refactored.auth.endpoints→split.into.3.modules|
|
||||
2.added.user.roles.migration→postgres.enum.type|
|
||||
DISCOVERED:legacy.session.table.unused.since.v2|
|
||||
ENTITIES:ProjectX;Alice(reviewer)|
|
||||
***
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Use dots instead of spaces within phrases
|
||||
- Use pipes as field separators
|
||||
- Use arrows for cause/effect or transitions
|
||||
- Stars indicate session importance (one to five)
|
||||
- Keep it tight — a future agent should get the gist in seconds
|
||||
|
||||
#### What to Capture
|
||||
|
||||
Prioritize recording:
|
||||
- **Decisions made** and their rationale
|
||||
- **Discoveries** — things that surprised you or that a future session needs to know
|
||||
- **Unfinished work** — what's pending, what was deferred
|
||||
- **User preferences** observed during the session
|
||||
- **Entities encountered** — people, projects, tools, services
|
||||
|
||||
### Phase 4: Fact Updates
|
||||
|
||||
If facts changed during the session, update the knowledge graph before writing the diary:
|
||||
|
||||
```
|
||||
mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
|
||||
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
|
||||
```
|
||||
|
||||
## Palace Structure
|
||||
|
||||
### Wings
|
||||
|
||||
Wings are top-level categories, typically one per project or domain:
|
||||
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
|
||||
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
|
||||
|
||||
#### Multi-harness palace
|
||||
|
||||
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
|
||||
|
||||
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
|
||||
- `pi_<uuid>.jsonl` → pi session
|
||||
- `<slug>_ses_<id>.jsonl` → opencode session
|
||||
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
|
||||
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
|
||||
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00. Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
|
||||
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
|
||||
|
||||
### Rooms
|
||||
|
||||
Rooms are aspects within a wing:
|
||||
- `fzf`, `scripts`, `configuration`, `general` — whatever the miner detects
|
||||
- Diary entries go into rooms by topic tag
|
||||
|
||||
### Drawers
|
||||
|
||||
Drawers hold verbatim content — never summarized, always searchable.
|
||||
|
||||
### Tunnels
|
||||
|
||||
Cross-wing connections linking related content across projects.
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
Entity-relationship triples with temporal validity. Query with `mempalace_kg_query`, browse with `mempalace_kg_timeline`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|---|---|
|
||||
| "No palace found" | Run `mempalace init <dir>` then `mempalace mine <dir>` |
|
||||
| "Error finding id" after mining | Run `mempalace repair --yes` then `mempalace_reconnect` |
|
||||
| Search returns irrelevant results | Use `max_distance=1.0` for stricter matching; add `wing` filter |
|
||||
| Miner skips file types | File manually with `mempalace_add_drawer` or use `--no-gitignore` |
|
||||
| Stale results after external changes | Call `mempalace_reconnect` |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Don't guess when you can search.** If a question touches past work, search first.
|
||||
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
|
||||
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
|
||||
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
|
||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
||||
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
name: pi-devbox-environment
|
||||
description: >-
|
||||
Operate correctly inside a pi-devbox container. Load when running inside
|
||||
pi-devbox (detection: the directory `/usr/local/lib/pi-devbox/` exists, the
|
||||
shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and
|
||||
the task touches any of: reaching the Docker host or its LAN, SSH, DNS name
|
||||
resolution, what survives container recreate (persistence vs ephemerality),
|
||||
running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the
|
||||
persistence model, the interactive-vs-tool-shell alias gotcha
|
||||
(dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH
|
||||
reachability and ControlMaster, split-horizon DNS mechanisms, the tmux
|
||||
0-index constraint, uv-first Python, and pi-studio reachability. This skill
|
||||
teaches MECHANISMS only — concrete hostnames, usernames, internal domains,
|
||||
nameservers, and even the host OS vary per deployment and MUST be discovered
|
||||
at runtime, never assumed or hardcoded.
|
||||
---
|
||||
|
||||
# pi-devbox environment
|
||||
|
||||
You are (or may be) running inside **pi-devbox**: a Docker container that ships
|
||||
pi, MemPalace, and a curated tool stack, with the host source tree mounted at
|
||||
`/workspace`. This skill is about the *container-shaped* facts that change how
|
||||
you should act — things that are easy to get wrong because they differ from a
|
||||
normal workstation shell.
|
||||
|
||||
> **Golden rule: this environment is a template, not a fixed deployment.**
|
||||
> The host could be macOS, Windows, or Linux. There may or may not be LAN
|
||||
> peers, a VPN, split-DNS, a skillset mount, or the `-studio` variant. Detect
|
||||
> and verify the specifics live (commands below) — do **not** assume any
|
||||
> particular hostname, domain, nameserver, or OS. Where this skill shows
|
||||
> example values they are illustrative placeholders.
|
||||
|
||||
## 0. Am I in pi-devbox, and what's true *here*?
|
||||
|
||||
Cheap detection signals (any one is sufficient):
|
||||
|
||||
```sh
|
||||
[ -d /usr/local/lib/pi-devbox ] && echo "pi-devbox image"
|
||||
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
|
||||
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac
|
||||
```
|
||||
|
||||
Then orient before acting:
|
||||
|
||||
```sh
|
||||
cat /etc/os-release | head -2 # container distro (usually Debian)
|
||||
ls -la /usr/local/lib/pi-devbox/ # which devbox helpers exist
|
||||
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null # host/LAN reachability, if any
|
||||
mount | grep -E ' /workspace | /home/\S+/\.ssh ' # what's bind-mounted
|
||||
```
|
||||
|
||||
## 1. Persistence vs ephemerality — know before you write
|
||||
|
||||
The container has **three storage tiers with very different lifetimes**. Pick
|
||||
the right one or work is silently lost on the next recreate/update.
|
||||
|
||||
| Tier | Examples | Survives `down`? | Survives `down -v`? | Survives image update / `--force-recreate`? |
|
||||
|---|---|---|---|---|
|
||||
| **Host bind-mount** | `/workspace`, usually `~/.ssh` (ro), often `~/.mempalace` | yes | yes (lives on host) | yes |
|
||||
| **Named volume** | `~/.pi`, `~/.ssh-local`, `~/.cache/bash`, `~/.local/share/{uv,nvim,zoxide}` | yes | **no** | yes |
|
||||
| **Writable container layer** | anything else: `sudo apt install …`, `rustup`/`ghc`/`R` toolchains, files in `/tmp`, `/opt` edits | yes | **no** | **no** |
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- **Durable work goes in `/workspace`** (it's the host filesystem, UID-aligned —
|
||||
what you write appears with the user's normal ownership on the host).
|
||||
- **Runtime-installed system packages and language toolchains are ephemeral.**
|
||||
If a task needs them reproducibly, it belongs in the image (Dockerfile) or a
|
||||
project manifest, not an ad-hoc `apt install`. Tell the user when you install
|
||||
something that won't survive.
|
||||
- **`~/.pi` is a named volume**, so things baked into the *image* under
|
||||
`/home/<user>/...` are **shadowed** by the volume on existing containers and
|
||||
only seen on a fresh volume. Image-owned content that must always be live
|
||||
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
|
||||
interactive login shell.** Your `bash` *tool* runs non-interactively, so these
|
||||
are "command not found" there — you must spell out the underlying command.
|
||||
|
||||
| Interactive alias | Non-interactive equivalent to actually run |
|
||||
|---|---|
|
||||
| `dssh <host>` | `ssh -F "$HOME/.ssh-local/config" <host>` |
|
||||
| `dscp …` | `scp -F "$HOME/.ssh-local/config" …` |
|
||||
| `cat file` (→ `bat`) | `cat file` works, but output differs; use `command cat` for raw |
|
||||
| `ll`, `la` (→ `eza`/`ls`) | `ls -lh`, `ls -lha` |
|
||||
|
||||
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
|
||||
entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
|
||||
`~/.ssh-local/config`. It always provides:
|
||||
|
||||
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
|
||||
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
|
||||
can't be created under it), plus `Include ~/.ssh/config`.
|
||||
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
|
||||
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
|
||||
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
|
||||
reach the host's directly-attached LAN peers (`ProxyJump host`). On a native
|
||||
Linux host the LAN is usually reachable directly and this jump block is
|
||||
omitted — **so don't assume a jump path exists; read the sidecar.**
|
||||
|
||||
Use it (remember §2 — spell it out in tool bash):
|
||||
|
||||
```sh
|
||||
ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
|
||||
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
|
||||
```
|
||||
|
||||
Two related mechanisms (don't reinvent them):
|
||||
|
||||
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
|
||||
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
||||
a `ControlPath` under the read-only `~/.ssh`, override with
|
||||
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
||||
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
||||
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
||||
skill for that path.
|
||||
|
||||
## 4. DNS / name resolution — environment-specific, verify live
|
||||
|
||||
How a name resolves here is **not universal** and depends on the host's
|
||||
networking. The container's own resolver is just `/etc/resolv.conf`, but the
|
||||
*host* (which you reach via §3, and whose DNS the container may inherit) can use
|
||||
**split-horizon DNS** to send certain internal domains to specific nameservers
|
||||
while everything else goes to a default resolver/VPN gateway. The mechanism is
|
||||
OS-specific and **may not be present at all**:
|
||||
|
||||
- **macOS host:** per-domain files in `/etc/resolver/<domain>`, each listing
|
||||
`nameserver` lines. Reading them (over `ssh … mac`) is a fine way to learn the
|
||||
real split-DNS map — *for that one machine.*
|
||||
- **Linux host:** typically `systemd-resolved` split DNS (per-link `Domains=`
|
||||
routing) or `/etc/resolv.conf` `search`/`nameserver`.
|
||||
- **Windows host:** the NRPT (Name Resolution Policy Table) plays the per-suffix
|
||||
role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.
|
||||
|
||||
Operating rules:
|
||||
|
||||
1. **Never hardcode a domain→nameserver mapping or a specific nameserver IP** —
|
||||
it is per-deployment and changes between users and even VPN states.
|
||||
2. **Verify by reading the live config**, e.g. `cat /etc/resolv.conf` in the
|
||||
container, or `ssh … mac 'cat /etc/resolver/* 2>/dev/null'` on a macOS host.
|
||||
3. **Reachability needs both DNS *and* a route.** A name resolving to an
|
||||
internal address is useless if packets to that subnet don't have a path
|
||||
(e.g. via the VPN or the §3 jump). Check both when something "resolves but
|
||||
won't connect."
|
||||
4. If you discover deployment-specific facts (a domain, a nameserver, a
|
||||
reachable peer), prefer recording them in MemPalace over baking them into
|
||||
code or this skill.
|
||||
|
||||
## 5. tmux is 0-indexed — don't change it
|
||||
|
||||
The image ships `/etc/tmux.conf` with `base-index 0` / `pane-base-index 0`
|
||||
because **pi-studio hard-codes its tmux send target to `<session>:0.0`.** If you
|
||||
(or a user `~/.tmux.conf`) set `base-index 1`, pi-studio fails with "can't find
|
||||
window: 0". Leave the indexing alone in this environment.
|
||||
|
||||
## 6. Python and other languages: uv-first, toolchains are ephemeral
|
||||
|
||||
- A system `python3` exists, but **prefer `uv`** for REPLs and project envs —
|
||||
it's installed and its store (`~/.local/share/uv`) is a persisted volume.
|
||||
- Throwaway REPL: `uv run --with ipython ipython`
|
||||
- Project env: `cd /workspace/proj && uv init && uv add <pkgs> && uv run …`
|
||||
(the `pyproject.toml` + `uv.lock` travel with the repo — the durable choice).
|
||||
- Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are
|
||||
**runtime opt-ins on the ephemeral layer** unless baked into the image — they
|
||||
do not survive `down -v` or an image update. Flag this when installing.
|
||||
|
||||
## 7. pi-studio reachability (only in the `-studio` variant)
|
||||
|
||||
Present only if `/opt/pi-studio` exists / the `studio_*` tools are in your tool
|
||||
list. pi-studio **binds to `127.0.0.1` inside the container** with no host-bind
|
||||
flag, so a plain `docker -p` publish can't reach it. Two supported paths:
|
||||
|
||||
- **Host networking** (`network_mode: host`): container loopback == host
|
||||
loopback; open the tokenized URL on the host. (Changes
|
||||
`host.docker.internal` semantics — weigh against §3 LAN jump.)
|
||||
- **`studio-expose` bridge** (`STUDIO_EXPOSE=1` or run `studio-expose &`): a
|
||||
`socat` relay from the container's external interface to its loopback, so a
|
||||
published `127.0.0.1:PORT` + `ssh -L PORT:127.0.0.1:PORT host` reaches it.
|
||||
|
||||
The real auth token comes from the `/studio` slash command (`/studio --status`
|
||||
to reprint), **not** from `studio-expose`. For Graphviz, use `dot-watch` →
|
||||
PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).
|
||||
|
||||
## 8. MemPalace is the shared brain
|
||||
|
||||
MemPalace data is usually a **host bind-mount**, so a pi on the host and a pi in
|
||||
this container share one palace (SQLite WAL: many readers, one writer). Use it
|
||||
to persist the deployment-specific facts this skill deliberately refuses to
|
||||
hardcode. Details are in the `mempalace` skill.
|
||||
|
||||
## Checklist before acting in this environment
|
||||
|
||||
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
||||
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
||||
- [ ] 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).
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
name: pi-extensions
|
||||
description: >-
|
||||
Use the pi extensions (pi-fork, pi-observational-memory, ssh-controlmaster) effectively in the pi coding agent harness. Load this skill only when running inside pi (detection - `fork` and `recall` are present in your tool list, or `pi --ssh` was used to start the session). pi-fork dispatches focused subtasks to forked agents at fast/balanced/deep effort tiers; pi-observational-memory compacts long sessions into recallable observations + reflections; ssh-controlmaster rewires pi's read/write/edit/bash tools to execute on a remote host over a multiplexed SSH connection. This skill covers tier selection, task design, boundary discipline, when to use recall, and remote-pi mechanics.
|
||||
---
|
||||
|
||||
# Pi Extensions: pi-fork, pi-observational-memory, ssh-controlmaster
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
Load only when **both** of these are true:
|
||||
|
||||
1. You are running inside the **pi coding agent harness** (not Claude Code, not opencode, not any other harness).
|
||||
2. The `fork` and/or `recall` tools appear in your available tool list, **or** the session was started with `pi --ssh ...`.
|
||||
|
||||
If you do not see those tools, this skill does not apply — skip it. Other harnesses do not have these extensions and the patterns below will not work there.
|
||||
|
||||
This skill is most useful at the start of any non-trivial session where you may need to dispatch parallel subtasks, where the conversation is likely to compact (sessions running > ~80k tokens), or where pi is operating against a remote host.
|
||||
|
||||
## Pi extension landscape (where the wiring lives)
|
||||
|
||||
Pi has **two distinct extension locations** and it's easy to look in the wrong one:
|
||||
|
||||
| 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-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 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
|
||||
|
||||
pi-fork and pi-observational-memory are symbiotic. **pi-fork burns context** (each fork dispatches a focused subtask whose detailed exploration would otherwise pollute your main thread). **pi-observational-memory preserves context** (when the main thread eventually compacts, observations + reflections survive the fold and can be recalled by ID). Aggressive forking only works long-term if the surviving summary is high-fidelity, and OM only earns its keep when it's preserving genuinely valuable distilled work.
|
||||
|
||||
ssh-controlmaster is orthogonal but composes cleanly: when pi is operating remotely, fork still spawns local sub-agents (each fork *itself* doesn't ssh), but their `bash`/`read`/`write`/`edit` calls do — see Part 3 caveats.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: pi-fork
|
||||
|
||||
### Effort tier mapping
|
||||
|
||||
Configured in `~/.pi/agent/settings.json` under `pi-fork.effortProfiles`. The conventional mapping is:
|
||||
|
||||
| Tier | Model | Use for |
|
||||
|---|---|---|
|
||||
| `fast` | haiku | mechanical edits, narrow lookups, file-listing, single-fact verification, simple syntactic checks |
|
||||
| `balanced` | sonnet (default) | normal exploration, implementation, testing, code review, option analysis |
|
||||
| `deep` | opus | architecture decisions, security analysis, concurrency reasoning, ambiguous debugging, high-risk reviews, runbook drafting where subtle mistakes are costly |
|
||||
|
||||
**Rule of thumb:** start at `balanced` unless you have a specific reason to go up or down. Going too cheap on a deep task wastes a fork; going too expensive on a mechanical task is just slow.
|
||||
|
||||
### When to fork vs. do it yourself
|
||||
|
||||
Fork when **any** of:
|
||||
- The task requires reading many files whose contents you don't need to keep in your main context afterwards (the fork returns a dense summary; raw file contents stay in the fork's context and are discarded).
|
||||
- You want to run multiple analyses in **parallel** (especially: comparing N options, where independent reasoning is itself a signal — see "parallel forks" below).
|
||||
- The task is well-scoped enough to specify completely up front and well-bounded enough that returning a dense report is more useful than continuing the dialogue.
|
||||
- You are about to do something that would burn a lot of tokens on tool calls (long file reads, many bash invocations) whose output you will mostly discard.
|
||||
|
||||
Don't fork when:
|
||||
- The work fits in your current context budget without crowding out what comes next.
|
||||
- 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 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
|
||||
|
||||
When facing a "which approach should we take" question with 2–4 candidate approaches, dispatching the candidates as parallel forks is high-leverage:
|
||||
|
||||
- They reason **independently**. No fork sees the others' work.
|
||||
- **Convergence is signal.** If three forks at different effort tiers reach the same recommendation citing different evidence, that's a strong validation that doesn't depend on any one model's bias.
|
||||
- **Divergence is also signal.** If one disagrees, read its reasoning carefully — it may have spotted something the others missed, or it may have a tier-specific weakness worth knowing.
|
||||
|
||||
Sample shape for an option-comparison call:
|
||||
- Fork 1 (deep) — detailed runbook for option A, with timing/risk/rollback
|
||||
- Fork 2 (balanced) — comparison table A vs B vs C across N axes, with a recommendation
|
||||
- Fork 3 (fast) — focused sub-question (e.g., "which container image / library version / CLI flag")
|
||||
|
||||
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 — and the mechanism that defeats briefs
|
||||
|
||||
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 — 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
|
||||
|
||||
- **Forking trivial work.** A fork has overhead. If the task takes < 30 seconds in your main thread, just do it.
|
||||
- **Vague briefs.** "Look into the database thing" returns vague output. The fork is not telepathic.
|
||||
- **Forking iterative work.** Forks are one-shot. If you need to iterate, you'll re-spec the task each time — usually worse than doing it yourself.
|
||||
- **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
|
||||
|
||||
### How it actually works
|
||||
|
||||
Observational memory (OM v3, "session-ledger" architecture) runs an **observer agent** in the background as your conversation grows. When token thresholds are crossed (defaults: observe at 10k, reflect at 20k, compact at 81k), the observer distills the recent transcript into:
|
||||
|
||||
- **Observations** — timestamped events, each with a 12-character hex ID like `[3682ebfad7af]`. Compact one-liners describing what happened in the conversation.
|
||||
- **Reflections** — durable, long-lived facts about the user, project, decisions, and constraints. Some reflections include observation IDs as evidence pointers.
|
||||
|
||||
When compaction fires, the raw transcript is folded away and replaced with a structured summary block containing the observations + reflections. **You — the next turn of the same agent — receive that summary block as your starting context.** That's the recovery mechanism.
|
||||
|
||||
**Storage is in-transcript, not on disk.** Do not grep for `observations.jsonl` or similar files; you will not find them. The artifact lives in the model's input context window.
|
||||
|
||||
Configuration lives in `~/.pi/agent/settings.json` under `observational-memory`. Tune `observeAfterTokens`, `reflectAfterTokens`, `compactAfterTokens`, and `observationsPoolMaxTokens` if observations feel sparse or noisy. The default 81k compaction threshold is well-calibrated for typical multi-task sessions.
|
||||
|
||||
### The `recall` tool
|
||||
|
||||
`recall(<12-char-hex-id>)` resolves a specific observation or reflection ID back to the original source context — the exact bash output, file contents, tool call results, commit message, or transcript fragment that the observation was distilled from.
|
||||
|
||||
**Use recall when:**
|
||||
- You are about to make a decision that depends materially on a compacted observation or reflection whose details are unclear.
|
||||
- You need exact wording, paths, commands, errors, commits, or user constraints behind a remembered claim.
|
||||
- A broad reflection is relevant but you need its supporting observations to act safely.
|
||||
- The user asks "why do you believe X" or "what supports that memory".
|
||||
|
||||
**Do not use recall for:**
|
||||
- Semantic search (it's keyed by ID, not topic — you must already have a specific 12-char hex ID).
|
||||
- Browsing the transcript out of curiosity.
|
||||
- Preemptive lookup of every ID in your context "just in case".
|
||||
|
||||
Recall costs tokens. Use it when exact source context will materially change your next action.
|
||||
|
||||
> **Calibration note (from a real ~1-month trial, 2026-05/06):** across 20 logged container sessions, `recall` was invoked **0 times** while obsmem passively carried 529 observations across 6 compactions. Zero recall is a *warning sign*, not a badge of efficiency — it means decisions after a compaction were made on the distilled one-liner alone, without ever re-checking the source. The injected summary is **lossy by design**. Default habit to adopt: when you are about to **edit code, ship a change, or assert a fact** that rests on a `[high]`/`[critical]` observation or a reflection you did not produce *this* turn, `recall` its ID **first**. One recall before a load-bearing action is cheap; redoing finished work or contradicting a prior correction is not.
|
||||
|
||||
### Reading the compaction summary
|
||||
|
||||
When you see a block like `The conversation history before this point was compacted into the following summary:` at the start of a session or turn, that's OM output. Standard structure:
|
||||
|
||||
- **Reflections** at the top: stable facts. Some have IDs in brackets.
|
||||
- **Observations** below, chronological: timestamped events with IDs in brackets and importance markers (`[high]`, `[critical]`, etc.).
|
||||
|
||||
When entries conflict, **the most recent observation reflects the latest known state.** Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Treating compacted memory as definitive without recall** when stakes are high. Compaction is lossy; the observation may have lost a constraint that was on the line above it in the original transcript.
|
||||
- **Recalling every ID preemptively.** Wasteful. Recall on demand.
|
||||
- **Assuming the disk holds OM artifacts.** It doesn't. Don't waste time looking.
|
||||
- **Ignoring the summary block** when starting a session. It's there because the prior session was real work — read it before answering questions about past work.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```
|
||||
fork(task=..., effort=fast|balanced|deep)
|
||||
- state decision authority explicitly
|
||||
- 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
|
||||
- id must already be visible in your context
|
||||
- not a search tool
|
||||
```
|
||||
|
||||
```
|
||||
~/.pi/agent/settings.json
|
||||
pi-fork.effortProfiles — model + thinking-depth per tier
|
||||
pi-fork.defaultEffort — usually "balanced"
|
||||
observational-memory.* — token thresholds, model, agentMaxTurns
|
||||
observational-memory.debugLog: true — opt-in NDJSON telemetry at
|
||||
~/.pi/agent/observational-memory/debug/<session>.ndjson (off by default)
|
||||
```
|
||||
|
||||
### Installing on a fresh machine (host)
|
||||
|
||||
These are git-sourced pi packages (pi-fork is **not** on npm). Add to the
|
||||
`packages` array in `~/.pi/agent/settings.json`, or:
|
||||
|
||||
```
|
||||
pi install git:github.com/elpapi42/pi-fork
|
||||
pi install git:github.com/elpapi42/pi-observational-memory # default branch: master (no main)
|
||||
# obsmem is also published: pi install npm:pi-observational-memory
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`evaluate-extension-usage.py` (bundled next to this skill) mines pi session
|
||||
transcripts for fork/recall counts and obsmem compaction stats. Run it per
|
||||
machine (transcripts live at `~/.pi/agent/sessions/`) for a combined
|
||||
host+container picture:
|
||||
|
||||
```
|
||||
./evaluate-extension-usage.py # ~/.pi/agent/sessions
|
||||
./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
|
||||
|
||||
### What it does
|
||||
|
||||
When pi is launched with `--ssh`, this extension **rewires pi's `read`, `write`, `edit`, and `bash` tools to execute on the remote machine**, multiplexed over a single SSH ControlMaster socket. Pi is still running locally — the LLM, the UI, the MCP servers, the fork dispatcher all live on your local box — but anything those tools touch on the filesystem is the *remote's* filesystem.
|
||||
|
||||
This is fundamentally different from running pi locally and using `bash` to ssh inside it: with `--ssh`, the tool layer itself is remoted, so the LLM thinks it's working in the remote's `cwd` (the system prompt is rewritten to say so).
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Key-based auth (preferred), remote cwd defaults to remote $HOME
|
||||
pi --ssh lagret
|
||||
|
||||
# Pin to a specific remote directory
|
||||
pi --ssh lagret:/volume1/docker/portainer/compose/119
|
||||
|
||||
# Password auth (input is NOT masked when typing)
|
||||
pi --ssh user@host --ssh-ask-pass
|
||||
```
|
||||
|
||||
The `lagret` form requires a `Host lagret` block in `~/.ssh/config` or a resolvable hostname. The status bar shows `SSH ⚡ own master <host>:<cwd>` or `SSH ⚡ system master <host>:<cwd>` once connected.
|
||||
|
||||
### How it cooperates with system SSH config
|
||||
|
||||
It reads `ssh -G <host>` to learn the effective config, then:
|
||||
|
||||
| `~/.ssh/config` for the host | Behavior |
|
||||
|---|---|
|
||||
| `ControlMaster auto` or `yes` with a `ControlPath` | Reuses the system master socket. Does **not** tear it down on pi exit ("it was the system's to manage before pi arrived"). |
|
||||
| No ControlMaster configured (or explicitly `no`) | Creates its own master at `/tmp/pi-cm-<pid>.sock` with `ControlPersist=yes`. Tears it down on pi `session_shutdown`. |
|
||||
|
||||
This means it composes cleanly with the system-wide `ssh-control-master-setup.sh` helper from the `ci-release-watcher` skill: if that script has already configured `~/.ssh/config` for the host, `pi --ssh` rides on the existing master rather than opening a parallel connection.
|
||||
|
||||
### Caveats and edge cases
|
||||
|
||||
- **Local vs remote tool boundary.** Only `read`/`write`/`edit`/`bash` are remoted. **MCP servers are still local** — `mempalace` files drawers and diary entries against the local palace even when your shell work happens remotely. Same for `fork`, `recall`, `todo`, and any other custom tool. This is usually what you want (palace memory survives across remote sessions) but worth knowing.
|
||||
- **fork over ssh.** Forks spawn locally and inherit the same `--ssh` mode by virtue of the parent's tool wiring; the fork's bash calls hit the same ControlMaster. Forks burn the same SSH socket, not a parallel one — multiplexing wins again.
|
||||
- **macOS Unix socket path limit.** The own-master socket lives at `/tmp/pi-cm-<pid>.sock` to stay under macOS's ~104-char limit. If you have a non-default `TMPDIR` long enough to blow this, ssh will fail to start the master.
|
||||
- **Password auth password visibility.** From the source: *"input is NOT masked — the password is visible while typing."* The password is written to a chmod-700 SSH_ASKPASS script in `/tmp` and deleted after the master establishes; not persisted, but on-screen during entry.
|
||||
- **Remote bash environment.** The remote shell is whatever `ssh user@host '<cmd>'` invokes — typically a non-login non-interactive bash. Don't expect `~/.bashrc` aliases or PATH manipulations from `~/.profile`. Pin tool paths or invoke via `bash -lc '...'` if you need login-shell behavior.
|
||||
- **Path translation is naive.** The extension does `path.replace(localCwd, remoteCwd)` to translate paths in tool calls. If the LLM emits an absolute remote path that doesn't share the local-cwd prefix, the path is passed through unchanged — usually fine but pathological for paths that happen to contain the local-cwd substring.
|
||||
|
||||
### When to use it
|
||||
|
||||
- Editing configs on a NAS / homelab host without scp ping-pong (`pi --ssh lagret:/volume1/...`)
|
||||
- Operating against a host whose tools/data you need but whose disk is too slow to mount via SSHFS
|
||||
- Investigating runner state, container configs, etc., on a remote host as if local
|
||||
- Multi-step remote work where opening a fresh ssh connection per step would burn your CGNAT flow budget
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Using `pi --ssh` for one-off shell work.** Just `ssh` directly. The extension shines when there are dozens of tool calls per session.
|
||||
- **Filing palace drawers expecting them on the remote.** They go to the local palace. If you want palace artifacts on the remote host, ssh into the remote and run pi *there* against its local palace.
|
||||
- **Forgetting `--ssh` in followup sessions.** Status bar is the canary — if you don't see `SSH ⚡` you're operating locally despite intending remote. Easy mistake on a fresh terminal.
|
||||
|
||||
### Reaching the devbox host from inside the container (`dssh` / `dscp`)
|
||||
|
||||
Distinct from `pi --ssh` above. When the **pi-devbox container** runs under OrbStack / Docker Desktop on macOS, it can SSH back to its own host. The entrypoint's `setup-lan-access.sh` regenerates `~/.ssh-local/config` on **every container start** (the in-container `~/.ssh` is mounted read-only, so a sidecar config + `known_hosts` + `ControlPath` under `~/.ssh-local/` is used instead).
|
||||
|
||||
```bash
|
||||
# Interactive shells get aliases (from ~/.bash_aliases):
|
||||
dssh host 'cmd' # = ssh -F ~/.ssh-local/config host
|
||||
dscp file host:/path # = scp -F ~/.ssh-local/config ...
|
||||
```
|
||||
|
||||
**The agent's `bash` tool is non-interactive — those aliases are NOT loaded.** Use the explicit form:
|
||||
|
||||
```bash
|
||||
ssh -F ~/.ssh-local/config host 'cmd'
|
||||
scp -F ~/.ssh-local/config <src> host:<dst>
|
||||
```
|
||||
|
||||
- Host aliases `host` and `mac` both resolve to `host.docker.internal` (user varies per host machine — check `~/.ssh-local/config` for the active `User` value, key `~/.ssh-local/devbox_jump_ed25519`, `ControlMaster auto` / `ControlPersist 4h`).
|
||||
- The config chains `Include ~/.config/devbox-shell/ssh-lan.conf` then `Include ~/.ssh/config`, so LAN targets are reachable too (add `ProxyJump host` to those entries).
|
||||
- **Use it for:** enabling/inspecting the host's pi config (`~/.pi/agent/settings.json`), running `evaluate-extension-usage.py` against the host's `~/.pi/agent/sessions/` for a combined host+container metric, or copying host transcripts into the container. The host's pi runs natively there; its palace, sessions, and extensions are separate from the container's.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Skill Notes
|
||||
|
||||
- **mempalace** is for cross-session persistent memory (diary, knowledge graph, drawer storage). OM is for **within-session** context survival across compaction. They complement each other: write a diary entry at session end *and* let OM compact your work-in-progress mid-session.
|
||||
- **systematic-debugging** and **test-driven-development** skills pair well with deep-tier forks: a deep fork can carry out a focused debugging investigation or write a failing test suite without polluting your main context.
|
||||
- **ci-release-watcher** ships a `scripts/ssh-control-master-setup.sh` helper that configures system-wide SSH ControlMaster in `~/.ssh/config`. That's a separate mechanism from the `ssh-controlmaster` pi extension — they compose, they don't overlap. Use the script for persistent host-wide multiplexing, the extension for per-pi-session remote operation.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate pi-fork / pi-observational-memory usage from pi session transcripts.
|
||||
|
||||
Mines pi's session .jsonl transcripts and reports:
|
||||
- per-tool call counts (highlighting `fork` and `recall`)
|
||||
- per-session fork/recall breakdown
|
||||
- obsmem passive activity: compaction events, observations carried,
|
||||
relevance-tier distribution, tokensBefore
|
||||
|
||||
Works on any machine. Point it at one or more session roots; by default it
|
||||
scans ~/.pi/agent/sessions (the standard pi location, host or container).
|
||||
|
||||
Usage:
|
||||
./evaluate-extension-usage.py # ~/.pi/agent/sessions
|
||||
./evaluate-extension-usage.py /path/to/sessions ... # explicit roots
|
||||
./evaluate-extension-usage.py --host HOST /path ... # label a root (for combined host+container runs)
|
||||
|
||||
For a true host+container picture, run once per machine (or copy each
|
||||
machine's ~/.pi/agent/sessions here) and pass all roots together.
|
||||
"""
|
||||
import json, sys, os, glob, re, collections, argparse
|
||||
|
||||
TIER_RE = re.compile(r'\[(low|medium|high|critical)\]')
|
||||
OBS_LINE_RE = re.compile(r'^\[[0-9a-f]{12}\] ', re.M)
|
||||
|
||||
|
||||
def walk_tools(x, counter):
|
||||
if isinstance(x, dict):
|
||||
tn = x.get("toolName")
|
||||
if tn:
|
||||
counter[tn] += 1
|
||||
for v in x.values():
|
||||
walk_tools(v, counter)
|
||||
elif isinstance(x, list):
|
||||
for v in x:
|
||||
walk_tools(v, counter)
|
||||
|
||||
|
||||
def analyze(roots):
|
||||
files = []
|
||||
for r in roots:
|
||||
if os.path.isfile(r) and r.endswith(".jsonl"):
|
||||
files.append(r)
|
||||
else:
|
||||
files += glob.glob(os.path.join(r, "**", "*.jsonl"), recursive=True)
|
||||
files = sorted(set(files))
|
||||
|
||||
tool_total = collections.Counter()
|
||||
per_session = []
|
||||
compactions = []
|
||||
for f in files:
|
||||
tc = collections.Counter()
|
||||
with open(f, errors="ignore") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.strip()
|
||||
if not ln:
|
||||
continue
|
||||
try:
|
||||
o = json.loads(ln)
|
||||
except Exception:
|
||||
continue
|
||||
walk_tools(o, tc)
|
||||
if o.get("type") == "compaction":
|
||||
s = o.get("summary", "") or ""
|
||||
compactions.append({
|
||||
"file": os.path.basename(f),
|
||||
"tokensBefore": o.get("tokensBefore"),
|
||||
"observations": len(OBS_LINE_RE.findall(s)),
|
||||
"tiers": dict(collections.Counter(TIER_RE.findall(s))),
|
||||
})
|
||||
tool_total.update(tc)
|
||||
per_session.append((os.path.basename(f)[:10], tc.get("fork", 0),
|
||||
tc.get("recall", 0), sum(tc.values())))
|
||||
return files, tool_total, per_session, compactions
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("roots", nargs="*",
|
||||
default=[os.path.expanduser("~/.pi/agent/sessions")])
|
||||
args = ap.parse_args()
|
||||
|
||||
files, tool_total, per_session, comp = analyze(args.roots)
|
||||
if not files:
|
||||
print("No .jsonl transcripts found under:", args.roots, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"=== {len(files)} transcripts under {args.roots} ===\n")
|
||||
print("Tool call totals:")
|
||||
for t, c in tool_total.most_common():
|
||||
mark = " <== pi-fork" if t == "fork" else (" <== obsmem recall" if t == "recall" else "")
|
||||
print(f" {c:6d} {t}{mark}")
|
||||
|
||||
fk = tool_total["fork"]; rc = tool_total["recall"]
|
||||
fk_sess = sum(1 for p in per_session if p[1])
|
||||
rc_sess = sum(1 for p in per_session if p[2])
|
||||
print(f"\npi-fork: {fk} calls across {fk_sess} sessions")
|
||||
print(f"recall: {rc} calls across {rc_sess} sessions"
|
||||
+ (" (!) zero recall over the window — see SKILL.md calibration note" if rc == 0 else ""))
|
||||
|
||||
if comp:
|
||||
tot_obs = sum(c["observations"] for c in comp)
|
||||
tb = [c["tokensBefore"] for c in comp if c["tokensBefore"]]
|
||||
print(f"\nobsmem passive: {len(comp)} compactions, {tot_obs} observations carried"
|
||||
+ (f", avg tokensBefore {sum(tb)//len(tb):,}" if tb else ""))
|
||||
agg = collections.Counter()
|
||||
for c in comp:
|
||||
agg.update(c["tiers"])
|
||||
if agg:
|
||||
print(" relevance tiers:", dict(agg))
|
||||
else:
|
||||
print("\nobsmem passive: no compaction events found "
|
||||
"(short sessions, or obsmem not active on these transcripts)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-base-hash.sh — guard the base-rebuild invariant.
|
||||
#
|
||||
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
|
||||
# into the base_tag hash in the docker-publish workflow. Otherwise a
|
||||
# ref-only change to that dependency does not change the base hash, the
|
||||
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
|
||||
# the dependency fix silently fails to land. This is the v1.1.2-class
|
||||
# staleness footgun (then it was mempalace-toolkit; this guard stops the
|
||||
# next one before it ships).
|
||||
#
|
||||
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
WF=".gitea/workflows/docker-publish.yml"
|
||||
DF="Dockerfile.base"
|
||||
|
||||
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
|
||||
# brace-group in the "Compute base tag" step. This lives in a separate
|
||||
# file from the workflow, so scanning $WF here is free of the self-match
|
||||
# hazard an inline workflow step would have.
|
||||
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
|
||||
if [ -z "$block" ]; then
|
||||
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
|
||||
fail=0
|
||||
for r in $refs; do
|
||||
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
|
||||
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
|
||||
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
|
||||
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" = 0 ]; then
|
||||
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
|
||||
fi
|
||||
exit $fail
|
||||
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
|
||||
Executable
+321
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runtime post-recreate verification for pi-devbox.
|
||||
#
|
||||
# Verifies that after `docker compose up -d --force-recreate`:
|
||||
# - The new image is actually live (pi version matches, when an expected
|
||||
# version is supplied — see the version note below)
|
||||
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
|
||||
# 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 in settings.json packages[]
|
||||
# - Shell defaults re-seeded from /etc/skel-devbox
|
||||
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
||||
# - /opt toolkits intact
|
||||
# - Known expected-absences don't regress
|
||||
#
|
||||
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
|
||||
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
|
||||
# a recreated container's persisted volumes or the entrypoint's runtime
|
||||
# deploy. This script is its runtime counterpart: it inspects what is actually
|
||||
# live in the container you are sitting in after a recreate.
|
||||
#
|
||||
# It is NOT baked into the published Docker Hub image; run it from a checkout of
|
||||
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
|
||||
# `docker pull` consumer is not the audience and will not have this file.
|
||||
#
|
||||
# Version note: pi's version is resolved from `latest` at CI build time and is
|
||||
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
|
||||
# So unlike opencode-devbox, this script cannot self-derive an expected version
|
||||
# from the Dockerfile. Pass --expected-version to assert a match; without it the
|
||||
# live pi version is reported as an informational WARN, not a failure.
|
||||
#
|
||||
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
# 1 one or more checks failed
|
||||
# 2 usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_VERSION=""
|
||||
VARIANT=""
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-version)
|
||||
EXPECTED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--variant)
|
||||
VARIANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
|
||||
# /opt/pi-studio; the plain variant does not.
|
||||
if [ -z "$VARIANT" ]; then
|
||||
if [ -d /opt/pi-studio ]; then
|
||||
VARIANT="studio"
|
||||
else
|
||||
VARIANT="plain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print header with git context
|
||||
echo "=== Recreate sanity check (variant: $VARIANT) ==="
|
||||
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
|
||||
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
|
||||
else
|
||||
echo " Repo HEAD: (not a git repo or no tags)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "-- pi version --"
|
||||
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||
pass "pi version $ACTUAL_VERSION"
|
||||
else
|
||||
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
|
||||
fi
|
||||
else
|
||||
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
|
||||
fi
|
||||
else
|
||||
fail "pi --version failed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Persisted named volumes (must survive --force-recreate) --"
|
||||
|
||||
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
|
||||
# keybindings symlink. Must exist and be non-empty after recreate.
|
||||
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
|
||||
pass "~/.pi/agent exists and is non-empty"
|
||||
else
|
||||
fail "~/.pi/agent missing or empty"
|
||||
fi
|
||||
|
||||
# shell history volume (devbox-shell-history). An empty .bash_history right
|
||||
# after recreate is NORMAL — only the mount point must exist.
|
||||
if [ -d "$HOME/.cache/bash" ]; then
|
||||
pass "~/.cache/bash exists as directory"
|
||||
else
|
||||
fail "~/.cache/bash missing or not a directory"
|
||||
fi
|
||||
|
||||
# remaining persisted volumes — mount points must exist
|
||||
for vol_path in \
|
||||
"$HOME/.local/share/zoxide" \
|
||||
"$HOME/.local/share/nvim" \
|
||||
"$HOME/.local/share/uv" \
|
||||
"$HOME/.ssh-local"; do
|
||||
if [ -d "$vol_path" ]; then
|
||||
pass "$vol_path exists"
|
||||
else
|
||||
fail "$vol_path missing or not a directory"
|
||||
fi
|
||||
done
|
||||
|
||||
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
|
||||
# devbox-palace named volume is commented out; the palace is reached via the
|
||||
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
|
||||
# is NOT a recreate regression here.
|
||||
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
|
||||
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
|
||||
else
|
||||
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
|
||||
fi
|
||||
else
|
||||
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
|
||||
|
||||
# keybindings symlink (pi-toolkit)
|
||||
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
|
||||
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/keybindings.json missing or not a symlink"
|
||||
fi
|
||||
|
||||
# global AGENTS.md symlink (pi-toolkit) — global instructions loaded by pi at
|
||||
# every start (directs the agent to read the pi-extensions skill at session start)
|
||||
if [ -L "$HOME/.pi/agent/AGENTS.md" ]; then
|
||||
pass "~/.pi/agent/AGENTS.md symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
||||
fi
|
||||
|
||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
|
||||
else
|
||||
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
||||
fi
|
||||
|
||||
# mempalace.ts bridge symlink
|
||||
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
||||
else
|
||||
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
|
||||
fi
|
||||
|
||||
# settings.json bootstrapped
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
pass "~/.pi/agent/settings.json bootstrapped"
|
||||
else
|
||||
fail "~/.pi/agent/settings.json missing"
|
||||
fi
|
||||
|
||||
# settings.json merge: the entrypoint deep-merges new template keys into a
|
||||
# preserved settings.json on every start, so config added in an image upgrade
|
||||
# (e.g. the observational-memory / pi-fork blocks) reaches existing volumes.
|
||||
# Assert those blocks are present and that the file is still valid JSON.
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
if jq -e 'has("observational-memory") and has("pi-fork")' "$HOME/.pi/agent/settings.json" >/dev/null 2>&1; then
|
||||
pass "settings.json has observational-memory + pi-fork blocks (template merge)"
|
||||
else
|
||||
fail "settings.json missing observational-memory and/or pi-fork blocks (template merge did not land)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 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 _pkg_registered "$pkg"; then
|
||||
pass "$pkg registered in settings.json packages[]"
|
||||
else
|
||||
fail "$pkg NOT in settings.json packages[] (tool will not load)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if _pkg_registered pi-studio; then
|
||||
pass "pi-studio registered in settings.json packages[]"
|
||||
else
|
||||
fail "pi-studio NOT in settings.json packages[] (studio variant)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- ssh ControlMaster dir --"
|
||||
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
|
||||
pass "/tmp/sshcm exists with mode 700"
|
||||
else
|
||||
fail "/tmp/sshcm missing or not mode 700"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
||||
if [ -f "$HOME/.bash_aliases" ]; then
|
||||
pass "~/.bash_aliases exists"
|
||||
else
|
||||
fail "~/.bash_aliases missing"
|
||||
fi
|
||||
|
||||
# History flush must survive shell nesting. The DEVBOX_HIST_SET guard must NOT
|
||||
# be exported: if it leaks into child processes, nested shells (esp. tmux
|
||||
# panes) skip installing `history -a` and lose in-memory history on abrupt
|
||||
# termination. Assert a child login shell still wires up the per-prompt flush.
|
||||
if bash -lic 'bash -lic "case \"\$PROMPT_COMMAND\" in *\"history -a\"*) exit 0;; *) exit 1;; esac"' </dev/null >/dev/null 2>&1; then
|
||||
pass "nested shell installs 'history -a' (DEVBOX_HIST_SET not exported)"
|
||||
else
|
||||
fail "nested shell missing 'history -a' — DEVBOX_HIST_SET leaking to children?"
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.inputrc" ]; then
|
||||
pass "~/.inputrc exists"
|
||||
else
|
||||
fail "~/.inputrc missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- cli_utils bind-mount --"
|
||||
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
|
||||
pass "/workspace/cli_utils exists with .git subdir"
|
||||
else
|
||||
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Baked /opt toolkits --"
|
||||
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
|
||||
if [ -d "$opt_path" ]; then
|
||||
pass "$opt_path exists"
|
||||
else
|
||||
fail "$opt_path missing"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
|
||||
pass "/opt/pi-studio exists with prebuilt client bundle"
|
||||
else
|
||||
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# mempalace MCP entrypoint on PATH
|
||||
if command -v mempalace-mcp >/dev/null 2>&1; then
|
||||
pass "mempalace-mcp on PATH"
|
||||
else
|
||||
fail "mempalace-mcp not on PATH"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Known expected-absences (regressions vs by-design) --"
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
warn "go absent — expected unless image built with INSTALL_GO=true"
|
||||
else
|
||||
pass "go is on PATH"
|
||||
fi
|
||||
|
||||
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
|
||||
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "=== FAILED: $FAILED check(s) ===" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== PASSED ==="
|
||||
+196
-24
@@ -1,26 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# smoke-test.sh — basic sanity checks for the pi-devbox image
|
||||
# smoke-test.sh — sanity checks for the pi-devbox image
|
||||
#
|
||||
# Usage: ./scripts/smoke-test.sh <image>
|
||||
#
|
||||
# Verifies:
|
||||
# - pi binary present and returns a version
|
||||
# - 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
|
||||
# - pi-fork + pi-observational-memory cloned with node_modules baked
|
||||
# - entrypoint deploys pi-toolkit keybindings symlink
|
||||
# - entrypoint deploys ≥4 extensions
|
||||
# - mempalace bridge symlink present
|
||||
# - settings.json bootstrapped
|
||||
# - 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
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: $0 <image>}"
|
||||
PASS=0; FAIL=0
|
||||
# Since the refactor to FROM opencode-devbox:latest-pi-only, this image equals
|
||||
# the pi-only variant (pi + companions + fork/recall node_modules, NO opencode),
|
||||
# so the threshold tracks pi-only's (2850 MB), not the old standalone 2200 MB.
|
||||
SIZE_THRESHOLD_MB=2850
|
||||
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
|
||||
# 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"
|
||||
@@ -31,12 +50,12 @@ run() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Stricter version of `run` that also asserts an expected substring in stdout.
|
||||
# Used for catching the "image bytes silently identical to previous release"
|
||||
# class of regression (Docker layer cache hit on `npm install -g <pkg>` because
|
||||
# the bare command string is identical across builds, even when `latest` would
|
||||
# resolve differently). Discovered 2026-05-23 — every pi-devbox release v0.74.0
|
||||
# through v0.75.5 had been shipping the same image bytes.
|
||||
# Stricter version of `run` that asserts an expected substring in stdout.
|
||||
# Catches the "image bytes silently identical to previous release" class of
|
||||
# regression — Docker layer cache hit on `npm install -g <pkg>` because the
|
||||
# bare command string is identical across builds, even when `latest` would
|
||||
# resolve differently. Discovered 2026-05-23 — every pi-devbox release
|
||||
# v0.74.0..v0.75.5 had been shipping the same image bytes.
|
||||
run_expect() {
|
||||
local label="$1"; local cmd="$2"; local expect="$3"
|
||||
local out
|
||||
@@ -51,7 +70,7 @@ run_expect() {
|
||||
echo "=== pi-devbox smoke test: $IMAGE ==="
|
||||
echo ""
|
||||
|
||||
# ── Basic binary checks ───────────────────────────────────────────────
|
||||
# ── Binaries ─────────────────────────────────────────────────────────
|
||||
echo "── Binaries ──"
|
||||
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
|
||||
run_expect "pi version matches build arg" "pi --version" "$EXPECTED_PI_VERSION"
|
||||
@@ -63,20 +82,108 @@ 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'"
|
||||
run "tldr (tealdeer)" "tldr --version"
|
||||
run "socat" "socat -V"
|
||||
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
|
||||
run "image-baked pi-devbox-environment skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
|
||||
run "global-AGENTS append snippet present" \
|
||||
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
|
||||
run "pi-devbox block merged into pi-global-AGENTS.md" \
|
||||
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
run "mempalace session-start pointer merged into global AGENTS.md" \
|
||||
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
# Vendored fallback skills (so a no-skillset container still resolves the
|
||||
# AGENTS.md 'read the pi-extensions skill' pointer).
|
||||
run "image-baked pi-extensions fallback skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md"
|
||||
run "pi-extensions skill ships its helper" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py"
|
||||
run "image-baked mempalace fallback skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
|
||||
# Layered freshness: when the pinned pi-extensions clone carries the skill, the
|
||||
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
||||
run "pi-extensions skill refreshed from package when present" \
|
||||
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
||||
|
||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||
echo ""
|
||||
echo "── tmux config ──"
|
||||
run_expect "/etc/tmux.conf has base-index 0" \
|
||||
"cat /etc/tmux.conf" "set -g base-index 0"
|
||||
run_expect "/etc/tmux.conf has pane-base-index 0" \
|
||||
"cat /etc/tmux.conf" "set -g pane-base-index 0"
|
||||
|
||||
# ── Repo clones ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Repo clones ──"
|
||||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) — inherited from
|
||||
# the pi-only base, cloned to /opt with node_modules baked at build time.
|
||||
run "pi-fork clone + node_modules" \
|
||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||||
run "pi-observational-memory clone + node_modules" \
|
||||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
|
||||
|
||||
# pi-studio is present only in the :latest-studio variant. Auto-detect by
|
||||
# probing /opt/pi-studio so this one script covers both variants.
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c 'test -d /opt/pi-studio' >/dev/null 2>&1; then
|
||||
STUDIO_VARIANT=1
|
||||
echo " ℹ️ pi-studio detected — running studio assertions"
|
||||
run "pi-studio clone + node_modules" \
|
||||
"test -f /opt/pi-studio/package.json && test -d /opt/pi-studio/node_modules"
|
||||
run "pi-studio prebuilt client bundle" \
|
||||
"test -f /opt/pi-studio/client/studio-client.js"
|
||||
else
|
||||
STUDIO_VARIANT=0
|
||||
echo " ℹ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
|
||||
fi
|
||||
|
||||
# ── Build provenance (manifest + OCI labels) ─────────────────────────
|
||||
echo ""
|
||||
echo "── Build provenance ──"
|
||||
run "/etc/pi-devbox/build-manifest.json present" \
|
||||
"test -f /etc/pi-devbox/build-manifest.json"
|
||||
run_expect "manifest records pi-extensions component" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
|
||||
run_expect "manifest records pi_version" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
|
||||
# Every component must be a resolved commit (or null for pi-studio in the
|
||||
# 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)
|
||||
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
|
||||
printf " ✅ OCI label se.jordbo.pi-devbox.pi-extensions-ref=%s\n" "$LBL"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ OCI label se.jordbo.pi-devbox.pi-extensions-ref missing or empty\n"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# ── Runtime deployment (needs entrypoint to run) ──────────────────────
|
||||
echo ""
|
||||
echo "── Runtime deployment ──"
|
||||
@@ -88,9 +195,22 @@ CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
|
||||
cleanup() { docker rm -f "$CID" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
|
||||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
|
||||
# Gate on BOTH the keybindings symlink (deployed by pi-toolkit) AND the
|
||||
# mempalace.ts bridge (deployed last by entrypoint-user.sh) AND ≥4 *.ts
|
||||
# extensions present. Parallel build load can otherwise sample the *.ts
|
||||
# count mid-deploy and produce a flake. See opencode-devbox c6f9d11
|
||||
# (2026-06-08) — same fix transplanted.
|
||||
for i in $(seq 1 45); do
|
||||
if docker exec "$CID" sh -c '
|
||||
test -L /home/developer/.pi/agent/keybindings.json && \
|
||||
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
|
||||
test -L /home/developer/.agents/skills/pi-devbox-environment && \
|
||||
test -L /home/developer/.agents/skills/pi-extensions && \
|
||||
test -L /home/developer/.agents/skills/mempalace && \
|
||||
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
|
||||
[ "$count" -ge 4 ]
|
||||
' >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
@@ -109,24 +229,76 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
|
||||
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
||||
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
||||
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
||||
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
||||
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||
|
||||
# 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 -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 in packages[] (/studio command + studio_* tools)" \
|
||||
"$(pkg_registered_cmd pi-studio)"
|
||||
fi
|
||||
|
||||
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
|
||||
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
||||
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
||||
|
||||
# ── Image size ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Image size ──"
|
||||
SIZE_MB=$(docker image inspect "$IMAGE" --format='{{.Size}}' | awk '{printf "%d", $1/1048576}')
|
||||
if [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||||
# Sum all layers via `docker history`. Docker's `image inspect --format='{{.Size}}'`
|
||||
# returns ONLY the variant-unique layer when the base is content-addressed and
|
||||
# shared (the case in this repo's two-phase build), which understates the
|
||||
# user-facing image size by 2+ GB. Summing layer sizes from history is the
|
||||
# metric Hub displays to users and the one we actually want to gate on.
|
||||
SIZE_MB=$(docker history --format '{{.Size}}' "$IMAGE" | python3 -c '
|
||||
import sys, re
|
||||
total=0.0
|
||||
for line in sys.stdin:
|
||||
s=line.strip()
|
||||
if s in ("0B", ""): continue
|
||||
m=re.match(r"^([0-9.]+)(B|kB|MB|GB)$", s)
|
||||
if not m: continue
|
||||
v=float(m.group(1)); u=m.group(2)
|
||||
mult={"B":1/1048576,"kB":1/1024,"MB":1,"GB":1024}[u]
|
||||
total+=v*mult
|
||||
print(int(total))
|
||||
')
|
||||
if [ -z "$SIZE_MB" ] || [ "$SIZE_MB" = "0" ]; then
|
||||
printf " ⚠️ image size: could not parse — skipping check\n"
|
||||
elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||||
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1))
|
||||
|
||||
Reference in New Issue
Block a user