Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29b62093f0 | |||
| cbd7cf5c67 | |||
| 7c00dd6001 | |||
| 7649d53f3b | |||
| ade58131d6 | |||
| ffd44ad9cf | |||
| 43cd6e22f2 | |||
| 62a2a79b1c | |||
| f20b2a7926 | |||
| 66a19aa394 | |||
| 572430237f | |||
| 1fd524e7fb |
+61
-3
@@ -12,16 +12,59 @@ 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.
|
||||
# docker-compose.yml to persist it across container recreation — that one
|
||||
# volume now covers the mined conversation transcripts too, since the pi and
|
||||
# opencode feeders stage inside the palace root (<palace-root>/pi-stage), so
|
||||
# the staged files and the palace dedup keys pointing at them cannot be
|
||||
# separated.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# Serve it with: mempalace serve --host 172.17.0.1 --port 8765
|
||||
#
|
||||
# NOT `mempalace-mcp --transport http --host 0.0.0.0`: `serve` is the turnkey
|
||||
# wrapper that mints/keeps a bearer token (0600, passed via env so it stays out
|
||||
# of `ps`) and can terminate TLS. Two binds to avoid:
|
||||
# 0.0.0.0 - exposes the palace to the whole LAN.
|
||||
# 127.0.0.1 - behind a tunnel this 403s every proxied request (the Host pin
|
||||
# is only enforced on loopback binds) AND silently starts with
|
||||
# no token at all, since auto-minting is gated on the bind being
|
||||
# non-loopback. Bind the docker0 gateway: reachable from the host
|
||||
# and its containers (so a newt/proxy container works), not from
|
||||
# the LAN. Set MEMPALACE_MCP_HTTP_TOKEN explicitly server-side.
|
||||
# MEMPALACE_REMOTE_URL=https://mempalace.example.com/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=
|
||||
|
||||
# ── MemPalace: automatic capture of pi sessions ───────────────────────
|
||||
# The mempalace.ts extension feeds this container's pi transcripts into the
|
||||
# palace by itself: on session_shutdown, and on a debounced agent_settled so a
|
||||
# crash loses at most one window rather than the whole session. The entrypoint
|
||||
# also runs a catch-up at container start, which is the only thing that can
|
||||
# recover transcripts after a hard kill (no handler runs on SIGKILL).
|
||||
# Nothing below is required for the local-palace case; the defaults work.
|
||||
#
|
||||
# MEMPALACE_FEED=0 # disable automatic capture entirely
|
||||
# MEMPALACE_FEED_DEBOUNCE_MS=600000 # min gap between mid-session feeds (10 min)
|
||||
# MEMPALACE_FEED_WING=wing_conversations
|
||||
#
|
||||
# REMOTE PALACE ONLY (MEMPALACE_REMOTE_URL set above): the palace is on another
|
||||
# host, and `mempalace_mine` resolves its source path in the SERVER process, so
|
||||
# the server cannot see this container's transcripts. The feeder therefore
|
||||
# rsyncs its staged exports into a per-device inbox on the palace host and asks
|
||||
# the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the
|
||||
# feeder is skipped (a remote palace with no inbox has nothing to mine).
|
||||
# MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path
|
||||
# MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — must be
|
||||
# the container path if the server runs in Docker
|
||||
# (see docker-compose.mempalace.yml)
|
||||
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
|
||||
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
|
||||
# MEMPALACE_PI_REMOTE_PATH=/data/feed
|
||||
# MEMPALACE_PI_DEVICE=
|
||||
|
||||
# ── 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
|
||||
@@ -43,7 +86,22 @@ SSH_KEY_PATH=~/.ssh
|
||||
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
|
||||
|
||||
# ── pi-atelier (TUI sidebar) ─────────────────────────────────────────
|
||||
# The image vendors pi-atelier at a pinned, audited tag and registers it on
|
||||
# container start. Set to 0 to opt out: the entrypoint then removes it from
|
||||
# pi's `packages[]` instead of registering it. This lives here rather than
|
||||
# being a `pi uninstall` because a broken TUI extension's failure mode is
|
||||
# "pi will not start", which you cannot fix from inside pi.
|
||||
# DEVBOX_ATELIER=1
|
||||
|
||||
# ── Git Configuration ────────────────────────────────────────────────
|
||||
# Set BOTH. If unset, every repo inside the container fails with
|
||||
# "Author identity unknown" on first commit, and an agent asked to commit
|
||||
# will guess an identity from git log — often the wrong one. The e-mail is
|
||||
# per-machine (work machines use the corporate address, personal machines the
|
||||
# private one), so it belongs in this per-machine .env, never in a skill or a
|
||||
# repo-local override. Consumed by entrypoint-user.sh -> ~/.gitconfig, which is
|
||||
# NOT persistent across container recreate — this file is the source of truth.
|
||||
GIT_USER_NAME=
|
||||
GIT_USER_EMAIL=
|
||||
|
||||
|
||||
@@ -136,8 +136,16 @@ jobs:
|
||||
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
|
||||
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
|
||||
studio_tag: ${{ steps.resolve.outputs.studio_tag }}
|
||||
atelier_ref: ${{ steps.resolve.outputs.atelier_ref }}
|
||||
atelier_tag: ${{ steps.resolve.outputs.atelier_tag }}
|
||||
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
|
||||
steps:
|
||||
# Needed since v1.7.0: the pi version and the pi-atelier tag are now
|
||||
# PINNED IN Dockerfile.variant and read from it here, so this job has to
|
||||
# see the repo. Keeping the pins in the Dockerfile (rather than duplicated
|
||||
# in this workflow) means a local `docker build` and CI ship the same
|
||||
# versions by construction, and a bump is one reviewable line.
|
||||
- uses: actions/checkout@v4
|
||||
- name: Resolve pi version + companion refs
|
||||
id: resolve
|
||||
shell: bash
|
||||
@@ -157,13 +165,35 @@ jobs:
|
||||
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>}')."
|
||||
# ── pi version: from the PIN, not from npm `latest` ───────────
|
||||
# Until v1.7.0 this followed npm `latest`, which meant every release
|
||||
# silently adopted whatever pi had shipped that morning — unaudited —
|
||||
# in the same build that then got tagged and published. A pi minor
|
||||
# can move the TUI/renderer internals that pi-atelier wraps (0.84 vs
|
||||
# atelier 0.6.0: startup hang, sustained CPU) or the session `.jsonl`
|
||||
# format that pi-session-repair parses. The pin makes adoption an
|
||||
# explicit, reviewable act; the drift warning below makes it a
|
||||
# prompt rather than a surprise.
|
||||
PI_VERSION=$(sed -n 's/^ARG PI_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::ARG PI_VERSION in Dockerfile.variant is not a concrete version (got '${PI_VERSION:-<empty>}'). CI refuses to build from a floating pi version — see the pin policy comment above that ARG."
|
||||
exit 1
|
||||
fi
|
||||
# The pin must actually exist on npm: catches a typo, an unpublished
|
||||
# version, or one yanked after we audited it — at resolve time, with
|
||||
# a clear message, instead of as an `npm install` failure mid-build.
|
||||
PI_PUBLISHED=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/${PI_VERSION}" | jq -r '.version // empty' 2>/dev/null || true)
|
||||
if [ "${PI_PUBLISHED:-}" != "${PI_VERSION}" ]; then
|
||||
echo "::error::Pinned pi version ${PI_VERSION} is not published on npm (registry returned '${PI_PUBLISHED:-<empty>}'). Fix ARG PI_VERSION in Dockerfile.variant."
|
||||
exit 1
|
||||
fi
|
||||
# Informational only — a newer pi must never be adopted implicitly.
|
||||
# `|| true`: a transient registry failure must not fail a release
|
||||
# whose version is already pinned and verified above.
|
||||
PI_NPM_LATEST=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version // empty' 2>/dev/null || true)
|
||||
if [ -n "${PI_NPM_LATEST:-}" ] && [ "${PI_NPM_LATEST}" != "${PI_VERSION}" ]; then
|
||||
echo "::warning::pi ${PI_NPM_LATEST} is published; this build ships the audited pin ${PI_VERSION}. To adopt it: read the upstream CHANGELOG for every version in between (TUI/theme API, session .jsonl format, extension loader, Node engine), re-check pi-atelier's floor, then bump ARG PI_VERSION in Dockerfile.variant and note the audit in CHANGELOG.md."
|
||||
fi
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||
@@ -176,6 +206,26 @@ jobs:
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
||||
# (which track a branch head) atelier wraps pi's private TUI
|
||||
# renderer, so its version is pinned in Dockerfile.variant and read
|
||||
# from there; we only resolve tag → SHA, for reproducibility and to
|
||||
# defeat the cache-hit footgun. Never floats to a branch.
|
||||
ATELIER_TAG=$(sed -n 's/^ARG PI_ATELIER_REF=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
||||
if ! printf '%s' "${ATELIER_TAG:-}" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::ARG PI_ATELIER_REF in Dockerfile.variant is not a semver tag (got '${ATELIER_TAG:-<empty>}'). pi-atelier must stay pinned to a tag — see the floor note above that ARG."
|
||||
exit 1
|
||||
fi
|
||||
ATELIER_LS=$(git ls-remote --tags "https://github.com/michaelmjhhhh/pi-atelier.git" || true)
|
||||
# Peeled ^{} line first (annotated tags), then the direct ref.
|
||||
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}^{}" '$2==t{print $1}')
|
||||
if [ -z "$ATELIER_REF" ]; then
|
||||
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}" '$2==t{print $1}')
|
||||
fi
|
||||
require_sha PI_ATELIER_REF "$ATELIER_REF"
|
||||
echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "atelier_tag=${ATELIER_TAG}" >> "$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" \
|
||||
@@ -230,7 +280,8 @@ jobs:
|
||||
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved PI_VERSION=${PI_VERSION}"
|
||||
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
|
||||
echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
|
||||
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})"
|
||||
@@ -357,6 +408,8 @@ jobs:
|
||||
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 }}
|
||||
PI_ATELIER_REF=${{ needs.resolve-versions.outputs.atelier_ref }}
|
||||
PI_ATELIER_VERSION=${{ needs.resolve-versions.outputs.atelier_tag }}
|
||||
RELEASE_TAG=smoke
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test (amd64)
|
||||
@@ -417,6 +470,8 @@ jobs:
|
||||
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 }}
|
||||
PI_ATELIER_REF=${{ needs.resolve-versions.outputs.atelier_ref }}
|
||||
PI_ATELIER_VERSION=${{ needs.resolve-versions.outputs.atelier_tag }}
|
||||
RELEASE_TAG=smoke-studio
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test studio (amd64)
|
||||
@@ -471,6 +526,8 @@ jobs:
|
||||
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 }}
|
||||
ATELIER_REF: ${{ needs.resolve-versions.outputs.atelier_ref }}
|
||||
ATELIER_TAG: ${{ needs.resolve-versions.outputs.atelier_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
@@ -490,6 +547,10 @@ jobs:
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "PI_ATELIER_REF=${ATELIER_REF}" \
|
||||
--build-arg "PI_ATELIER_VERSION=${ATELIER_TAG}" \
|
||||
--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 + atelier ${ATELIER_TAG} TUI sidebar), 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:-}" \
|
||||
@@ -559,6 +620,8 @@ jobs:
|
||||
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 }}
|
||||
ATELIER_REF: ${{ needs.resolve-versions.outputs.atelier_ref }}
|
||||
ATELIER_TAG: ${{ needs.resolve-versions.outputs.atelier_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
@@ -579,6 +642,10 @@ jobs:
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "INSTALL_STUDIO=true" \
|
||||
--build-arg "IMAGE_TITLE=pi-devbox (studio)" \
|
||||
--build-arg "PI_ATELIER_REF=${ATELIER_REF}" \
|
||||
--build-arg "PI_ATELIER_VERSION=${ATELIER_TAG}" \
|
||||
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — studio variant: everything in the core variant (pi ${PI_VERSION}, pi-toolkit, fork + observational-memory + atelier ${ATELIER_TAG}, 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}" \
|
||||
|
||||
@@ -6,11 +6,28 @@ name: Lint
|
||||
# actionlint runs shellcheck against each `run:` step using its *effective*
|
||||
# shell, so `set -o pipefail` under dash is flagged as SC3040 before any
|
||||
# expensive build runs. This is cheap (~10s) and independent of the build
|
||||
# pipeline, so it fires on every push/PR — not just on release tags, which
|
||||
# is where the build workflow (docker-publish.yml) is otherwise only
|
||||
# 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 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). The wasted compute is small (measured:
|
||||
# lint here runs 0.3-0.9 min, against a 77.6 min release build for v1.6.4 — so
|
||||
# runner contention is NOT a real argument in this repo, unlike opencode-devbox
|
||||
# where actionlint installs shellcheck and takes 6-15 min). The substantive
|
||||
# reason is discovery ambiguity: 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 under a minute while the
|
||||
# image is still building, making a release look finished before anything is
|
||||
# published. See AGENTS.md "Gitea API access" for the head_sha-filtered
|
||||
# discovery pattern.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||
(amd64 + arm64) only after smoke passes.
|
||||
(amd64 + arm64) only after smoke passes. **A tag push produces two runs, not
|
||||
one** — `lint.yml` fires on every push (including tag refs) and
|
||||
`docker-publish.yml` fires on `v*` tags. Watch the **publish** run; see
|
||||
*Gitea API access* below for how to find it without picking lint by mistake.
|
||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
base-latest if the base was rebuilt this run).
|
||||
7. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
@@ -92,13 +95,61 @@ host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
||||
**not** baked into the image. When configured, they are also available for
|
||||
**any** direct Gitea API interaction from inside the container — inspecting
|
||||
CI runs, checking published tags, listing commits — e.g.
|
||||
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=5"`.
|
||||
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20"`.
|
||||
Prefer this over a short-lived PAT file when the env token is present (the
|
||||
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
|
||||
unauthenticated too, so the token matters mainly for private repos or
|
||||
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
||||
revoke after use. Never echo the token value (including into logs).
|
||||
|
||||
**Gotcha — a tag push fires EVERY workflow whose triggers match the tag ref.**
|
||||
`lint.yml` uses a bare `push:` trigger, so a release tag yields *both* a lint run
|
||||
and the publish run. The listing is newest-first and lint sorts **above** the
|
||||
publish run, so "take the first run whose `path` contains `refs/tags/<tag>`"
|
||||
picks the wrong one **reliably, not occasionally**. Real listing for v1.6.4:
|
||||
|
||||
```
|
||||
id=531 #104 lint.yml@refs/tags/v1.6.4 <- wrong; sorts first
|
||||
id=530 #103 docker-publish.yml@refs/tags/v1.6.4 <- the release build
|
||||
id=529 #102 lint.yml@refs/heads/main <- same commit, linted on push
|
||||
```
|
||||
|
||||
Lint goes green in minutes while the image is still building, so watching it
|
||||
makes a release look finished when nothing has been published yet.
|
||||
|
||||
**Gotcha — the jobs endpoint takes the internal `id`, NOT the `run_number` the
|
||||
UI shows as `#104`.** The two diverge widely, and `GET
|
||||
.../actions/runs/<run_number>/jobs` does **not** error — it silently returns a
|
||||
*different* run's jobs. Always read `id` from the run listing:
|
||||
|
||||
```bash
|
||||
# Which runs did this tag/commit trigger? Filter on head_sha; never trust
|
||||
# ordering or run numbering. limit=20, not 5 — with two runs per push the
|
||||
# publish run falls off a 5-item window fast.
|
||||
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
|
||||
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20" \
|
||||
| jq --arg sha "$(git rev-list -n1 vX.Y.Z)" \
|
||||
'.workflow_runs[] | select(.head_sha==$sha) | {id, run_number, path, status, conclusion}'
|
||||
# pick the id whose .path starts with docker-publish.yml, then:
|
||||
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
|
||||
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs/<id>/jobs" \
|
||||
| jq '.jobs[] | {name, status, conclusion}'
|
||||
```
|
||||
|
||||
**Watcher config for this repo** (`ci-release-watcher` skill, hub-only shape —
|
||||
pi-devbox has no downstream host to deploy to):
|
||||
|
||||
- `EXPECT_WORKFLOW=docker-publish.yml` — the skill's `preflight_run()` aborts at
|
||||
startup if the run id belongs to lint instead.
|
||||
- `EXPECTED_FRESH_TAGS='vX.Y.Z latest vX.Y.Z-studio latest-studio'`
|
||||
- `EXPECTED_EXISTS_TAGS='base-latest'` — existence only: it is content-addressed
|
||||
and legitimately keeps its old timestamp when the base is a cache hit.
|
||||
- `CRITICAL_JOBS='build-variant build-variant-studio'` — job names are matched
|
||||
**exactly** (`critical.issubset(succeeded)`), so the studio variant must be
|
||||
listed explicitly; the skill's default omits it. Leave `promote-base-latest`
|
||||
out: it legitimately skips on a base cache hit, which would misclassify a good
|
||||
run. `update-description` is the cosmetic post-publish job.
|
||||
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
||||
|
||||
+406
@@ -11,6 +11,412 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.8.0 — 2026-08-15
|
||||
|
||||
Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The
|
||||
image already shipped `mempalace-toolkit`, but its pi feeder
|
||||
(`mempalace-pi-session`) was never symlinked onto `PATH`, so nothing ever mined
|
||||
pi's transcripts — the palace only ever contained what an agent remembered to
|
||||
file by hand. A container that gets recreated regularly has no other memory, so
|
||||
a missed wind-down was a permanently lost session.
|
||||
|
||||
Also here: **pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1**, bumped
|
||||
together. The pi bump closes the Amazon Bedrock tool-argument poison pill that
|
||||
v1.6.4 recorded as unfixed upstream; the atelier bump is the matching companion,
|
||||
since both sides changed fullscreen input handling in the same fortnight. Audits
|
||||
for both are below.
|
||||
|
||||
*Why event-driven and not a timer:* there is nothing schedulable inside the
|
||||
container — PID 1 is `bash -l`, with no systemd and no cron — and anything
|
||||
installed would not survive recreate anyway. The triggers therefore live where
|
||||
the events already are: pi's own lifecycle, plus container start.
|
||||
|
||||
### Added
|
||||
|
||||
- **`mempalace-pi-session` symlinked onto `PATH`** (`Dockerfile.base`,
|
||||
alongside its `mempalace-session` / `mempalace-docs` siblings, with the same
|
||||
`--help` build-time check). `entrypoint-user.sh` also self-heals the symlink
|
||||
into `~/.local/bin` (already ahead of `/usr/local/bin` on `PATH`, and
|
||||
writable by `developer`) so the feature works on images whose base predates
|
||||
this change.
|
||||
- **Container-start catch-up feed** (`entrypoint-user.sh`, backgrounded). pi's
|
||||
mempalace extension feeds the palace on `session_shutdown` and on a debounced
|
||||
`agent_settled`, but a hard kill (`docker kill`, OOM, host reboot) runs no
|
||||
handler at all; this is the only trigger that can recover the previous life's
|
||||
transcripts. Skipped when a remote palace is configured without an inbox to
|
||||
ship to — **and the skip now says so** (see Changed) — and skippable entirely
|
||||
with `MEMPALACE_FEED=0`.
|
||||
- **`MEMPALACE_PI_STAGE` no longer needs pinning here — the feeder's default
|
||||
was fixed upstream instead.** It used to stage under `~/.cache`, which is
|
||||
disposable in a container; the first cut of this change pinned the env var
|
||||
into the persisted `~/.pi` volume. That was the wrong fix: it created a second
|
||||
convention that could still diverge from the palace (keep the palace volume,
|
||||
drop `devbox-pi-config`, and a scoped `mempalace sync` prunes every
|
||||
conversation drawer, because dedup keys on the *staged* path). The feeder now
|
||||
defaults to `<palace-root>/pi-stage`, resolved with mempalace's own
|
||||
precedence (`$MEMPALACE_PALACE_PATH` → `$MEMPAL_PALACE_PATH` →
|
||||
`~/.mempalace/config.json` → `~/.mempalace/palace`), so the stage inherits
|
||||
whatever persistence the palace has and the two cannot be separated by
|
||||
accident. No `ENV` and no entrypoint export: adding one back would
|
||||
re-introduce exactly the split it removes.
|
||||
- **Transcript inbox mount in `docker-compose.mempalace.yml`**
|
||||
(`${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro`). A client cannot mine into a
|
||||
remote palace directly: `mempalace_mine` expands its source path in the
|
||||
*server* process, so the server can only see paths inside its own container.
|
||||
Clients rsync their staged exports to a per-device subdirectory and then ask
|
||||
the server to mine `/data/feed/<device>`. Read-only because mining only reads
|
||||
sources — all locks live palace-side.
|
||||
- **Smoke tests** for the above: `mempalace-pi-session` on `PATH`, two
|
||||
assertions that the stage resolves next to the palace (default, and following
|
||||
`$MEMPALACE_PALACE_PATH`), and two behavioural guards that feed the exporter a
|
||||
synthetic pi session — one that must be captured, one abandoned session that
|
||||
must not be. The second matters because pi expands skills/context into the
|
||||
user prompt, so an abandoned session can look substantial by byte count while
|
||||
containing no assistant output; and if pi's JSONL shape ever changes, the
|
||||
exporter would silently capture nothing.
|
||||
- **`.env.example`**: documents `MEMPALACE_FEED`,
|
||||
`MEMPALACE_FEED_DEBOUNCE_MS`, `MEMPALACE_FEED_WING`, and the remote-palace
|
||||
shipping vars `MEMPALACE_PI_SSH_TARGET`, `MEMPALACE_PI_REMOTE_PATH`,
|
||||
`MEMPALACE_PI_DEVICE`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The "remote palace, no inbox" skip announces itself instead of vanishing**
|
||||
(`entrypoint-user.sh`). When `MEMPALACE_REMOTE_URL` is set but
|
||||
`MEMPALACE_PI_SSH_TARGET` is not, there is genuinely nothing the feeder can
|
||||
ship to, so skipping is correct — but the branch was a bare `:`, and the skip
|
||||
happens *before* the subshell that writes `~/.pi/agent/mempalace-catchup.log`.
|
||||
A container in that state therefore contributed nothing to the palace and left
|
||||
**no artifact at all**, not even an empty log, to explain why — indistinguish-
|
||||
able from a healthy run that had nothing to file. Found while flipping the
|
||||
first client onto the shared palace (2026-08-12), where it is the single most
|
||||
likely way to end up quietly memory-less. The notice now goes to both the
|
||||
container start output and that log path, names the two variables that fix it,
|
||||
states that MCP tools still work (only *this* container's transcripts go
|
||||
nowhere), and points at `MEMPALACE_FEED=0` for anyone who meant it.
|
||||
Deliberately incapable of breaking startup: an unwritable `~/.pi` — root-owned
|
||||
volume, a classic Docker accident — would make `mkdir -p` fail under `set -e`
|
||||
and abort the whole entrypoint, so it degrades to stdout-only. That was a real
|
||||
new risk, since this branch previously touched no filesystem whatsoever.
|
||||
Covered by two smoke assertions against the entrypoint as shipped in the image
|
||||
(the branch only runs at container start, so a `docker run` one-shot cannot
|
||||
reach it).
|
||||
|
||||
### Bumped: pi 0.84.1 → 0.84.2
|
||||
|
||||
- **`ARG PI_VERSION=0.84.2`** (`Dockerfile.variant`), with the audit the pin
|
||||
policy in that file requires.
|
||||
|
||||
**Headline for this image: the Bedrock tool-argument poison pill is FIXED
|
||||
upstream.** The v1.6.4 entry below recorded it as *"Not fixed upstream … still
|
||||
replayed unsanitised"* — that note is now superseded. pi-ai 0.84.2 adds a
|
||||
recursive `sanitizeBedrockDocument()` and applies it at exactly the site that
|
||||
entry named ([#7882](https://github.com/earendil-works/pi/pull/7882)):
|
||||
|
||||
```diff
|
||||
- toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
|
||||
+ toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) },
|
||||
```
|
||||
|
||||
(`dist/api/bedrock-converse-stream.js` — line 692 in pi-ai 0.84.1, 704 in
|
||||
0.84.2; it was 644 in 0.83.0 and 634 in 0.82.1.) The sanitiser drops object
|
||||
members whose key is the empty string, recursing through arrays and nested
|
||||
objects and preserving every valid value. It runs while the request is built,
|
||||
so it covers the live turn *and* a resume: a session already bricked by an
|
||||
empty-key tool argument now replays instead of dying on a Bedrock
|
||||
`ValidationException`. **`pi-session-repair` (in `cli_utils`) is therefore no
|
||||
longer the recovery path on this image.** It stays useful for older images and
|
||||
for inspecting a transcript, because the stored `.jsonl` is still malformed —
|
||||
the fix sanitises what is *sent*, not what was *recorded*.
|
||||
|
||||
**Why bumping `PI_VERSION` is the only way to get it:** pi publishes an
|
||||
`npm-shrinkwrap.json`, which pins transitive dependencies *exactly*. pi
|
||||
0.84.1's shrinkwrap pins `@earendil-works/pi-ai` to **0.84.1**, so although
|
||||
0.84.1's `package.json` range is `^0.84.1` — which would otherwise admit
|
||||
0.84.2 — rebuilding the old pin can never pick the fix up. Transitive upstream
|
||||
fixes do not leak into this image; `PI_VERSION` is the whole gate.
|
||||
|
||||
Rest of the audit, against the integration surface the pin policy names:
|
||||
|
||||
- **Session `.jsonl` format — unchanged.** Identical
|
||||
`migrateV1ToV2`/`migrateV2ToV3` ladder in both versions, so existing sessions
|
||||
on the named volume load as-is and `pi-session-repair`'s parse target is
|
||||
untouched.
|
||||
- **Node engine floor — unchanged** at `>=22.19.0` (image ships 22.23.2).
|
||||
- **pi-atelier — no change needed.** The pin stays `v0.8.0`: the hard floor is
|
||||
"never pair < 0.7.1 with pi >= 0.84", this bump does not leave 0.84.x, and
|
||||
atelier's `peerDependencies` (`>=0.80.7`) are satisfied. pi-atelier **0.8.1**
|
||||
is published but deliberately NOT adopted here — one variable at a time, and
|
||||
atelier is the component that has drawn blood at startup.
|
||||
- **Directly relevant to `pi --ssh` use of this image:** 0.84.2 fixes split
|
||||
`Alt+Enter` over SSH being misread as Escape, and adds `PI_TUI_ESC_TIMEOUT`
|
||||
for high-latency terminals.
|
||||
- **Keybindings — one surface worth knowing.** `pi-toolkit` ships exactly one
|
||||
override, `tui.input.newLine: [shift+enter, ctrl+j, alt+j]`. 0.84.2's new
|
||||
fullscreen transcript search (`Ctrl+Shift+F`) binds `Shift+Enter` to
|
||||
*previous match* while its overlay is focused. Different context, so no
|
||||
conflict is expected — but it is the one place the override meets a new
|
||||
default, and the first place to look if "shift+enter stopped inserting a
|
||||
newline" is ever reported.
|
||||
- **New `defaultTools` setting** (choose startup built-in tools globally or per
|
||||
project) is additive; `pi-toolkit`'s `settings.example.json` does not set it,
|
||||
so the bootstrap template needs no change.
|
||||
|
||||
### Bumped: pi-atelier v0.8.0 → v0.8.1
|
||||
|
||||
- **`ARG PI_ATELIER_REF` / `ARG PI_ATELIER_VERSION` = `v0.8.1`**
|
||||
(`Dockerfile.variant`), bumped *together* with `PI_VERSION` as that pin's
|
||||
comment requires — and this pairing is a good advert for the rule, because both
|
||||
sides touched fullscreen input handling within three days of each other.
|
||||
|
||||
atelier 0.8.1 (2026-08-12) is two changes, only one of them code: *"Preserve
|
||||
fullscreen transcript mouse-wheel scrolling after Sidebar resize and visibility
|
||||
changes by leaving Pi's persistent mouse reporting enabled"*, plus a README
|
||||
simplification. The single source file that differs from 0.8.0 is
|
||||
`src/split-pane.ts`. It extracts an `isPiFullscreenRenderer()` predicate and,
|
||||
under pi's fullscreen renderer, stops writing its own
|
||||
`\e[?1002h\e[?1006h` / `\e[?1006l\e[?1002l` pair around a sidebar resize —
|
||||
previously it enabled mouse reporting on grab and disabled it on release, which
|
||||
tore down the reporting **pi itself** had switched on and left the wheel dead
|
||||
afterwards. Outside fullscreen it manages mouse mode exactly as before. It also
|
||||
now captures the terminal it enabled mouse on and writes the disable sequence to
|
||||
*that* terminal instead of to whatever `tui` currently points at.
|
||||
|
||||
**The audit that matters is the private-internals coupling**, since that is what
|
||||
hung startup at 0.6.0/0.7.0. atelier reaches into three pi internals; all three
|
||||
are unchanged in pi 0.84.2:
|
||||
|
||||
- **`TuiAltScreen`** — detected *by constructor name*, so a rename would
|
||||
silently disable both the resize-input prioritisation and the new mouse
|
||||
behaviour, with no error. Still
|
||||
`class TuiAltScreen extends TuiBase implements ViewportTUI`.
|
||||
- **`tui.inputListeners`** — a private `Set` that atelier deletes from and
|
||||
re-adds to, to get its resize handler ahead of pi's viewport listener (which
|
||||
"consumes every mouse event for text selection"). Still `inputListeners = new
|
||||
Set()`, at the identical line 103 of `pi-tui/dist/tui.js` in both versions,
|
||||
and still a `Set` — atelier guards with `instanceof Set`.
|
||||
- **the prototype `render` descriptor** it wraps via `findPrototypeRender`.
|
||||
Still an own `render(width)` on `TuiAltScreen`.
|
||||
|
||||
pi's mouse sequences are byte-identical between 0.84.1 and 0.84.2 (same
|
||||
`1002h`/`1006h`/`1002l`/`1006l`/`1003h` occurrence counts), so atelier's
|
||||
assumption about what pi leaves enabled still holds. `pi-tui`'s base class
|
||||
changed additively only (one new `isOverlayFocused()`), and `TuiAltScreen`'s own
|
||||
changes are the new search feature (`activeSearch`, `openSearch`/`closeSearch`,
|
||||
the two search match styles, `copySelection`).
|
||||
|
||||
**Caveat, stated plainly:** pi 0.84.2 adds a focused fullscreen *search overlay*
|
||||
that participates in input handling, while atelier reorders input listeners
|
||||
around pi's viewport listener. The two look convergent — 0.84.2 separately fixes
|
||||
*"focused fullscreen overlays not receiving mouse wheel or viewport scroll
|
||||
keys"* — but this pairing is reasoned from the diffs, **not proven by
|
||||
execution**: the CI smoke test does not drive the TUI, so a fullscreen
|
||||
interaction regression would not be caught before pull. Worth an `alt+a` plus a
|
||||
sidebar resize and a wheel scroll in fullscreen on first use of this image.
|
||||
|
||||
Version metadata is unchanged: `engines.node >=22.19.0`, `peerDependencies`
|
||||
still the uninformative `>=0.80.7` on both pi packages (so still nothing in npm
|
||||
metadata encodes the real floor), and still zero runtime dependencies — so the
|
||||
"no `npm install` step" note above stays true. The GitHub tag `v0.8.1` exists
|
||||
(commit `c31d7439`), which is what CI resolves to a SHA.
|
||||
|
||||
### Notes
|
||||
|
||||
- The `Dockerfile.base` change moves the base hash, so this needs a base
|
||||
rebuild; the `~/.local/bin` self-heal exists so the feature does not have to
|
||||
wait for one. The skip-notice change is in `entrypoint-user.sh`, which is
|
||||
`COPY`d in `Dockerfile.base` too, so it rides the same rebuild — until then,
|
||||
older images keep skipping silently and the two commands in the toolkit's
|
||||
`phase-1-exposure-runbook.md` §3.7 are the way to tell.
|
||||
- Requires the matching `mempalace-toolkit` change (`--prepare` two-phase
|
||||
split, remote transport, and the auto-feed triggers in
|
||||
`extensions/pi/mempalace.ts`). The split exists because the palace is
|
||||
single-writer: a live pi session holds it through the extension's own
|
||||
`mempalace-mcp`, so a CLI `mempalace mine` during a session fails with
|
||||
"palace ... is held by PID". Staging is therefore done by the CLI and the
|
||||
mine itself by whichever process already holds the palace.
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 — 2026-08-07
|
||||
|
||||
Minor release. Headline: **pi-atelier is now part of the image** — the TUI
|
||||
sidebar/status rail every container previously had to hand-install — and **pi is
|
||||
pinned to an audited version instead of tracking npm `latest`**.
|
||||
|
||||
*Why minor and not patch:* the policy above reserves patch for "pi version bumps,
|
||||
smaller fixes" and minor for "new variants, significant base additions". Bundling
|
||||
a new companion package into every image is the same shape as v1.1.0, which went
|
||||
minor for bundling pi-studio; v1.4.0 likewise went minor for adding typst. This
|
||||
release also adds a new build-arg pair, a new opt-out env var, and a settings
|
||||
migration, so patch would understate it.
|
||||
|
||||
### Added
|
||||
|
||||
- **pi-atelier vendored at `/opt/pi-atelier`, pinned to `v0.8.0`** — the TUI
|
||||
sidebar (ordered panels, split-pane, themes) is now part of the image instead
|
||||
of something each user hand-installs. Vendored + registered at container start
|
||||
by `entrypoint-user.sh`, the same pattern as pi-fork/pi-observational-memory/
|
||||
pi-studio, and deliberately **not** `pi install npm:pi-atelier`: an npm
|
||||
install writes into `~/.pi/npm-global` on the config volume, which shadows the
|
||||
image and pins nothing — the footgun that once hid a missing `fork` tool for
|
||||
six weeks. Unlike its siblings it gets **no `npm install`**: pi-atelier
|
||||
declares zero runtime dependencies (only peerDeps, satisfied by the baked pi)
|
||||
and has no build step, so pi loads its TypeScript straight from the checkout
|
||||
(`pi.extensions` → `extensions/index.ts`).
|
||||
- **A version FLOOR, encoded as an executable test.** pi-atelier 0.6.0/0.7.0
|
||||
wrap pi's private TUI renderer in a way that recurses under pi 0.84: pi hangs
|
||||
at startup with sustained CPU and no error message. Upstream fixed the
|
||||
recursion in 0.7.1 and restored the non-overlapping split in 0.7.2
|
||||
("avoiding the recursive render path that caused startup hangs and sustained
|
||||
CPU usage"); 0.8.0 is additive on top of that. atelier's own
|
||||
`peerDependencies` still say `>=0.80.7`, which does **not** express the floor,
|
||||
so nothing in npm metadata could have warned us. `smoke-test.sh` and
|
||||
`recreate-sanity-check.sh` now assert the pairing rule **pi ≥ 0.84 ⇒
|
||||
pi-atelier ≥ 0.7.1** — verified against a 4×4 version matrix — so a bad
|
||||
combination fails the build instead of publishing an image whose TUI never
|
||||
starts. CI resolves the pinned tag to its **peeled commit SHA**; atelier uses
|
||||
annotated tags, so the unpeeled ref is a tag object, not a commit (pi-studio's
|
||||
lightweight tags never exposed that distinction).
|
||||
- **`DEVBOX_ATELIER=0`** opts out: the entrypoint removes pi-atelier from pi's
|
||||
`packages[]` instead of registering it. The switch lives in the entrypoint
|
||||
rather than being "just run `pi uninstall`" because this component's failure
|
||||
mode is *pi will not start*, which cannot be repaired from inside pi.
|
||||
- **Migration for hand-installed copies.** A pre-existing `npm:pi-atelier` entry
|
||||
is dropped from `packages[]` (with a `settings.json.bak.atelier.<ts>` backup)
|
||||
so the pinned `/opt` copy takes over. This is not cosmetic: the registration
|
||||
guard counts `npm:<name>` as already-registered, so without this step every
|
||||
existing volume would have kept its unpinned npm copy — and a 0.6.x copy
|
||||
alongside pi 0.84 is exactly the startup hang above. Only that one exact
|
||||
string is removed; jq-parse failures or a missing file leave settings
|
||||
untouched, and the backup prefix is distinct from the template merge's so two
|
||||
rewrites in the same second cannot overwrite each other's backup.
|
||||
|
||||
### Changed
|
||||
|
||||
- **pi-toolkit's `pi-atelier.json` modernised to atelier's current schema**
|
||||
(pi-toolkit `0e1369e`, cross-repo — it reaches the image through the pinned
|
||||
`PI_TOOLKIT_REF` clone). The seeded config had been written against the pre-0.7
|
||||
vocabulary: `segments` → `segmentLayout` with explicit per-segment visibility,
|
||||
`ornament: "none"` → `{"id":"brand","visible":false}`, `showExtensionStatuses`
|
||||
→ `{"id":"statuses","visible":true}`, plus the sidebar toggles that did not
|
||||
exist when it was written (`showSidebarAgent`, `showSidebarTodos`, and
|
||||
`showSidebarOnStartup`, new in atelier 0.8.0). Upstream still reads the old
|
||||
keys, but only as non-authoritative legacy inputs, so the file worked while
|
||||
silently missing every sidebar control added since. Verified by loading the old
|
||||
and new file through pi-atelier 0.8.0's own `loadConfig()`: zero warnings from
|
||||
each and an identical *effective* config, so it is a pure schema
|
||||
modernisation — every deliberate choice (compact density, 60/85 context
|
||||
thresholds, notifications off) is preserved. `sidebarPanelLayout` is left unset
|
||||
on purpose so the panel set tracks upstream as atelier adds panels.
|
||||
- **pi is now PINNED, not `latest`: `PI_VERSION=0.84.1`** (`Dockerfile.variant`).
|
||||
CI's `resolve-versions` job used to resolve `@earendil-works/pi-coding-agent`
|
||||
to npm `latest`, which meant every release silently adopted whatever pi had
|
||||
shipped that morning — unaudited — in the same build that then got tagged and
|
||||
published. A pi minor can move the private TUI/renderer internals pi-atelier
|
||||
wraps (0.84 vs atelier 0.6.0: startup hang) or the session `.jsonl` format
|
||||
`pi-session-repair` parses. **The pin is a checkpoint, not a freeze** —
|
||||
bumping stays a routine one-line change; what stops is *unreviewed* adoption.
|
||||
0.84.1 was audited for this release: theme/TUI additions are additive, the
|
||||
session format is unchanged (`CURRENT_SESSION_VERSION = 3` in both 0.83.0 and
|
||||
0.84.1, identical `migrateV1ToV2`/`migrateV2ToV3` ladder, so existing
|
||||
transcripts are neither migrated nor at risk), and the Node engine floor is
|
||||
unmoved at `>=22.19.0`.
|
||||
- The pins live in the **Dockerfiles** and CI reads them from there (a
|
||||
`checkout` was added to `resolve-versions`), so a local `docker build` and a
|
||||
CI release ship the same versions by construction instead of by convention.
|
||||
- CI **fails** the build when the pin is not a concrete version, and when the
|
||||
pinned version is not actually published on npm — catching a typo, an
|
||||
unpublished version, or one yanked after we audited it, at resolve time with
|
||||
a clear message rather than as an `npm install` error mid-build.
|
||||
- CI **warns** (`::warning::`, never adopts) when npm `latest` is ahead of the
|
||||
pin, naming the newer version and what to re-check. That warning is the
|
||||
prompt to audit and bump — not something to silence.
|
||||
|
||||
- **mempalace pin `3.5.0` → `3.6.0`** (`Dockerfile.base` `MEMPALACE_VERSION`),
|
||||
in lockstep with opencode-devbox v2.9.0 as the pin's own comment requires.
|
||||
3.6.0 (2026-07-17) is PyPI latest and is additive/reliability only — secure
|
||||
`mempalace serve` remote mode, optional Milvus backend, atomic KG
|
||||
`supersede()`, conversation chronology, mining exclusions, plus recovery and
|
||||
locking fixes. **Reviewed for MCP tool-schema changes before bumping** — that
|
||||
being the exact regression class this pin exists to catch, after an unpinned
|
||||
install once swept in the broken 3.3.x/3.4.0 `diary_write` schema: there are
|
||||
**none**, and nothing touches `diary_write`, so the perl workaround removed in
|
||||
v1.2.2 stays removed. Two fixes are directly relevant to how this image uses
|
||||
mempalace: read-only mode now covers `checkpoint` + `delete_by_source` in
|
||||
`_MUTATING_TOOLS` (#1930), and agent attribution is preserved in
|
||||
`mempalace_checkpoint` (#2023/#2034) — the latter matters because the diary
|
||||
protocol relies on per-agent attribution. Rebuilds the base image.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **New README section: "Using pi-atelier (TUI sidebar)"** — what the status rail
|
||||
and sidebar give you, the `alt+a` / `/atelier` entry points, session-scoped
|
||||
`/atelier sidebar on|off` versus persistent Save, and `DEVBOX_ATELIER=0` to opt
|
||||
out. Plus the config story: why `~/.pi/agent/pi-atelier.json` is **copied, not
|
||||
symlinked** (atelier saves via write-temp-then-`rename(2)`, and `rename`
|
||||
replaces a symlink rather than following it, so a symlink would silently detach
|
||||
on the first save), why `install.sh` therefore only seeds it when absent, which
|
||||
keys are current versus legacy-compatibility, and the 92-column auto-hide /
|
||||
64-column main-pane floor so a narrow terminal degrades gracefully.
|
||||
- **Documents how to authenticate the container to a LAN peer with its own key**
|
||||
(README: *Giving the container its own key for a peer*) — the gap the existing
|
||||
*Naming LAN peers* section left open. That section explained `ProxyJump`
|
||||
*routing* while asserting `HostName`/`User`/`IdentityFile` are "inherited from
|
||||
the matching block in your real `~/.ssh/config`", which is precisely what fails
|
||||
in a container: host keys are normally passphrase-protected and unlocked by the
|
||||
macOS Keychain or an `ssh-agent`, neither of which exists here, so the key can
|
||||
never be decrypted — `Permission denied (publickey)` while the identical
|
||||
`ssh peer` works fine in a host terminal — and `~/.ssh` is read-only, so no
|
||||
usable key can be added there either. The new walkthrough (throwaway example
|
||||
keys) covers a passphraseless keypair in the `devbox-ssh-local` volume so it
|
||||
survives `--force-recreate`; a hardened `authorized_keys` line (`restrict`,
|
||||
`from=`, optional `permitopen`); the non-obvious detail that `from=` must allow
|
||||
the **host's** addresses, plural, because container egress is NAT'd through the
|
||||
host and a roaming laptop presents a different one per network (a `from=`
|
||||
mismatch is indistinguishable from a wrong key in the error message); the
|
||||
`IdentityFile` override in the host-owned `ssh-lan.conf`; and verification with
|
||||
`-o ControlPath=none` so a warm ControlMaster cannot fake a pass. States
|
||||
explicitly that no private key is in the published image — the volume is
|
||||
created at runtime on the operator's own machine.
|
||||
- **Corrects two claims in *Naming LAN peers***: (1) `ssh-lan.conf` is not
|
||||
`ProxyJump`-only — it is `Include`d before `~/.ssh/config`, so by
|
||||
first-value-wins *any* option set there wins, which is what makes the
|
||||
`IdentityFile` override above possible; (2) "newly added peers work
|
||||
immediately, no container or session restart needed" holds only for *edits* to
|
||||
an existing file. Creating it for the first time **does** need one restart,
|
||||
because `setup-lan-access.sh` emits the
|
||||
`Include ~/.config/devbox-shell/ssh-lan.conf` line only
|
||||
`if [ -r "$SSH_LAN_CONF" ]` at container start — until then ssh never reads it,
|
||||
which presents exactly as "my override is being ignored".
|
||||
- **Adds *macOS-only keywords in a shared `~/.ssh/config`***. The same file is
|
||||
read by macOS ssh and by the container's Linux OpenSSH, where macOS-only
|
||||
keywords are fatal rather than ignored: one `UseKeychain yes` in a `Host *`
|
||||
block yields `Bad configuration option: usekeychain` /
|
||||
`terminating, 1 bad configuration options` and takes down `dssh`/`dscp`,
|
||||
`pi --ssh`, `scp` and every helper that shells out to ssh — while the host
|
||||
keeps working, so it presents as a container regression rather than a host
|
||||
config error. Fix is `IgnoreUnknown UseKeychain` *ahead of* the keyword (macOS
|
||||
still honours it, Linux skips it), plus keeping such a `Host *` block below
|
||||
OrbStack's `Include ~/.orbstack/ssh/config`, which documents in its own comment
|
||||
that it must come first.
|
||||
|
||||
- Documents **per-variant image description labels** (committed and pushed after
|
||||
the v1.6.4 tag without a changelog entry). Both published variants used to
|
||||
inherit `Dockerfile.base`'s `description="pi-devbox — base image
|
||||
(variant-independent)"`, so `v1.6.4` and `v1.6.4-studio` both advertised
|
||||
themselves on Docker Hub as *the base image* — misleading, and useless for
|
||||
telling the two apart. Since a `LABEL` cannot branch on `INSTALL_STUDIO`, the
|
||||
text now arrives as a build arg: CI passes a variant-specific string
|
||||
(interpolating `RELEASE_TAG`, `PI_VERSION`, and `STUDIO_TAG` for studio), while
|
||||
the `Dockerfile.variant` default keeps a bare local `docker build` honest
|
||||
rather than misleading. Sets `org.opencontainers.image.title`/`.description`
|
||||
alongside the legacy bare `description` key so both Hub and OCI-aware tooling
|
||||
see it. The ARGs stay in the last-declared block, so the label layer remains
|
||||
the only thing invalidated.
|
||||
|
||||
---
|
||||
|
||||
## v1.6.4 — 2026-07-30
|
||||
|
||||
Patch release. Headline: **the `fork` tool has never once loaded since v1.0.0**
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
||||
|
||||
### pi and companions
|
||||
|
||||
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`
|
||||
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`, pinned to an audited version (not npm `latest`)
|
||||
- **pi-atelier** — TUI sidebar (ordered panels, split-pane, themes), vendored at `/opt/pi-atelier` and pinned to an audited tag; the exact tag is in the image labels (`se.jordbo.pi-devbox.pi-atelier-version`) and `/etc/pi-devbox/build-manifest.json`
|
||||
- **[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
|
||||
|
||||
+19
-5
@@ -367,13 +367,24 @@ ARG INSTALL_MEMPALACE=true
|
||||
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff
|
||||
# rather than a surprise.
|
||||
#
|
||||
# 3.5.0 (2026-06) ships the upstream fix for the top-level-anyOf diary_write
|
||||
# 3.5.0 (2026-06) shipped the upstream fix for the top-level-anyOf diary_write
|
||||
# schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema
|
||||
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
|
||||
# which Anthropic's tools API accepts — so the old mcp_server.py perl
|
||||
# workaround that used to live below is gone. Keep in lockstep with
|
||||
# opencode-devbox when bumping.
|
||||
ARG MEMPALACE_VERSION=3.5.0
|
||||
# workaround that used to live below is gone.
|
||||
#
|
||||
# 3.6.0 (2026-07-17, PyPI latest) is additive/reliability only — secure
|
||||
# `mempalace serve` remote mode, optional Milvus backend, atomic KG
|
||||
# supersede(), conversation chronology, mining exclusions, plus recovery and
|
||||
# locking fixes. Reviewed for MCP tool-schema changes before bumping (that
|
||||
# being the exact regression class this pin exists to catch): there are NONE,
|
||||
# and nothing touches diary_write. Two fixes matter for how this image uses
|
||||
# mempalace: read-only mode now covers checkpoint + delete_by_source in
|
||||
# _MUTATING_TOOLS (#1930), and agent attribution is preserved in
|
||||
# mempalace_checkpoint (#2023/#2034).
|
||||
#
|
||||
# Keep in lockstep with opencode-devbox when bumping.
|
||||
ARG MEMPALACE_VERSION=3.6.0
|
||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
@@ -413,9 +424,12 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
|
||||
[ "$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 && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
|
||||
/opt/mempalace-toolkit/bin/mempalace-pi-session && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
mempalace-pi-session --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
|
||||
+68
-11
@@ -29,16 +29,34 @@ ARG USER_NAME=developer
|
||||
# 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
|
||||
# ── pi version pin: an AUDITED CHECKPOINT, not a freeze ──────────────
|
||||
# PI_VERSION is pinned to a version whose upstream CHANGELOG has been read
|
||||
# against this image's integration surface: the theme/TUI API that pi-atelier
|
||||
# couples to, the session `.jsonl` format that `pi-session-repair` parses, the
|
||||
# extension/package loader, and the Node engine floor. CI reads THIS LINE as
|
||||
# the single source of truth (see the `resolve-versions` job) and no longer
|
||||
# follows npm `latest` — following it meant every release silently adopted
|
||||
# whatever pi shipped that morning, unaudited, in the very build that then got
|
||||
# tagged and published.
|
||||
#
|
||||
# BUMPING IS ROUTINE AND EXPECTED — the pin exists to force a look, not to
|
||||
# hold a version forever:
|
||||
# 1. Read the upstream CHANGELOG for every version between old and new.
|
||||
# 2. Re-check the companions that couple to pi's private TUI/renderer
|
||||
# internals — pi-atelier above all (see PI_ATELIER_REF below for the
|
||||
# 0.6.0-under-pi-0.84 startup-hang precedent).
|
||||
# 3. Bump this line, record the audit in CHANGELOG.md, then tag.
|
||||
# CI fails the build if this pin is not a published npm version, and warns —
|
||||
# without adopting it — when npm `latest` has moved ahead. That warning is the
|
||||
# prompt to do step 1; it is not something to silence.
|
||||
#
|
||||
# A concrete version here ALSO defeats the registry-buildcache cache-hit
|
||||
# footgun that `latest` carried: a byte-identical build-arg string produced an
|
||||
# identical layer hash, so the cache reused the layer from whatever pi was
|
||||
# current when it was first populated (shipped the same bytes for pi-devbox
|
||||
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
|
||||
# branch below is kept only for a deliberate local `docker build` override.
|
||||
ARG PI_VERSION=0.84.2
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
@@ -54,6 +72,29 @@ 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
|
||||
# pi-atelier (TUI sidebar: ordered panels, split-pane, themes) is PINNED TO A
|
||||
# TAG, which CI resolves to that tag's commit SHA — same treatment as
|
||||
# pi-studio, for reproducibility plus cache-busting.
|
||||
#
|
||||
# This floor is hard-earned. pi-atelier 0.6.0/0.7.0 wrapped pi's PRIVATE TUI
|
||||
# renderer, and under pi 0.84 that wrapper recursed: pi hung at startup with
|
||||
# sustained CPU. Upstream fixed the recursion in 0.7.1 and restored the
|
||||
# non-overlapping split in 0.7.2 — "avoiding the recursive render path that
|
||||
# caused startup hangs and sustained CPU usage". Its own peerDependencies
|
||||
# still say `>=0.80.7`, which does NOT encode that floor, so nothing would
|
||||
# have warned us: NEVER pair pi-atelier < 0.7.1 with pi >= 0.84. Bump this
|
||||
# pin and PI_VERSION together, checking atelier's CHANGELOG for the pi
|
||||
# version it claims to track.
|
||||
#
|
||||
# No `npm install` step, unlike pi-fork/pi-observational-memory/pi-studio:
|
||||
# pi-atelier declares ZERO runtime dependencies (only peerDeps, satisfied by
|
||||
# the baked pi) and has no build step — pi loads its TypeScript directly from
|
||||
# the /opt checkout. Adding an install here would be a no-op that only costs
|
||||
# build time.
|
||||
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
||||
ARG PI_ATELIER_REF=v0.8.1
|
||||
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||
ARG PI_ATELIER_VERSION=v0.8.1
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
@@ -87,12 +128,14 @@ RUN set -e && \
|
||||
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 && \
|
||||
git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \
|
||||
(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)"
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" && \
|
||||
echo "pi-atelier at $(cd /opt/pi-atelier && git rev-parse --short HEAD) (${PI_ATELIER_VERSION})"
|
||||
|
||||
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
||||
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
||||
@@ -219,15 +262,28 @@ 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.pi-atelier-ref="${PI_ATELIER_REF}" \
|
||||
se.jordbo.pi-devbox.pi-atelier-version="${PI_ATELIER_VERSION}" \
|
||||
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}"
|
||||
@@ -254,6 +310,7 @@ RUN set -e; \
|
||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
||||
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
||||
echo " \"pi-atelier\": \"$(rev /opt/pi-atelier)\","; \
|
||||
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
||||
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
||||
echo " }"; \
|
||||
|
||||
@@ -21,6 +21,8 @@ on the host.
|
||||
mempalace integration, etc.)
|
||||
- `pi-fork` — the `fork` tool for spawning sub-agents
|
||||
- `pi-observational-memory` — the `recall` tool for session compaction
|
||||
- `pi-atelier` — TUI sidebar: ordered panels, split-pane, themes. Pinned to an
|
||||
audited tag; see [Version pins](#version-pins-pi-pi-atelier-mempalace)
|
||||
|
||||
### MemPalace (AI memory)
|
||||
|
||||
@@ -338,6 +340,59 @@ DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a
|
||||
`.md` that embeds it) and hit **refresh-from-disk** after each edit.
|
||||
Note: SVG is **not** in Studio's local-image-link allowlist — use PNG.
|
||||
|
||||
## Using pi-atelier (TUI sidebar)
|
||||
|
||||
`pi-atelier` is bundled in **both** variants (vendored at `/opt/pi-atelier`,
|
||||
pinned — see [Version pins](#version-pins-pi-pi-atelier-mempalace)). It adds two
|
||||
things to pi's terminal UI:
|
||||
|
||||
- a **status rail** — activity, token/cost metrics, context usage, model, git
|
||||
state, extension statuses, and a menu;
|
||||
- a **sidebar** — ordered panels (agent, activity, alerts, TODOs, context,
|
||||
workspace, usage, tools) in a split pane beside the transcript.
|
||||
|
||||
Nothing needs installing; the entrypoint registers it on container start, and it
|
||||
binds on the next pi start (or `/reload`).
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| Open the atelier menu | `alt+a`, or `/atelier` |
|
||||
| Toggle the sidebar for this session | `/atelier sidebar on` / `off` |
|
||||
| Change settings persistently | atelier menu → **Settings**, then **Save** |
|
||||
| Turn the whole thing off | `DEVBOX_ATELIER=0` in `.env` |
|
||||
|
||||
If your terminal or keymap swallows `alt+a`, use `/atelier` and pick a different
|
||||
`shortcut` in the config file below.
|
||||
|
||||
### Config
|
||||
|
||||
Config lives at `~/.pi/agent/pi-atelier.json` on the `devbox-pi-config` volume,
|
||||
seeded from pi-toolkit with container-appropriate defaults: compact density,
|
||||
context warnings at 60/85 % (earlier than upstream's 70/90), sidebar tool names
|
||||
on, and desktop completion notifications **off** (a container has nowhere useful
|
||||
to pop a toast).
|
||||
|
||||
It is **copied, not symlinked** — atelier rewrites this exact path when you hit
|
||||
**Save**, using write-temp-then-`rename(2)`, and `rename` replaces a symlink with
|
||||
a regular file instead of following it. A symlink would silently detach on your
|
||||
first save. Consequently pi-toolkit's `install.sh` only seeds the file when it is
|
||||
absent: once you have saved your own preferences, image upgrades leave them
|
||||
alone, and `install.sh` prints a diff hint instead of clobbering.
|
||||
|
||||
The seeded file uses atelier's **current** schema — `segmentLayout` with explicit
|
||||
per-segment visibility, plus `showSidebarAgent` / `showSidebarTodos` /
|
||||
`showSidebarOnStartup`. Older configs written against the pre-0.7 vocabulary
|
||||
(`segments`, `ornament`, `showExtensionStatuses`) still load, but only through
|
||||
upstream's legacy-compatibility shims — so if you are carrying one on an old
|
||||
volume, expect it to keep working while missing every sidebar control added
|
||||
since. `sidebarPanelLayout` is deliberately left unset so the panel set follows
|
||||
upstream's product default as atelier adds panels; set it only if you want to
|
||||
pin the order yourself.
|
||||
|
||||
The sidebar auto-hides below 92 terminal columns and keeps the main pane at
|
||||
least 64 columns wide, so a narrow terminal degrades to the plain TUI rather
|
||||
than a squeezed one.
|
||||
|
||||
## docker-compose.yml — basic shape
|
||||
|
||||
```yaml
|
||||
@@ -554,6 +609,35 @@ User-level overrides in `~/.ssh/config` win because Debian's
|
||||
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
||||
the `Host *` block.
|
||||
|
||||
### macOS-only keywords in a shared `~/.ssh/config`
|
||||
|
||||
The same `~/.ssh/config` is read by macOS ssh *and* by the Linux OpenSSH inside
|
||||
the container (the sidecar `Include`s it). macOS-only keywords are **fatal**
|
||||
there, not ignored — a single `UseKeychain yes` in a `Host *` block takes down
|
||||
every ssh call in the container:
|
||||
|
||||
```
|
||||
/home/developer/.ssh/config: line 2: Bad configuration option: usekeychain
|
||||
/home/developer/.ssh/config: terminating, 1 bad configuration options
|
||||
```
|
||||
|
||||
That breaks `dssh`/`dscp`, `pi --ssh`, `scp`, and anything that shells out to
|
||||
ssh (including CI/deploy helpers), while the host keeps working perfectly — so
|
||||
it presents as a container regression rather than a host config error. Guard the
|
||||
keyword on the host, *before* it is used:
|
||||
|
||||
```diff
|
||||
Host *
|
||||
+ IgnoreUnknown UseKeychain
|
||||
UseKeychain yes
|
||||
AddKeysToAgent yes
|
||||
```
|
||||
|
||||
`IgnoreUnknown` is understood by both implementations: macOS still honours
|
||||
`UseKeychain`, Linux skips it. Also keep such a `Host *` block **below** any
|
||||
`Include` that must come first — OrbStack's own `Include ~/.orbstack/ssh/config`
|
||||
says so in a comment, and a `Host *` block above it silently violates that.
|
||||
|
||||
### Per-host `ControlPath` on a read-only `~/.ssh`
|
||||
|
||||
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
|
||||
@@ -573,7 +657,10 @@ this without editing the read-only config:
|
||||
jump via the host, add `ProxyJump host` overrides in the host-owned
|
||||
`~/.config/devbox-shell/ssh-lan.conf` (see
|
||||
[Naming LAN peers](#naming-lan-peers)) rather than the read-only
|
||||
`~/.ssh/config`.
|
||||
`~/.ssh/config`. If the peer also rejects the host's key — the usual case,
|
||||
since host keys are normally passphrase-protected and the container has no
|
||||
Keychain or agent — see
|
||||
[Giving the container its own key for a peer](#giving-the-container-its-own-key-for-a-peer).
|
||||
|
||||
## tmux and 0-indexed sessions
|
||||
|
||||
@@ -646,6 +733,7 @@ repoint each one at a mirror, another host, or a local `file://` path
|
||||
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
|
||||
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
|
||||
| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant |
|
||||
| `PI_ATELIER_REPO` | `https://github.com/michaelmjhhhh/pi-atelier.git` | variant |
|
||||
| `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant |
|
||||
|
||||
Each has a matching `*_REF` arg (branch name or commit SHA). Example — build
|
||||
@@ -750,14 +838,109 @@ Host pve pve-2 alpserv-2 lagret
|
||||
ProxyJump host
|
||||
```
|
||||
|
||||
`HostName` / `User` / `IdentityFile` are inherited from the matching block in
|
||||
your real `~/.ssh/config` (first-value-wins, so only `ProxyJump` is taken from
|
||||
here). This file is `Include`d *before* `~/.ssh/config` and read fresh on every
|
||||
connection — newly added peers work immediately, no container or session
|
||||
restart needed — and the peer names stay out of the published image (they're a
|
||||
fact about your specific LAN, not the image). Alternatively, set
|
||||
`DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the
|
||||
host without naming peers (see `.env.example`).
|
||||
Any option can be set here, not just `ProxyJump`: the file is `Include`d
|
||||
*before* `~/.ssh/config` and ssh takes the **first** value it sees for each
|
||||
option, so whatever you put here wins while everything you omit is inherited
|
||||
from the matching block in your real `~/.ssh/config`. Peer names stay out of the
|
||||
published image (they are a fact about your LAN, not the image). Alternatively,
|
||||
set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through
|
||||
the host without naming peers (see `.env.example`).
|
||||
|
||||
Once the file exists it is re-read on every connection, so *edits* take effect
|
||||
immediately — no container or session restart. **Creating it for the first time
|
||||
does need one restart**, because `setup-lan-access.sh` only emits the
|
||||
`Include ~/.config/devbox-shell/ssh-lan.conf` line when the file is already
|
||||
readable at container start (`if [ -r "$SSH_LAN_CONF" ]`). Until then ssh never
|
||||
looks at it — which reads exactly like "my override is being ignored".
|
||||
|
||||
#### Giving the container its own key for a peer
|
||||
|
||||
`ProxyJump` fixes *routing*; it does not fix *authentication*, and inheriting
|
||||
the host's `IdentityFile` usually fails inside the container:
|
||||
|
||||
- Host keys are commonly passphrase-protected, and that passphrase is unlocked
|
||||
by the macOS Keychain or a running `ssh-agent`. The container has neither, so
|
||||
the key can never be decrypted — `Permission denied (publickey)` even though
|
||||
the identical `ssh peer` works in a host terminal.
|
||||
- `~/.ssh` is mounted read-only, so you can neither drop a container-usable key
|
||||
in there nor edit `~/.ssh/config` from inside.
|
||||
|
||||
The answer is a **container-only keypair** in `~/.ssh-local/` — the named volume
|
||||
`devbox-ssh-local`, so it survives `docker compose up -d --force-recreate` —
|
||||
plus an `IdentityFile` override in the host-owned `ssh-lan.conf`. Note that
|
||||
nothing is baked into the *published image*: that volume is created on your
|
||||
machine at runtime, so no private key ever ships to Docker Hub, and a fresh pull
|
||||
elsewhere generates its own. (Every key below is a throwaway example.)
|
||||
|
||||
**1. In the container** — generate a passphraseless key (there is no agent to
|
||||
unlock a protected one):
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -N '' -C "devbox-$(hostname)" \
|
||||
-f ~/.ssh-local/mypeer_devbox_ed25519
|
||||
cat ~/.ssh-local/mypeer_devbox_ed25519.pub
|
||||
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7
|
||||
```
|
||||
|
||||
**2. On the peer** — append that public key to `~/.ssh/authorized_keys` **of
|
||||
the account you will log in as** (the `User` from step 3), narrowly authorized
|
||||
rather than bare:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
cat >> ~/.ssh/authorized_keys <<'KEY'
|
||||
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
|
||||
KEY
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
Both lines are safe on a peer that is already set up: `mkdir -p` is a no-op
|
||||
when the directory exists, the `chmod`s only tighten, and appending never
|
||||
touches keys already listed. Use `>>`, never `>` — one stray truncation
|
||||
revokes every other key on that account. The options prefix must sit on the
|
||||
**same physical line** as the key, comma-separated with no spaces: a paste
|
||||
that wrapped is the likeliest reason a key that looks right is refused.
|
||||
`ssh-copy-id` cannot add that prefix, so append by hand (or let it copy the
|
||||
bare key and edit the line afterwards). If authentication still fails with no
|
||||
clear reason, suspect permissions — sshd's `StrictModes` silently ignores
|
||||
`authorized_keys` when the home directory, `~/.ssh` or the file itself is
|
||||
group- or world-writable, and says why only in the peer's own log
|
||||
(`journalctl -u ssh`, `/var/log/auth.log`).
|
||||
|
||||
`restrict` disables pty, agent/X11 and port forwarding; append
|
||||
`port-forwarding` and `permitopen="127.0.0.1:<port>"` after it if you need one
|
||||
specific tunnel. `from=` must list the **host's** addresses, not the
|
||||
container's: container egress is NAT'd through the host, so the peer sees the
|
||||
host's LAN address (confirm with `echo $SSH_CLIENT` on first login). List every
|
||||
network the host roams — e.g. both home WLAN subnets plus its VPN address —
|
||||
because a `from=` mismatch is indistinguishable from a wrong key in the error
|
||||
message.
|
||||
|
||||
**3. On the host** — point the peer at that key in
|
||||
`~/.config/devbox-shell/ssh-lan.conf`:
|
||||
|
||||
```
|
||||
Host mypeer mypeer.home.arpa
|
||||
HostName 192.168.1.142
|
||||
User myuser
|
||||
IdentityFile ~/.ssh-local/mypeer_devbox_ed25519
|
||||
IdentitiesOnly yes
|
||||
# ProxyJump host # only if the container cannot reach the peer directly
|
||||
```
|
||||
|
||||
That path exists only inside containers, which is why it belongs here rather
|
||||
than in the shared `~/.ssh/config`.
|
||||
|
||||
**4. First time only** — restart the container so the `Include` is emitted (see
|
||||
above), then verify with the master socket bypassed, so a warm connection cannot
|
||||
fake a pass:
|
||||
|
||||
```bash
|
||||
ssh -F ~/.ssh-local/config -o ControlPath=none mypeer 'echo $SSH_CLIENT'
|
||||
```
|
||||
|
||||
Use one key per machine (`devbox-mbp`, `devbox-studio`, …) so a single
|
||||
`authorized_keys` line can be revoked without locking out the others.
|
||||
|
||||
### Smoke-testing a local build
|
||||
|
||||
@@ -794,9 +977,72 @@ pi-devbox follows semver-ish:
|
||||
- **Minor** — new variants, significant base additions.
|
||||
- **Patch** — pi version bumps, smaller fixes.
|
||||
|
||||
The `pi --version` inside the image is asserted by smoke tests to
|
||||
match the release tag's pi component, so version drift between the
|
||||
image and the tag is caught at CI time.
|
||||
The `pi --version` inside the image is asserted by smoke tests to match the
|
||||
version CI resolved (since v1.7.0, the pin below), so drift between what was
|
||||
intended and what actually got baked is caught at CI time rather than on a
|
||||
user's pull.
|
||||
|
||||
### Version pins: pi, pi-atelier, mempalace
|
||||
|
||||
Three components are pinned to an exact version **in the repo** instead of being
|
||||
resolved to `latest` at build time:
|
||||
|
||||
| Component | Pin | Where |
|
||||
|---|---|---|
|
||||
| pi | `0.84.2` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.8.1` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| mempalace | `3.6.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||
|
||||
The objective is **not** to freeze versions. Bumping is routine — usually one
|
||||
line plus a changelog note. The objective is that adopting a new upstream
|
||||
version is a deliberate, reviewable act, not a side effect of whatever happened
|
||||
to be published the morning CI ran. Each of these has already drawn blood:
|
||||
|
||||
- **pi** — a minor release can move the private TUI/renderer internals that
|
||||
pi-atelier wraps, or the session `.jsonl` format `pi-session-repair` parses.
|
||||
- **pi-atelier** — 0.6.0/0.7.0 hang pi 0.84 **at startup**, burning CPU with no
|
||||
error (fixed in 0.7.1/0.7.2). Its `peerDependencies` still say `>=0.80.7`, so
|
||||
nothing in the npm metadata expresses the real floor.
|
||||
- **mempalace** — an unpinned install once swept in the broken `diary_write` MCP
|
||||
tool schema of 3.3.x/3.4.0, which is why that pin's comment requires a
|
||||
tool-schema review before every bump.
|
||||
|
||||
CI enforces this rather than trusting it:
|
||||
|
||||
- `resolve-versions` reads the pins **out of the Dockerfiles** — single source of
|
||||
truth, so a local `docker build` and a CI release ship the same versions — and
|
||||
fails the build if a pin is not concrete, not a semver tag, or not actually
|
||||
published on npm.
|
||||
- When npm has a newer pi than the pin, CI emits a `::warning::` naming it. That
|
||||
warning is the prompt to audit and bump; it never adopts the version.
|
||||
- `smoke-test.sh` asserts the image's `pi --version` equals the pin, and
|
||||
separately asserts the pairing rule **pi ≥ 0.84 ⇒ pi-atelier ≥ 0.7.1**, so a
|
||||
bad combination fails the build instead of publishing a TUI that never starts.
|
||||
|
||||
To bump pi: read the upstream CHANGELOG for every intervening version (TUI/theme
|
||||
API, session format, extension loader, Node engine floor), re-check pi-atelier's
|
||||
CHANGELOG for the pi version it claims to track, then edit the one `ARG` line and
|
||||
record what you checked in `CHANGELOG.md`.
|
||||
|
||||
#### If you previously hand-installed pi-atelier
|
||||
|
||||
A hand-installed `pi install npm:pi-atelier` lands in `~/.pi/npm-global`, which
|
||||
is on the `devbox-pi-config` **volume** — so it outlives image upgrades and stays
|
||||
at whatever version you installed, unpinned and unaudited. Since the image now
|
||||
vendors an audited pi-atelier at `/opt/pi-atelier`, the entrypoint removes a
|
||||
lingering `npm:pi-atelier` entry from `packages[]` (after backing
|
||||
`settings.json` up to `settings.json.bak.atelier.<timestamp>`) and registers the
|
||||
pinned `/opt` copy instead. Nothing else in your settings is touched, and the
|
||||
npm-global copy itself is left on disk — only the registration changes.
|
||||
|
||||
This matters more than it sounds: leaving a 0.6.x npm copy registered alongside
|
||||
pi 0.84 is precisely the combination that hangs at startup.
|
||||
|
||||
To opt out of pi-atelier entirely, set `DEVBOX_ATELIER=0` in `.env`. The
|
||||
entrypoint then removes any pi-atelier entry from `packages[]` on start. That
|
||||
switch lives in the entrypoint — not in a pi command — deliberately: this
|
||||
component's failure mode is "pi will not start", which you cannot repair with
|
||||
`pi uninstall`.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Point every client at it by setting, in that client's .env:
|
||||
#
|
||||
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=<the shared bearer token>
|
||||
#
|
||||
# (see .env.example). When set, the client connects over HTTP and does NOT
|
||||
# spawn its own local mempalace-mcp.
|
||||
@@ -18,12 +19,21 @@
|
||||
# (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`.
|
||||
# ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
|
||||
# earlier version of this comment said otherwise and was wrong. The server
|
||||
# compares `Authorization: Bearer <token>` with hmac.compare_digest and
|
||||
# **refuses to start on a non-loopback bind without a token**, so
|
||||
# MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
|
||||
# service crash-loops. It also pins `Host` and allowlists `Origin`.
|
||||
#
|
||||
# Still do not publish port 8765 to an untrusted network. The default binds to
|
||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, attach
|
||||
# them to the shared `mempalace-net` network (container-to-container, no host
|
||||
# port needed — use http://mempalace-server:8765/mcp). To reach it from
|
||||
# elsewhere, terminate TLS in a tunnel/reverse proxy and let the bearer token be
|
||||
# the authentication — do NOT add browser-shaped auth (SSO/PIN/password) in
|
||||
# front, because every MCP client here is a headless JSON-RPC POST and would
|
||||
# receive a login page where JSON should be.
|
||||
|
||||
name: mempalace-server
|
||||
|
||||
@@ -40,6 +50,11 @@ services:
|
||||
user: "0:0"
|
||||
environment:
|
||||
- HOME=/data
|
||||
# Required: mempalace refuses a non-loopback bind without a token (it
|
||||
# would exit at startup and, with restart:unless-stopped, crash-loop).
|
||||
# `:?` fails fast at `docker compose up` with a readable message instead.
|
||||
# Clients send the same value as MEMPALACE_REMOTE_TOKEN.
|
||||
- MEMPALACE_MCP_HTTP_TOKEN=${MEMPALACE_REMOTE_TOKEN:?set MEMPALACE_REMOTE_TOKEN in .env — the shared palace requires a bearer token}
|
||||
command:
|
||||
- mempalace-mcp
|
||||
- --transport
|
||||
@@ -60,16 +75,28 @@ services:
|
||||
- mempalace-shared:/data/.mempalace
|
||||
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
||||
- mempalace-shared-chroma:/data/.cache/chroma
|
||||
# Transcript inbox. Clients cannot mine into a remote palace directly:
|
||||
# `mempalace_mine` expands its source path in THIS process, so it can only
|
||||
# see paths inside this container. Each client rsyncs its staged session
|
||||
# exports to a per-device subdirectory on the host (see
|
||||
# MEMPALACE_PI_SSH_TARGET in .env.example) and then calls mempalace_mine
|
||||
# with the container-side path below (MEMPALACE_PI_REMOTE_PATH=/data/feed).
|
||||
# Read-only: mining only reads sources, and all locks live palace-side.
|
||||
- ${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro
|
||||
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).
|
||||
# GET /healthz, which is Host/Origin-gated but deliberately token-free —
|
||||
# so this probe needs no credentials. Do NOT go back to POSTing
|
||||
# `tools/list` here: that carries no Authorization header and now 401s,
|
||||
# marking a perfectly healthy server unhealthy forever. The Host pin is
|
||||
# only enforced on loopback *binds* (this one is 0.0.0.0), so a request to
|
||||
# 127.0.0.1 inside the container passes.
|
||||
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()"
|
||||
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8765/healthz',timeout=5).status==200 else 1)"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
+134
-4
@@ -92,6 +92,82 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── MemPalace: pi transcript feeder ─────────────────────────────────
|
||||
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
|
||||
# session transcripts into the palace. pi's mempalace extension drives it on
|
||||
# session_shutdown and on a debounced agent_settled; this is the catch-up for
|
||||
# the one case no handler can cover — a hard kill (docker kill, OOM, host
|
||||
# reboot) runs nothing at all, so without this the previous life's transcripts
|
||||
# are never mined.
|
||||
#
|
||||
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
|
||||
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
|
||||
# referencing it share one lifetime — whatever persistence the palace has, the
|
||||
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
|
||||
# re-introduce the very split that design prevents: palace volume kept, stage
|
||||
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
|
||||
#
|
||||
# Backgrounded: a cold mine can take tens of seconds and must never delay the
|
||||
# shell. Contention with a live session is handled by the tool itself (it exits
|
||||
# 0 and lets the palace holder do the mine).
|
||||
|
||||
# Self-heal onto PATH for images whose base predates the toolkit symlink.
|
||||
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
|
||||
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
|
||||
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
|
||||
! command -v mempalace-pi-session >/dev/null 2>&1; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
|
||||
fi
|
||||
|
||||
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
|
||||
# login shell, and a silently-skipped catch-up is exactly the failure we are
|
||||
# here to prevent.
|
||||
MEMPALACE_FEEDER=""
|
||||
if command -v mempalace-pi-session >/dev/null 2>&1; then
|
||||
MEMPALACE_FEEDER="mempalace-pi-session"
|
||||
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
|
||||
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
|
||||
fi
|
||||
|
||||
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
|
||||
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
|
||||
# Remote palace, but no inbox to ship transcripts to — the feeder genuinely
|
||||
# cannot do anything here, so skipping is right. Saying so is the point:
|
||||
# this branch used to be a bare `:`, and the skip happens *before* the
|
||||
# subshell below that writes mempalace-catchup.log, so a container in this
|
||||
# state contributed nothing to the palace and left no artifact at all — not
|
||||
# even an empty log — to explain why. That is indistinguishable from a
|
||||
# healthy run that simply had nothing to file. `tee` puts the notice both in
|
||||
# the container's start output (docker logs) and at the path anyone
|
||||
# debugging "why is nothing from this container in the palace?" looks first.
|
||||
# This is an entrypoint: a notice must never be able to stop a container
|
||||
# from starting. An unwritable ~/.pi (root-owned volume — a classic Docker
|
||||
# permission accident) makes `mkdir -p` fail, and under `set -e` that would
|
||||
# abort startup entirely: a brand-new failure mode in precisely the branch
|
||||
# that used to do nothing at all. Degrade to stdout-only instead.
|
||||
_mp_log="$HOME/.pi/agent/mempalace-catchup.log"
|
||||
mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null
|
||||
{
|
||||
echo "MemPalace catch-up skipped: remote palace with no transcript inbox."
|
||||
echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})"
|
||||
echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this"
|
||||
echo " container's staged sessions. MCP tools still read and write the shared"
|
||||
echo " palace — but this container's own conversations are mined nowhere."
|
||||
echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env,"
|
||||
echo " or unset MEMPALACE_REMOTE_URL to keep the palace local."
|
||||
echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this."
|
||||
} | tee "$_mp_log" 2>/dev/null || true
|
||||
unset _mp_log
|
||||
else
|
||||
mkdir -p "$HOME/.pi/agent"
|
||||
(
|
||||
"$MEMPALACE_FEEDER" --reason container-start \
|
||||
>"$HOME/.pi/agent/mempalace-catchup.log" 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"
|
||||
@@ -169,9 +245,9 @@ if command -v pi &>/dev/null; then
|
||||
"$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):
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) + pi-atelier
|
||||
# (TUI sidebar panels/split-pane) + (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) +
|
||||
@@ -206,9 +282,63 @@ if command -v pi &>/dev/null; then
|
||||
fi
|
||||
}
|
||||
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do
|
||||
# ── pi-atelier: retire a stale `npm:pi-atelier`, plus an opt-out ──────
|
||||
# The image now vendors pi-atelier at a pinned, audited tag (PI_ATELIER_REF
|
||||
# in Dockerfile.variant). A leftover `npm:pi-atelier` entry from a
|
||||
# hand-install resolves through ~/.pi/npm-global, which lives on the
|
||||
# devbox-pi-config VOLUME — so it survives image upgrades and keeps whatever
|
||||
# version was installed by hand, unpinned and unaudited. That is not
|
||||
# academic: pi-atelier < 0.7.1 makes pi >= 0.84 hang at startup with
|
||||
# sustained CPU, so leaving it in place turns a pi bump into a TUI that will
|
||||
# not start. And `_pi_pkg_registered` deliberately counts `npm:<name>` as
|
||||
# registered (it respects a user's own npm install), so the loop below would
|
||||
# never replace it.
|
||||
#
|
||||
# We only DELETE the exact `npm:pi-atelier` string; the loop then registers
|
||||
# /opt/pi-atelier in pi's own canonical serialization, so this code never has
|
||||
# to guess the stored relative-path form. Idempotent — after the rewrite
|
||||
# there is no npm entry left to match.
|
||||
#
|
||||
# DEVBOX_ATELIER=0 goes further and removes pi-atelier from `packages`
|
||||
# altogether. That escape hatch lives HERE, in the entrypoint, precisely
|
||||
# because this component's known failure mode is "pi will not start" — which
|
||||
# you cannot repair with `pi uninstall`.
|
||||
_pi_atelier_drop() {
|
||||
# $1 = jq predicate over one `packages` entry, selecting what to REMOVE.
|
||||
# Returns 0 only when the file was actually rewritten (caller logs), 1 for
|
||||
# "nothing to do" — including missing jq or unparseable JSON, which must
|
||||
# never clobber user settings. Backs up first, same convention as the
|
||||
# template merge above.
|
||||
_ad_settings="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_ad_settings" ] || return 1
|
||||
command -v jq >/dev/null 2>&1 || return 1
|
||||
_ad_new=$(jq "(.packages // []) |= map(select(($1) | not))" "$_ad_settings" 2>/dev/null) || return 1
|
||||
[ -n "$_ad_new" ] || return 1
|
||||
if printf '%s' "$_ad_new" | jq -e --slurpfile cur "$_ad_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
# `.bak.atelier.` rather than the merge's plain `.bak.` prefix: both can
|
||||
# fire in the same startup, and a bare seconds-resolution timestamp would
|
||||
# make the second cp overwrite the first one's backup.
|
||||
cp "$_ad_settings" "${_ad_settings}.bak.atelier.$(date +%Y%m%d-%H%M%S)"
|
||||
printf '%s\n' "$_ad_new" > "$_ad_settings"
|
||||
return 0
|
||||
}
|
||||
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
|
||||
if _pi_atelier_drop '(. == "npm:pi-atelier") or ((type == "string") and endswith("/pi-atelier"))'; then
|
||||
echo "pi-atelier: unregistered per DEVBOX_ATELIER=0 (settings backup saved)"
|
||||
fi
|
||||
elif [ -d /opt/pi-atelier ]; then
|
||||
if _pi_atelier_drop '. == "npm:pi-atelier"'; then
|
||||
echo "pi-atelier: dropped stale npm: registration — the pinned /opt copy takes over (settings backup saved)"
|
||||
fi
|
||||
fi
|
||||
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio /opt/pi-atelier; do
|
||||
[ -d "$_pkg" ] || continue
|
||||
_name=$(basename "$_pkg")
|
||||
# DEVBOX_ATELIER=0 → leave pi-atelier unregistered (handled just above).
|
||||
if [ "$_name" = "pi-atelier" ] && [ "${DEVBOX_ATELIER:-1}" = "0" ]; then continue; fi
|
||||
if ! _pi_pkg_registered "$_name"; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
|
||||
@@ -218,6 +218,15 @@ _pkg_registered() {
|
||||
fi
|
||||
}
|
||||
|
||||
# True when a literal `npm:pi-atelier` entry is still present — the
|
||||
# volume-resident registration the entrypoint migrates away from.
|
||||
_npm_atelier_present() {
|
||||
_s="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_s" ] || return 1
|
||||
command -v jq >/dev/null 2>&1 || return 1
|
||||
jq -e '(.packages // []) | any(. == "npm:pi-atelier")' "$_s" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
for pkg in pi-fork pi-observational-memory; do
|
||||
if _pkg_registered "$pkg"; then
|
||||
@@ -234,6 +243,47 @@ if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
fail "pi-studio NOT in settings.json packages[] (studio variant)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi-atelier — vendored from v1.7.0 on. Absent on older images, and
|
||||
# deliberately unregistered when DEVBOX_ATELIER=0; neither is a failure.
|
||||
if [ -d /opt/pi-atelier ]; then
|
||||
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
|
||||
if _pkg_registered pi-atelier; then
|
||||
fail "pi-atelier still in packages[] despite DEVBOX_ATELIER=0"
|
||||
else
|
||||
pass "pi-atelier unregistered (DEVBOX_ATELIER=0, as requested)"
|
||||
fi
|
||||
elif _pkg_registered pi-atelier; then
|
||||
pass "pi-atelier registered in settings.json packages[]"
|
||||
else
|
||||
fail "pi-atelier NOT in settings.json packages[] (sidebar will not load)"
|
||||
fi
|
||||
if _npm_atelier_present; then
|
||||
fail "stale npm:pi-atelier still in packages[] — it resolves through the ~/.pi/npm-global VOLUME and shadows the pinned /opt copy (entrypoint migration did not run)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── pi <-> pi-atelier compatibility floor ─────────────────────────────
|
||||
# atelier < 0.7.1 wraps pi's private TUI renderer in a way that recurses under
|
||||
# pi >= 0.84: pi hangs at startup burning CPU, with no error message. atelier's
|
||||
# own peerDependencies (>=0.80.7) do not encode this. Assert it here too, not
|
||||
# just in the build-time smoke test: this script runs after a real
|
||||
# `--force-recreate` on a live box, where a volume-resident old copy is exactly
|
||||
# what could bite.
|
||||
if [ -d /opt/pi-atelier ] && command -v jq >/dev/null 2>&1; then
|
||||
_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }
|
||||
_av=$(jq -r '.version // empty' /opt/pi-atelier/package.json 2>/dev/null || true)
|
||||
_pv=$(pi --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)
|
||||
if [ -n "$_av" ] && [ -n "$_pv" ]; then
|
||||
if _ge "$_pv" 0.84.0 && ! _ge "$_av" 0.7.1; then
|
||||
fail "pi $_pv with pi-atelier $_av — atelier < 0.7.1 hangs pi >= 0.84 at startup (bump PI_ATELIER_REF in Dockerfile.variant)"
|
||||
else
|
||||
pass "pi $_pv + pi-atelier $_av (compatibility floor OK)"
|
||||
fi
|
||||
else
|
||||
warn "could not compare pi/pi-atelier versions (pi='$_pv' atelier='$_av')"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
# - 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-atelier vendored at /opt/pi-atelier, registered from /opt (not npm:),
|
||||
# and >= the version floor pi's TUI requires (see the floor test)
|
||||
# - pi-fork + pi-observational-memory cloned with node_modules baked
|
||||
# - entrypoint deploys pi-toolkit keybindings symlink
|
||||
# - entrypoint deploys ≥4 extensions
|
||||
@@ -89,6 +91,70 @@ run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot
|
||||
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"
|
||||
run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
|
||||
# The staging dir must sit next to the palace, not in a disposable cache: the
|
||||
# palace keys per-source dedup on the STAGED path, so a stage that can be wiped
|
||||
# while the palace survives lets `mempalace sync` prune every drawer mined from
|
||||
# it. Assert the resolved default, not an env var — the guarantee is "stage
|
||||
# shares the palace's lifetime", which an ENV pin would quietly break.
|
||||
# NOTE: --sessions-dir gets an EMPTY temp dir, never /tmp. The stage banner is
|
||||
# printed before any export, so nothing needs to be found — and pointing a
|
||||
# default-staged run at a populated dir would export whatever transcripts it
|
||||
# finds into the real stage, which is how a synthetic test session ends up
|
||||
# staged for mining as if it were a real conversation.
|
||||
run "pi stage defaults next to the palace (not a cache dir)" '
|
||||
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||||
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
|
||||
'
|
||||
run "pi stage follows MEMPALACE_PALACE_PATH" '
|
||||
out=$(MEMPALACE_PALACE_PATH=/tmp/alt/.mempalace/palace \
|
||||
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||||
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
|
||||
'
|
||||
# Regression guard for the pi transcript exporter. If pi ever changes its
|
||||
# session JSONL shape, the exporter stops recognising sessions and the palace
|
||||
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
|
||||
# synthetic session and assert it is actually exported. Uses --dry-run so no
|
||||
# palace is touched, and a temp stage so nothing real is written.
|
||||
run "pi transcript exporter recognises a pi session" '
|
||||
set -e
|
||||
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
|
||||
{
|
||||
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question one\"}}"
|
||||
a=$(printf "a%.0s" $(seq 1 1200))
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$a\"}]}}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question two\"}}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"short reply\"}]}}"
|
||||
} > "$s/2026-01-01T00-00-00-000Z_smoke.jsonl"
|
||||
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
|
||||
echo "$out" | grep -q "Exported 1 session"
|
||||
'
|
||||
# The same guard from the other side: a session with no real assistant output
|
||||
# (an abandoned prompt, whose bulk is injected skill text) must NOT be filed.
|
||||
run "pi transcript exporter rejects an abandoned session" '
|
||||
set -e
|
||||
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
|
||||
{
|
||||
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke2\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
|
||||
u=$(printf "u%.0s" $(seq 1 13000))
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"$u\"}}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ready. What would you like to work on?\"}]}}"
|
||||
} > "$s/2026-01-01T00-00-00-000Z_smoke2.jsonl"
|
||||
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
|
||||
echo "$out" | grep -q "no sessions qualified"
|
||||
'
|
||||
# The remote-palace-without-inbox skip must ANNOUNCE itself, not vanish. This
|
||||
# branch of entrypoint-user.sh runs at container start (not reachable from a
|
||||
# `docker run` one-shot), so assert against the entrypoint that actually shipped
|
||||
# in the image. Guards a silent regression back to the bare `:` no-op, which
|
||||
# left a container contributing nothing to the palace with no artifact saying
|
||||
# why — the log it would normally leave is written by the other branch.
|
||||
run_expect "remote-palace-without-inbox skip is announced, not silent" \
|
||||
"grep -o 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | head -1" \
|
||||
"MemPalace catch-up skipped"
|
||||
run "...and the skip notice names the variable that fixes it" \
|
||||
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
|
||||
# v1.0.0 base additions — verify presence and basic functionality.
|
||||
run "pandoc" "pandoc --version"
|
||||
run "typst" "typst --version"
|
||||
@@ -137,6 +203,24 @@ 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-atelier: deliberately NO node_modules assertion, unlike its siblings —
|
||||
# it declares zero runtime dependencies (only peerDeps, satisfied by the baked
|
||||
# pi) and has no build step, so Dockerfile.variant skips `npm install` for it.
|
||||
# Assert what pi actually loads instead: the entry point named by its
|
||||
# package.json `pi.extensions` key.
|
||||
run "pi-atelier clone + entry point" \
|
||||
"test -f /opt/pi-atelier/package.json && test -f /opt/pi-atelier/extensions/index.ts"
|
||||
|
||||
# ── pi <-> pi-atelier compatibility floor (executable, not a comment) ──
|
||||
# pi-atelier < 0.7.1 wraps pi's PRIVATE TUI renderer in a way that recurses
|
||||
# under pi >= 0.84: pi hangs at startup burning CPU, with no error. Upstream
|
||||
# fixed it in 0.7.1/0.7.2, but atelier's peerDependencies still say
|
||||
# `>=0.80.7`, so neither npm nor pi can warn about the real floor. Both
|
||||
# versions are pinned in Dockerfile.variant; this makes a bad PAIRING fail the
|
||||
# build instead of publishing an image whose TUI never starts.
|
||||
run_expect "pi-atelier >= 0.7.1 floor for pi >= 0.84 (startup-hang guard)" \
|
||||
'ge() { [ "$(printf "%s\n%s\n" "$1" "$2" | sort -V | head -n1)" = "$2" ]; }; AV=$(jq -r ".version // empty" /opt/pi-atelier/package.json 2>/dev/null); PV=$(pi --version 2>/dev/null | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" | head -n1); if [ -z "$AV" ] || [ -z "$PV" ]; then echo "unreadable versions (atelier=$AV pi=$PV)"; elif ge "$PV" 0.84.0 && ! ge "$AV" 0.7.1; then echo "VIOLATION: pi $PV with pi-atelier $AV"; else echo "compatible: pi $PV + pi-atelier $AV"; fi' \
|
||||
"compatible:"
|
||||
|
||||
# pi-studio is present only in the :latest-studio variant. Auto-detect by
|
||||
# probing /opt/pi-studio so this one script covers both variants.
|
||||
@@ -159,6 +243,8 @@ 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-atelier" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-atelier"'
|
||||
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
|
||||
@@ -271,6 +357,24 @@ if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
|
||||
"$(pkg_registered_cmd pi-studio)"
|
||||
fi
|
||||
|
||||
# pi-atelier registration. It is LAST in the entrypoint's install loop, so a
|
||||
# pass here also means that loop ran to completion rather than dying midway.
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-atelier)" \
|
||||
>/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test "pi-atelier registered in packages[] (TUI sidebar)" \
|
||||
"$(pkg_registered_cmd pi-atelier)"
|
||||
# ...and registered from the vendored /opt copy, NOT as `npm:pi-atelier`: an
|
||||
# npm: entry resolves through ~/.pi/npm-global on the config VOLUME, which
|
||||
# outlives image upgrades and would silently keep an old, unaudited atelier —
|
||||
# exactly the shape that pairs a stale 0.6.x with a new pi and hangs at startup.
|
||||
exec_test "pi-atelier registered from /opt, not npm: (volume-shadowing guard)" \
|
||||
'jq -e "((.packages // []) | any((type == \"string\") and endswith(\"/pi-atelier\"))) and (((.packages // []) | any(. == \"npm:pi-atelier\")) | not)" $HOME/.pi/agent/settings.json'
|
||||
|
||||
# ── /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'
|
||||
|
||||
Reference in New Issue
Block a user