Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f986e90e | |||
| 26f223568d | |||
| 01abda3456 | |||
| b5810654f6 | |||
| 4f6f470518 | |||
| fbc1f86612 | |||
| 2ebf00d6d4 | |||
| c3b6d36778 | |||
| 3a509077c2 | |||
| a55f6369b3 | |||
| ffd54750b9 | |||
| d8b745c164 | |||
| ae13c2264e | |||
| a2f0a4a441 | |||
| 53b41cd76b | |||
| 29b62093f0 | |||
| cbd7cf5c67 | |||
| 7c00dd6001 | |||
| 7649d53f3b | |||
| ade58131d6 | |||
| ffd44ad9cf | |||
| 43cd6e22f2 | |||
| 62a2a79b1c | |||
| f20b2a7926 | |||
| 66a19aa394 | |||
| 572430237f | |||
| 1fd524e7fb | |||
| e86e5df327 | |||
| fa04d2083d | |||
| 209f2c2f67 | |||
| 4d4abd9a9f | |||
| d5c5da3f6c | |||
| 8248688d58 | |||
| e274510fd1 | |||
| 9ef7a92dce | |||
| 6dfbded9c8 | |||
| 45b6239777 | |||
| d00eef2acb | |||
| fb35c549b5 | |||
| 649fc44c5b | |||
| 89a8dc7fab | |||
| 6625d66f3a | |||
| 8caafc3f49 | |||
| 71b12a9ed4 | |||
| fb49828826 | |||
| 02be95ac1f |
+83
-3
@@ -12,16 +12,81 @@ SSH_KEY_PATH=~/.ssh
|
|||||||
# ── MemPalace memory (local by default) ───────────────────────────
|
# ── MemPalace memory (local by default) ───────────────────────────
|
||||||
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
|
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
|
||||||
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
|
# 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.
|
||||||
|
#
|
||||||
|
# That palace root is resolved with mempalace's own precedence
|
||||||
|
# ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
|
||||||
|
# ~/.mempalace/palace), and the feeders derive their stage FROM it
|
||||||
|
# (<palace-root>/pi-stage). Neither the image nor the entrypoint exports it, by
|
||||||
|
# design: pinning the palace without carrying the stage along re-creates the
|
||||||
|
# very split that a shared root removed. Override it only to move the palace off
|
||||||
|
# the default -- e.g. onto a different mount -- and only to a path with the SAME
|
||||||
|
# persistence as the palace itself. A stage that outlives its palace (or dies
|
||||||
|
# first) makes a scoped `mempalace sync` prune conversation drawers, because
|
||||||
|
# their dedup key is the staged path. Setting it to the default buys nothing.
|
||||||
|
# Unlike WORKSPACE_PATH/SSH_KEY_PATH above, this is a path INSIDE the container.
|
||||||
|
# MEMPALACE_PALACE_PATH=/home/developer/.mempalace/palace
|
||||||
#
|
#
|
||||||
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
|
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
|
||||||
# + native), set the URL below. When set, the extension connects over HTTP
|
# + 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
|
# 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.
|
# 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_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 — i.e. the
|
||||||
|
# path the SERVER PROCESS can open. If the palace
|
||||||
|
# server runs in Docker, that is the container path
|
||||||
|
# (see docker-compose.mempalace.yml). If it runs
|
||||||
|
# NATIVELY (systemd unit / uv tool / plain
|
||||||
|
# `mempalace serve`), it sees host paths, so this
|
||||||
|
# must equal the path half of
|
||||||
|
# MEMPALACE_PI_SSH_TARGET. Getting this wrong is
|
||||||
|
# quiet: rsync still succeeds and only the mine
|
||||||
|
# fails with "source directory not found", so
|
||||||
|
# transcripts ship and are filed nowhere. The feeder
|
||||||
|
# warns in preflight when the two paths disagree.
|
||||||
|
# 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) ─────────────────
|
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||||
# reach the host's directly-attached LAN peers by default. The entrypoint
|
# reach the host's directly-attached LAN peers by default. The entrypoint
|
||||||
@@ -43,7 +108,22 @@ SSH_KEY_PATH=~/.ssh
|
|||||||
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
|
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
|
||||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
|
# 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 ────────────────────────────────────────────────
|
# ── 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_NAME=
|
||||||
GIT_USER_EMAIL=
|
GIT_USER_EMAIL=
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ name: Publish Docker Image
|
|||||||
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
|
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
|
||||||
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
|
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
|
||||||
# 7. update-description patch Docker Hub description.
|
# 7. update-description patch Docker Hub description.
|
||||||
|
#
|
||||||
|
# Note the trigger: `push: tags: v*` (plus workflow_dispatch). Nothing here runs
|
||||||
|
# on a push to main, so a smoke assertion added outside a release is UNVALIDATED
|
||||||
|
# until the next tag — which is exactly how v1.8.0 shipped a broken assertion
|
||||||
|
# written three days earlier (it asserted a literal /home/developer stage path,
|
||||||
|
# while `run` executes `docker run --entrypoint=""` as root with HOME=/root).
|
||||||
|
# The `smoke_only` dispatch input exists to close that gap: it runs steps 1-4
|
||||||
|
# against HEAD and stops before anything is published.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -33,6 +41,10 @@ on:
|
|||||||
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
|
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
smoke_only:
|
||||||
|
description: 'Build base + run both smoke jobs against HEAD, then stop. Publishes nothing. Use to validate smoke assertions without cutting a tag.'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -136,8 +148,16 @@ jobs:
|
|||||||
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
|
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
|
||||||
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
|
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
|
||||||
studio_tag: ${{ steps.resolve.outputs.studio_tag }}
|
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 }}
|
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
|
||||||
steps:
|
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
|
- name: Resolve pi version + companion refs
|
||||||
id: resolve
|
id: resolve
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -157,13 +177,69 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
|
# Read a commit SHA from Gitea, surviving a bad build token.
|
||||||
# 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)
|
# These repos are public (see the note at the call sites), so auth is
|
||||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
# a convenience, not a requirement — but Gitea REJECTS an invalid
|
||||||
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
|
# token (401) rather than ignoring it, so a revoked or malformed
|
||||||
|
# GITEA_BUILD_TOKEN could fail an entire release on reads that work
|
||||||
|
# fine anonymously. An ABSENT secret was always safe (Gitea ignores an
|
||||||
|
# empty `token ` value and serves the request, 200); a STALE one was
|
||||||
|
# not. So: try authed, and on 401/403 retry anonymously.
|
||||||
|
#
|
||||||
|
# A non-200 after that emits nothing and returns 0 deliberately, so
|
||||||
|
# require_sha raises the loud explicit abort rather than this helper
|
||||||
|
# inventing a fallback ref.
|
||||||
|
#
|
||||||
|
# Messages go to STDERR, not as ::warning:: annotations: this
|
||||||
|
# function's stdout IS the SHA, so anything written there would be
|
||||||
|
# captured into the ref by the command substitution.
|
||||||
|
gitea_sha() { # $1=repo
|
||||||
|
local repo="$1" url resp code
|
||||||
|
url="https://gitea.jordbo.se/api/v1/repos/joakimp/${repo}/commits?limit=1&sha=main"
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$url" || printf '\n000')
|
||||||
|
code=${resp##*$'\n'}
|
||||||
|
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
|
||||||
|
printf 'WARNING: Gitea rejected the build token for %s (HTTP %s); retrying anonymously. The read should succeed (public repo), but GITEA_BUILD_TOKEN is stale or malformed and should be rotated.\n' "$repo" "$code" >&2
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' "$url" || printf '\n000')
|
||||||
|
code=${resp##*$'\n'}
|
||||||
|
fi
|
||||||
|
if [ "$code" != "200" ]; then
|
||||||
|
printf 'WARNING: Gitea commit lookup for %s returned HTTP %s\n' "$repo" "$code" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s' "${resp%$'\n'*}" | jq -r '.[0].sha // empty' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 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
|
exit 1
|
||||||
fi
|
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"
|
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||||
@@ -176,15 +252,47 @@ jobs:
|
|||||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
|
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
||||||
# requires auth even for public-repo commit listing.
|
# (which track a branch head) atelier wraps pi's private TUI
|
||||||
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
# renderer, so its version is pinned in Dockerfile.variant and read
|
||||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
|
# from there; we only resolve tag → SHA, for reproducibility and to
|
||||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
# 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. All three Gitea
|
||||||
|
# repos read in this step are PUBLIC: an unauthenticated GET of these
|
||||||
|
# commit endpoints returns 200 with the IDENTICAL sha (verified
|
||||||
|
# 2026-08-15 for pi-toolkit, pi-extensions and mempalace-toolkit).
|
||||||
|
# The comment that used to sit here claimed the Gitea API "requires
|
||||||
|
# auth even for public-repo commit listing" — it does not. Only
|
||||||
|
# /api/v1/repos/*/actions/* refuses anonymous reads (401), which is
|
||||||
|
# what that claim was almost certainly generalised from.
|
||||||
|
#
|
||||||
|
# The header is still passed on purpose: it keeps working if a repo is
|
||||||
|
# ever flipped private, and an ABSENT secret degrades cleanly, because
|
||||||
|
# Gitea ignores an empty `token ` value and serves the request
|
||||||
|
# anonymously (200). The real hazard is the opposite one — a REVOKED or
|
||||||
|
# malformed token returns 401 where anonymous would have returned 200,
|
||||||
|
# so a stale GITEA_BUILD_TOKEN turns a healthy public read into a
|
||||||
|
# require_sha failure that reads like an API or network fault. If this
|
||||||
|
# step ever fails on a repo you can browse anonymously, suspect the
|
||||||
|
# token before you suspect Gitea.
|
||||||
|
TOOLKIT_REF=$(gitea_sha pi-toolkit)
|
||||||
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
||||||
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
|
EXTENSIONS_REF=$(gitea_sha pi-extensions)
|
||||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
|
|
||||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
||||||
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
||||||
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||||
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -194,9 +302,7 @@ jobs:
|
|||||||
# into the base-decide hash (see that job) to force a base rebuild
|
# into the base-decide hash (see that job) to force a base rebuild
|
||||||
# when the toolkit moves — otherwise a toolkit-only fix silently
|
# when the toolkit moves — otherwise a toolkit-only fix silently
|
||||||
# fails to land unless Dockerfile.base itself changes.
|
# fails to land unless Dockerfile.base itself changes.
|
||||||
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
MEMPALACE_TOOLKIT_REF=$(gitea_sha mempalace-toolkit)
|
||||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
|
|
||||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
||||||
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
||||||
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -230,7 +336,8 @@ jobs:
|
|||||||
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
||||||
echo "studio_tag=${STUDIO_TAG}" >> "$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_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||||
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
||||||
echo "Resolved PI_STUDIO_REF=${STUDIO_REF} (pi-studio ${STUDIO_TAG})"
|
echo "Resolved PI_STUDIO_REF=${STUDIO_REF} (pi-studio ${STUDIO_TAG})"
|
||||||
@@ -357,6 +464,8 @@ jobs:
|
|||||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_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
|
RELEASE_TAG=smoke
|
||||||
SOURCE_REVISION=${{ github.sha }}
|
SOURCE_REVISION=${{ github.sha }}
|
||||||
- name: Smoke test (amd64)
|
- name: Smoke test (amd64)
|
||||||
@@ -417,6 +526,8 @@ jobs:
|
|||||||
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
|
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
|
||||||
PI_STUDIO_VERSION=${{ needs.resolve-versions.outputs.studio_tag }}
|
PI_STUDIO_VERSION=${{ needs.resolve-versions.outputs.studio_tag }}
|
||||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_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-studio
|
RELEASE_TAG=smoke-studio
|
||||||
SOURCE_REVISION=${{ github.sha }}
|
SOURCE_REVISION=${{ github.sha }}
|
||||||
- name: Smoke test studio (amd64)
|
- name: Smoke test studio (amd64)
|
||||||
@@ -427,6 +538,14 @@ jobs:
|
|||||||
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
||||||
build-variant:
|
build-variant:
|
||||||
needs: [base-decide, smoke, resolve-versions]
|
needs: [base-decide, smoke, resolve-versions]
|
||||||
|
# A `smoke_only` dispatch stops the pipeline here: base is probed/built and
|
||||||
|
# both smoke jobs run, but nothing is published. Deliberately NOT wrapped in
|
||||||
|
# always() — specifying `if:` keeps the implicit "all needs succeeded" gate,
|
||||||
|
# so a failing smoke still blocks the release. On a tag push `inputs` is
|
||||||
|
# unset, and `null != 'true'` is true, so releases are unaffected.
|
||||||
|
# promote-base-latest and update-description need build-variant to have
|
||||||
|
# succeeded, so they skip on their own — no extra guard required.
|
||||||
|
if: inputs.smoke_only != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -471,6 +590,8 @@ jobs:
|
|||||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG_FLAGS=()
|
TAG_FLAGS=()
|
||||||
@@ -490,6 +611,10 @@ jobs:
|
|||||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_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 "RELEASE_TAG=${RELEASE_TAG}" \
|
||||||
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||||
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
||||||
@@ -513,6 +638,7 @@ jobs:
|
|||||||
# or fail independently of the core release.
|
# or fail independently of the core release.
|
||||||
build-variant-studio:
|
build-variant-studio:
|
||||||
needs: [base-decide, smoke-studio, resolve-versions]
|
needs: [base-decide, smoke-studio, resolve-versions]
|
||||||
|
if: inputs.smoke_only != 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -559,6 +685,8 @@ jobs:
|
|||||||
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
|
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
|
||||||
STUDIO_TAG: ${{ needs.resolve-versions.outputs.studio_tag }}
|
STUDIO_TAG: ${{ needs.resolve-versions.outputs.studio_tag }}
|
||||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG_FLAGS=()
|
TAG_FLAGS=()
|
||||||
@@ -579,6 +707,10 @@ jobs:
|
|||||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||||
--build-arg "INSTALL_STUDIO=true" \
|
--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_REF=${STUDIO_REF}" \
|
||||||
--build-arg "PI_STUDIO_VERSION=${STUDIO_TAG}" \
|
--build-arg "PI_STUDIO_VERSION=${STUDIO_TAG}" \
|
||||||
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
||||||
|
|||||||
@@ -6,11 +6,28 @@ name: Lint
|
|||||||
# actionlint runs shellcheck against each `run:` step using its *effective*
|
# actionlint runs shellcheck against each `run:` step using its *effective*
|
||||||
# shell, so `set -o pipefail` under dash is flagged as SC3040 before any
|
# 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
|
# 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
|
# pipeline, so it fires on every branch push/PR — not just on release tags,
|
||||||
# is where the build workflow (docker-publish.yml) is otherwise only
|
# which is where the build workflow (docker-publish.yml) is otherwise only
|
||||||
# triggered.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ re-brand of opencode-devbox's `pi-only` variant.
|
|||||||
by copying `/opt/pi-extensions/skill/` over the committed `rootfs/` snapshot
|
by copying `/opt/pi-extensions/skill/` over the committed `rootfs/` snapshot
|
||||||
(Option 1 over Option 2 — see `skills/VENDORED.md`).
|
(Option 1 over Option 2 — see `skills/VENDORED.md`).
|
||||||
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
|
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
|
||||||
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
|
- `entrypoint-user.sh` — per-container start: prints the `pi-devbox-version`
|
||||||
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
|
banner first (which build/commit is running, from the manifest below),
|
||||||
deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
|
then SSH ControlMaster socket dir, LAN-access setup, MemPalace init,
|
||||||
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), image-baked
|
pi-toolkit + pi-extensions deploy, mempalace-bridge symlink, fork/recall +
|
||||||
skills symlink-in, skillset deploy.
|
pi-studio pi-install, optional `studio-expose` bridge (when
|
||||||
|
`STUDIO_EXPOSE=1`), image-baked skills symlink-in, skillset deploy.
|
||||||
- `rootfs/` — files baked into the image (bash aliases, inputrc,
|
- `rootfs/` — files baked into the image (bash aliases, inputrc,
|
||||||
setup-lan-access.sh, `studio-expose` helper). Also
|
setup-lan-access.sh, `studio-expose` helper, `pi-devbox-version` — wraps
|
||||||
|
`/etc/pi-devbox/build-manifest.json` into a human-readable summary + live
|
||||||
|
drift check, see README “Build provenance”). Also
|
||||||
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
|
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
|
||||||
skills (the repo-authored `pi-devbox-environment`, plus vendored fallback
|
skills (the repo-authored `pi-devbox-environment`, plus vendored fallback
|
||||||
copies of `pi-extensions` and `mempalace` — see `skills/VENDORED.md`)
|
copies of `pi-extensions` and `mempalace` — see `skills/VENDORED.md`)
|
||||||
@@ -73,7 +76,15 @@ re-brand of opencode-devbox's `pi-only` variant.
|
|||||||
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
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 +
|
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
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 fires **only**
|
||||||
|
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
|
||||||
|
excludes tag refs on purpose (the tagged tree was already linted when the
|
||||||
|
commit hit `main`, and a fast lint run sorting above the slow publish run
|
||||||
|
made releases look finished before anything shipped). Verified on v1.8.4:
|
||||||
|
`refs/tags/v1.8.4` produced run 571 (publish) and nothing else. Still filter
|
||||||
|
discovery on `head_sha` **and** the workflow `path` — see *Gitea API access*
|
||||||
|
below — because that guard costs nothing and a future workflow added on `v*`
|
||||||
|
would silently reintroduce the ambiguity.
|
||||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||||
base-latest if the base was rebuilt this run).
|
base-latest if the base was rebuilt this run).
|
||||||
7. **Revoke any short-lived Gitea PAT** used during the release at
|
7. **Revoke any short-lived Gitea PAT** used during the release at
|
||||||
@@ -81,6 +92,34 @@ re-brand of opencode-devbox's `pi-only` variant.
|
|||||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||||
its lifecycle is managed host-side, nothing to revoke.
|
its lifecycle is managed host-side, nothing to revoke.
|
||||||
|
|
||||||
|
## Verifying this repo's reality from inside a container
|
||||||
|
|
||||||
|
Most work on this repo happens **inside** a pi-devbox container, inspecting a
|
||||||
|
host or a peer over SSH. That setup manufactures convincing false negatives, so
|
||||||
|
when you are about to report that something is **absent, unreachable, or not
|
||||||
|
running**, suspect your own command first. Recurring instances:
|
||||||
|
|
||||||
|
- **`docker` is not on the host's non-interactive SSH `PATH`.** `ssh mac 'docker
|
||||||
|
ps'` says *command not found* on a host that plainly runs Docker; use
|
||||||
|
`/usr/local/bin/docker` (or `command -v docker` first). Every step in the
|
||||||
|
*Release-day checklist* that inspects a running container hits this.
|
||||||
|
- **Don't `| head -N` a search whose answer you don't already know.** The host's
|
||||||
|
`~/.ssh/config` is ~500 lines; a `head -20` "proved" a peer absent that was
|
||||||
|
defined at line 454.
|
||||||
|
- **The deployment compose file is not this repo's.** `docker-compose.yml` here
|
||||||
|
is a template pinning `:latest`; a real host runs its own per-machine file
|
||||||
|
(find it with `docker inspect <container> --format '{{ index .Config.Labels
|
||||||
|
"com.docker.compose.project.config_files" }}'`). Recreating from the repo copy
|
||||||
|
can silently move a host off `:latest-studio` onto `:latest`.
|
||||||
|
- **A live SSH ControlMaster hides remote auth changes** — after editing a
|
||||||
|
peer's `authorized_keys`, prove access with `-o ControlPath=none -o
|
||||||
|
ControlMaster=no`, or the breakage surfaces in a later session instead.
|
||||||
|
|
||||||
|
Depth and further mechanisms: the repo-authored `pi-devbox-environment` skill
|
||||||
|
(`rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md`) §2
|
||||||
|
and §3 — that file is the one an agent actually loads mid-session, whereas this
|
||||||
|
`AGENTS.md` is only auto-read when the cwd *is* this repo.
|
||||||
|
|
||||||
## Gitea API access (env token)
|
## Gitea API access (env token)
|
||||||
|
|
||||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||||
@@ -89,13 +128,61 @@ host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
|||||||
**not** baked into the image. When configured, they are also available for
|
**not** baked into the image. When configured, they are also available for
|
||||||
**any** direct Gitea API interaction from inside the container — inspecting
|
**any** direct Gitea API interaction from inside the container — inspecting
|
||||||
CI runs, checking published tags, listing commits — e.g.
|
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
|
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
|
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
|
||||||
unauthenticated too, so the token matters mainly for private repos or
|
unauthenticated too, so the token matters mainly for private repos or
|
||||||
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
||||||
revoke after use. Never echo the token value (including into logs).
|
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)
|
## Cache-hit footgun (must-know)
|
||||||
|
|
||||||
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
||||||
|
|||||||
+1423
-2
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -46,7 +46,8 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
|||||||
|
|
||||||
### pi and companions
|
### 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-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`
|
- **[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
|
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
|
||||||
@@ -64,12 +65,19 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
|||||||
### Document and image tooling
|
### Document and image tooling
|
||||||
|
|
||||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
||||||
|
- **Typst** — markup-based typesetting, used as pandoc's `--pdf-engine`
|
||||||
- **graphviz** (`dot`) — diagram rendering pipelines
|
- **graphviz** (`dot`) — diagram rendering pipelines
|
||||||
- **imagemagick** (`magick`) — image conversion / resizing
|
- **imagemagick** (`magick`) — image conversion / resizing
|
||||||
|
|
||||||
|
### Browser automation
|
||||||
|
|
||||||
|
- **agent-browser** — CLI for driving a real browser (open pages, click/fill/`eval`, snapshot the DOM, screenshots) so agents can verify front-end work instead of guessing
|
||||||
|
- **Playwright** + a headless **Chromium** are pre-installed and pinned together; `AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser, so `agent-browser open <url>` works out of the box with no setup
|
||||||
|
- **socat** — TCP bridge used to expose the pi-studio server outside the container's loopback
|
||||||
|
|
||||||
### Modern CLI tooling
|
### Modern CLI tooling
|
||||||
|
|
||||||
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
|
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
|
||||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||||
- **Display**: bat, eza, htop, tree
|
- **Display**: bat, eza, htop, tree
|
||||||
- **Data**: jq, yq
|
- **Data**: jq, yq
|
||||||
|
|||||||
+128
-6
@@ -14,7 +14,7 @@
|
|||||||
# content-addressed over this file, so any byte change invalidates the
|
# content-addressed over this file, so any byte change invalidates the
|
||||||
# cache. Recommended cadence: once per release for security updates.
|
# cache. Recommended cadence: once per release for security updates.
|
||||||
#
|
#
|
||||||
# BASE_REBUILD_DATE: 2026-07-11 (Unreleased — typst PDF engine + xz-utils + pandoc typst-template default-font patch)
|
# BASE_REBUILD_DATE: 2026-07-13 (Unreleased — agent-browser CLI + Playwright Chromium for headless browser automation; prior: typst PDF engine + xz-utils + pandoc typst-template default-font patch)
|
||||||
#
|
#
|
||||||
# ── Lineage note ─────────────────────────────────────────────────────
|
# ── Lineage note ─────────────────────────────────────────────────────
|
||||||
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
||||||
@@ -367,13 +367,73 @@ ARG INSTALL_MEMPALACE=true
|
|||||||
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff
|
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff
|
||||||
# rather than a surprise.
|
# 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
|
# schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema
|
||||||
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
|
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
|
||||||
# which Anthropic's tools API accepts — so the old mcp_server.py perl
|
# 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
|
# workaround that used to live below is gone.
|
||||||
# opencode-devbox when bumping.
|
#
|
||||||
ARG MEMPALACE_VERSION=3.5.0
|
# 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.
|
||||||
|
#
|
||||||
|
# 3.7.1 (from 3.6.0) is safe for anyone with an EXISTING LOCAL palace: verified
|
||||||
|
# against the 3.7.1 source, not the changelog. Legacy drawers lack the new
|
||||||
|
# `chunk_total` marker and both decision sites trust them ("trust the match as
|
||||||
|
# before"), NORMALIZE_VERSION is 2 in both, chromadb stays <2 (no index-format
|
||||||
|
# migration), there is no auto-migration ("We do NOT auto-migrate"), and the one
|
||||||
|
# new palace file (logstream.sqlite3) is created lazily on first logstream use.
|
||||||
|
# Two behaviour changes to know: MEMPALACE_MCP_ALLOW_PEER_WRITER no longer works
|
||||||
|
# on local/chroma palaces, and writer-lock setup failures now fail CLOSED
|
||||||
|
# (refuse the write) rather than fail open. Neither affects the container's
|
||||||
|
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
|
||||||
|
# same lock under 3.6.0.
|
||||||
|
#
|
||||||
|
# 3.8.0 (2026-08-23, PyPI, released hours after this project's own v1.8.5 tag
|
||||||
|
# the same day) is additive/reliability only — reviewed for MCP tool-schema
|
||||||
|
# changes before bumping, as always: there are NONE. Two PRs matter:
|
||||||
|
# - PR #2320/#2322: `sync --apply` no longer deletes a drawer solely because
|
||||||
|
# its source_file was unreachable AT THAT MOMENT — it now asks for
|
||||||
|
# corroboration first. This fixes losing a whole mined project to one
|
||||||
|
# `sync --apply` while its volume happened to be unmounted.
|
||||||
|
# IMPORTANT — do not over-read this fix: it addresses TRANSIENT
|
||||||
|
# unreachability, not the standing landmine (documented in the operator's
|
||||||
|
# global AGENTS.md) against running `mempalace_sync` / `mempalace_delete_by_source`
|
||||||
|
# beyond dry-run on the SHARED central palace. On that palace most
|
||||||
|
# source_file paths are PERMANENTLY absent from whichever host runs the
|
||||||
|
# sync — a different machine's paths simply do not exist here, ever, not
|
||||||
|
# merely "right now". That is a different failure shape than #2320/#2322
|
||||||
|
# fixes. The landmine still stands; this bump does not relax it.
|
||||||
|
# - PR #2307: long-running Chroma servers no longer invalidate their own
|
||||||
|
# HNSW cache on their own writes (server-side perf fix). This does NOT
|
||||||
|
# make `mempalace_reconnect` unnecessary — that tool exists for EXTERNAL
|
||||||
|
# writes bypassing the in-process client (e.g. direct sqlite backfills,
|
||||||
|
# CLI commands against a running server), a different scenario #2307
|
||||||
|
# does not touch.
|
||||||
|
#
|
||||||
|
# Known gap, carried forward (flagged in prior release notes, not fixed here):
|
||||||
|
# unlike PI_VERSION, which CI's resolve-versions job verifies is published and
|
||||||
|
# warns — never silently adopts — on npm drift, MEMPALACE_VERSION has NO
|
||||||
|
# equivalent CI-side audit (confirmed: zero references to MEMPALACE_VERSION in
|
||||||
|
# .gitea/workflows/docker-publish.yml). This is a literal Dockerfile string
|
||||||
|
# with no automated freshness or publish check.
|
||||||
|
#
|
||||||
|
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
||||||
|
# central palace host) currently serves mempalace 3.7.1 SERVER-SIDE via
|
||||||
|
# docker-compose.mempalace.yml, which reuses this same devbox image. Bumping
|
||||||
|
# this ARG changes only the CLIENT version baked into pi-devbox images: it
|
||||||
|
# introduces client/server skew until synlig's compose stack is separately
|
||||||
|
# rebuilt/redeployed with the new pin. Not something to code around here —
|
||||||
|
# just sequence the redeploy.
|
||||||
|
ARG MEMPALACE_VERSION=3.8.0
|
||||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||||
@@ -413,9 +473,15 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
|
|||||||
[ "$ok" = "1" ] && \
|
[ "$ok" = "1" ] && \
|
||||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
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 && \
|
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 && \
|
||||||
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-census /usr/local/bin/mempalace-census && \
|
||||||
|
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
|
||||||
|
/opt/mempalace-toolkit/bin/mempalace-pi-session \
|
||||||
|
/opt/mempalace-toolkit/bin/mempalace-census && \
|
||||||
mempalace-session --help >/dev/null && \
|
mempalace-session --help >/dev/null && \
|
||||||
mempalace-docs --help >/dev/null && \
|
mempalace-docs --help >/dev/null && \
|
||||||
|
mempalace-pi-session --help >/dev/null && \
|
||||||
|
mempalace-census --help >/dev/null && \
|
||||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -457,6 +523,58 @@ RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesour
|
|||||||
apt-get install -y --no-install-recommends nodejs && \
|
apt-get install -y --no-install-recommends nodejs && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── agent-browser — headless browser automation for the agent ────────
|
||||||
|
# Gives the agent a real browser it can drive (open/click/fill/eval/
|
||||||
|
# screenshot) so front-end work involving live DOM or WebGL can be VERIFIED
|
||||||
|
# rather than guessed at. The `agent-browser` skill (shipped from the
|
||||||
|
# skillset repo, not this image) documents the CLI; without this block that
|
||||||
|
# skill is a no-op because the binary isn't present. Verified end-to-end
|
||||||
|
# 2026-07-13: drives the baked Chromium headless (open + screenshot + eval
|
||||||
|
# into a WebGL SPA) — doctor's launch test passes in ~0.5s.
|
||||||
|
#
|
||||||
|
# TWO pieces, because agent-browser is a standalone Rust CLI that ships NO
|
||||||
|
# browser of its own — it only drives one you provide:
|
||||||
|
# 1. the CLI itself (npm; ~70 MB of prebuilt native binaries), and
|
||||||
|
# 2. a Chromium, which we fetch via Playwright.
|
||||||
|
#
|
||||||
|
# Why Playwright fetches the browser (and NOT `agent-browser install`):
|
||||||
|
# agent-browser's own installer drops Chrome under ~/.agent-browser/browsers
|
||||||
|
# — inside /home/${USER_NAME}, which is a NAMED VOLUME at runtime, so a
|
||||||
|
# build-time download would be SHADOWED (invisible) once the volume mounts.
|
||||||
|
# Playwright honours PLAYWRIGHT_BROWSERS_PATH, so we place the browser under
|
||||||
|
# /usr/local/share (never shadowed) and hand agent-browser a STABLE symlink
|
||||||
|
# via AGENT_BROWSER_EXECUTABLE_PATH — the symlink insulates the ENV from
|
||||||
|
# Playwright's per-version, per-ARCH browser directory (`chrome-linux` on arm64,
|
||||||
|
# `chrome-linux64` on amd64 — Chrome-for-Testing), so we `find` the `chrome`
|
||||||
|
# binary rather than hardcode the path; the headless-shell binary is named
|
||||||
|
# `chrome-headless-shell`, so `-name chrome` skips it.
|
||||||
|
#
|
||||||
|
# `playwright install --with-deps chromium` also apt-installs Chromium's
|
||||||
|
# runtime libs; verified to resolve correctly on Debian trixie (exit 0 — the
|
||||||
|
# t64 library renames are handled by Playwright's dep list). Build runs as
|
||||||
|
# root, so the apt step works. NPM_CONFIG_PREFIX=/usr keeps both CLIs on /usr
|
||||||
|
# so they survive the ~/.pi/npm-global volume mount (same trick the variant
|
||||||
|
# uses for pi). After fetching, we DROP Playwright's `chromium_headless_shell-*`
|
||||||
|
# build — agent-browser drives the full chrome (verified, incl. headless), so the
|
||||||
|
# headless shell is dead weight — and clean the apt/npm caches, trimming the
|
||||||
|
# layer to ~625 MB (Chromium) from ~960 MB. Still the bulk of the base's size,
|
||||||
|
# and the one real tradeoff of shipping this to every variant.
|
||||||
|
ARG AGENT_BROWSER_VERSION=latest
|
||||||
|
ARG PLAYWRIGHT_VERSION=latest
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright
|
||||||
|
RUN NPM_CONFIG_PREFIX=/usr npm install -g \
|
||||||
|
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
||||||
|
"playwright@${PLAYWRIGHT_VERSION}" && \
|
||||||
|
playwright install --with-deps chromium && \
|
||||||
|
CHROME="$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -path '*/chromium-*/*' | head -n1)" && \
|
||||||
|
[ -n "$CHROME" ] && ln -sf "$CHROME" /usr/local/bin/agent-chrome && \
|
||||||
|
agent-browser --version && \
|
||||||
|
test -x "$(readlink -f /usr/local/bin/agent-chrome)" && \
|
||||||
|
rm -rf "${PLAYWRIGHT_BROWSERS_PATH}"/chromium_headless_shell-* && \
|
||||||
|
npm cache clean --force && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /root/.npm /tmp/*
|
||||||
|
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/agent-chrome
|
||||||
|
|
||||||
# ── tldr (tealdeer) — community-maintained command examples ──────────
|
# ── tldr (tealdeer) — community-maintained command examples ──────────
|
||||||
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
|
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
|
||||||
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
|
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
|
||||||
@@ -604,11 +722,15 @@ COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
|||||||
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
|
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
|
||||||
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
||||||
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
||||||
|
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
||||||
|
COPY rootfs/usr/local/bin/devbox-skill-reconcile /usr/local/bin/devbox-skill-reconcile
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||||
/usr/local/bin/studio-expose \
|
/usr/local/bin/studio-expose \
|
||||||
/usr/local/bin/dot-watch \
|
/usr/local/bin/dot-watch \
|
||||||
|
/usr/local/bin/pi-devbox-version \
|
||||||
|
/usr/local/bin/devbox-skill-reconcile \
|
||||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||||
|
|||||||
+100
-11
@@ -29,16 +29,49 @@ ARG USER_NAME=developer
|
|||||||
# runs each repo's install.sh on container start so symlinks land under
|
# runs each repo's install.sh on container start so symlinks land under
|
||||||
# ~/.pi/agent/ on the named volume.
|
# ~/.pi/agent/ on the named volume.
|
||||||
#
|
#
|
||||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
# ── pi version pin: an AUDITED CHECKPOINT, not a freeze ──────────────
|
||||||
# (resolved from `npm view @earendil-works/pi-coding-agent version`).
|
# PI_VERSION is pinned to a version whose upstream CHANGELOG has been read
|
||||||
# The default `latest` is for local dev convenience only — it has a
|
# against this image's integration surface: the theme/TUI API that pi-atelier
|
||||||
# known cache-hit footgun in registry-cached CI builds: the resulting
|
# couples to, the session `.jsonl` format that `pi-session-repair` parses, the
|
||||||
# build-arg string is byte-identical across builds, the layer-hash is
|
# extension/package loader, and the Node engine floor. CI reads THIS LINE as
|
||||||
# identical, and the registry buildcache silently reuses the layer
|
# the single source of truth (see the `resolve-versions` job) and no longer
|
||||||
# from whatever pi version was current when the cache was first
|
# follows npm `latest` — following it meant every release silently adopted
|
||||||
# populated. CI MUST pass a resolved concrete version. See pi-devbox
|
# whatever pi shipped that morning, unaudited, in the very build that then got
|
||||||
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
|
# tagged and published.
|
||||||
ARG PI_VERSION=latest
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
|
||||||
|
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
|
||||||
|
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
|
||||||
|
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
|
||||||
|
# were grepped for that symbol and reference it ZERO times, so nothing here
|
||||||
|
# couples to the renamed type. Recorded because the heading will look alarming
|
||||||
|
# to the next reader doing step 1 above — the audit is done, don't redo it.
|
||||||
|
# Adopted for two fixes that land squarely on this repo's own vendored-skill
|
||||||
|
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
|
||||||
|
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
|
||||||
|
# files such as README.md / AGENTS.md inside a skill dir were reported as
|
||||||
|
# broken skills unless they declared valid skill frontmatter.
|
||||||
|
# pi-atelier needs no companion bump: v0.8.2 clears the >=0.7.1 floor that
|
||||||
|
# pi >= 0.84 requires (see PI_ATELIER_REF below).
|
||||||
|
ARG PI_VERSION=0.84.3
|
||||||
ARG PI_TOOLKIT_REF=main
|
ARG PI_TOOLKIT_REF=main
|
||||||
ARG PI_EXTENSIONS_REF=main
|
ARG PI_EXTENSIONS_REF=main
|
||||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||||
@@ -54,6 +87,29 @@ ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
|||||||
ARG PI_FORK_REF=master
|
ARG PI_FORK_REF=master
|
||||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||||
ARG PI_OBSMEM_REF=master
|
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.2
|
||||||
|
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||||
|
ARG PI_ATELIER_VERSION=v0.8.2
|
||||||
|
|
||||||
RUN set -e && \
|
RUN set -e && \
|
||||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||||
@@ -87,12 +143,14 @@ RUN set -e && \
|
|||||||
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
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_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_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-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||||
(cd /opt/pi-observational-memory && 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-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-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-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) ──
|
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
||||||
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
||||||
@@ -219,15 +277,28 @@ ARG SOURCE_REVISION=
|
|||||||
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||||
# only so its intended ref lands in the label set alongside the others.
|
# only so its intended ref lands in the label set alongside the others.
|
||||||
ARG MEMPALACE_TOOLKIT_REF=main
|
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}" \
|
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
||||||
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
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-version="${PI_VERSION}" \
|
||||||
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
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-extensions-ref="${PI_EXTENSIONS_REF}" \
|
||||||
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_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-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.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
||||||
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
|
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
|
||||||
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}"
|
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}"
|
||||||
@@ -241,6 +312,19 @@ RUN set -e; \
|
|||||||
mkdir -p /etc/pi-devbox; \
|
mkdir -p /etc/pi-devbox; \
|
||||||
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
||||||
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
||||||
|
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
|
||||||
|
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
|
||||||
|
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
|
||||||
|
# a palace bug could not be correlated to an image, which is precisely the
|
||||||
|
# correlation this file exists to provide. Read from the INSTALLED BINARY,
|
||||||
|
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
|
||||||
|
# what catches an install which resolved to something other than the pin.
|
||||||
|
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
|
||||||
|
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
|
||||||
|
# leading-digit test then rejects usage/error text (a renamed flag prints a
|
||||||
|
# usage block) and degrades to JSON null, so this can never fail the build.
|
||||||
|
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
|
||||||
|
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
|
||||||
STUDIO_REV='null'; \
|
STUDIO_REV='null'; \
|
||||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||||
{ \
|
{ \
|
||||||
@@ -249,11 +333,16 @@ RUN set -e; \
|
|||||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||||
echo " \"pi_version\": \"${PI_V}\","; \
|
echo " \"pi_version\": \"${PI_V}\","; \
|
||||||
|
# Sibling of pi_version, NOT a member of components{}: that map holds git
|
||||||
|
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
|
||||||
|
# silently truncate a longer version string.
|
||||||
|
echo " \"mempalace_version\": ${MP_CORE},"; \
|
||||||
echo " \"components\": {"; \
|
echo " \"components\": {"; \
|
||||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||||
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
||||||
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
||||||
|
echo " \"pi-atelier\": \"$(rev /opt/pi-atelier)\","; \
|
||||||
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
||||||
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
||||||
echo " }"; \
|
echo " }"; \
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ on the host.
|
|||||||
mempalace integration, etc.)
|
mempalace integration, etc.)
|
||||||
- `pi-fork` — the `fork` tool for spawning sub-agents
|
- `pi-fork` — the `fork` tool for spawning sub-agents
|
||||||
- `pi-observational-memory` — the `recall` tool for session compaction
|
- `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)
|
### MemPalace (AI memory)
|
||||||
|
|
||||||
@@ -68,9 +70,27 @@ so `TERM=xterm-kitty` is understood. Override either in your own
|
|||||||
### Document and image tooling
|
### Document and image tooling
|
||||||
|
|
||||||
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
||||||
|
- `typst` — markup-based typesetting, wired up as pandoc's `--pdf-engine` (see
|
||||||
|
[Generating a PDF with pandoc + typst](#generating-a-pdf-with-pandoc--typst))
|
||||||
- `graphviz` — `dot` rendering for diagram pipelines
|
- `graphviz` — `dot` rendering for diagram pipelines
|
||||||
- `imagemagick` — image conversion / resizing (invoked as `magick`)
|
- `imagemagick` — image conversion / resizing (invoked as `magick`)
|
||||||
|
|
||||||
|
### Browser automation
|
||||||
|
|
||||||
|
- `agent-browser` — CLI for driving a real headless browser: open pages,
|
||||||
|
click/fill/`eval`, snapshot the DOM, take screenshots. Useful whenever a task
|
||||||
|
involves a web UI or verifying how a page actually renders (live DOM, WebGL,
|
||||||
|
layout, popup positioning) instead of guessing from source.
|
||||||
|
- `playwright` + a pre-installed headless **Chromium** back it.
|
||||||
|
`AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser via a stable
|
||||||
|
`/usr/local/bin/agent-chrome` symlink (insulated from Playwright's
|
||||||
|
per-version/arch install directory), so `agent-browser open <url>` works
|
||||||
|
out of the box with no setup. Run `agent-browser skills get core --full`
|
||||||
|
for the command set and workflow patterns.
|
||||||
|
- `socat` — TCP bridge used by `studio-expose` to reach pi-studio's
|
||||||
|
loopback-bound server from outside the container (see
|
||||||
|
[Using pi-studio](#using-pi-studio--studio-variant))
|
||||||
|
|
||||||
### Language toolchains
|
### Language toolchains
|
||||||
|
|
||||||
- `python3` + `python3-venv` + `python3-pip` (system Python)
|
- `python3` + `python3-venv` + `python3-pip` (system Python)
|
||||||
@@ -338,6 +358,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.
|
`.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.
|
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
|
## docker-compose.yml — basic shape
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -492,7 +565,8 @@ directory, and they compose:
|
|||||||
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
|
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
|
||||||
external mount, survive volume recreate (the source is an image path, not a
|
external mount, survive volume recreate (the source is an image path, not a
|
||||||
home dir a named volume would shadow), and are created only when absent so a
|
home dir a named volume would shadow), and are created only when absent so a
|
||||||
same-named skillset skill or user override is never clobbered. The bundled
|
user override is never clobbered. Precedence against a mounted `skillset` repo
|
||||||
|
is per-skill, not blanket — see *Skillset repo* below. The bundled
|
||||||
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
|
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
|
||||||
the container's persistence model, host/LAN SSH reachability, split-DNS
|
the container's persistence model, host/LAN SSH reachability, split-DNS
|
||||||
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
||||||
@@ -503,8 +577,11 @@ directory, and they compose:
|
|||||||
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
||||||
fork/recall under-utilisation). That pointer would dangle in a container
|
fork/recall under-utilisation). That pointer would dangle in a container
|
||||||
started *without* the private `skillset` repo, so the image also bakes
|
started *without* the private `skillset` repo, so the image also bakes
|
||||||
fallback copies of **`pi-extensions`** and **`mempalace`**. They are
|
fallback copies of **`pi-extensions`** and **`mempalace`**. Whether a mounted
|
||||||
symlinked only when absent, so a mounted skillset always overrides them. The
|
skillset overrides them depends on who *owns* the skill (see *Skillset repo*):
|
||||||
|
`mempalace` is skillset-owned, so the live clone wins; `pi-extensions` is
|
||||||
|
owned by its package repo, so the baked copy keeps winning — the skillset's
|
||||||
|
copy of it is a downstream duplicate that can lag. The
|
||||||
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
|
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
|
||||||
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
|
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
|
||||||
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
|
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
|
||||||
@@ -518,7 +595,16 @@ directory, and they compose:
|
|||||||
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
|
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
|
||||||
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
|
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
|
||||||
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
|
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
|
||||||
classified as foreign-links by its `--prune-stale` pass and left untouched.
|
classified as foreign-links by its `--prune-stale` pass and left untouched —
|
||||||
|
which through v1.8.4 meant the baked copy *always* won, so an edit pushed to a
|
||||||
|
skillset-owned skill was invisible until the next image build. Since v1.8.5
|
||||||
|
`devbox-skill-reconcile` runs right after the deploy and repoints the links for
|
||||||
|
skills the skillset owns, listed in
|
||||||
|
`/usr/local/share/pi-devbox/skills/skillset-owned.txt` (today: `mempalace`).
|
||||||
|
Effective precedence, highest first: **user override** (a real directory, or a
|
||||||
|
symlink pointing outside the baked tree) → **live skillset clone** (owned names
|
||||||
|
only) → **baked snapshot** (everything else, and every skill when no skillset
|
||||||
|
is mounted). Check with `readlink -f ~/.agents/skills/<skill>`.
|
||||||
|
|
||||||
To make agents *proactively* load a baked skill at session start (rather than
|
To make agents *proactively* load a baked skill at session start (rather than
|
||||||
only on description match), the image appends a short, gated pointer to the
|
only on description match), the image appends a short, gated pointer to the
|
||||||
@@ -554,6 +640,35 @@ User-level overrides in `~/.ssh/config` win because Debian's
|
|||||||
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
||||||
the `Host *` block.
|
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`
|
### Per-host `ControlPath` on a read-only `~/.ssh`
|
||||||
|
|
||||||
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
|
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
|
||||||
@@ -573,7 +688,10 @@ this without editing the read-only config:
|
|||||||
jump via the host, add `ProxyJump host` overrides in the host-owned
|
jump via the host, add `ProxyJump host` overrides in the host-owned
|
||||||
`~/.config/devbox-shell/ssh-lan.conf` (see
|
`~/.config/devbox-shell/ssh-lan.conf` (see
|
||||||
[Naming LAN peers](#naming-lan-peers)) rather than the read-only
|
[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
|
## tmux and 0-indexed sessions
|
||||||
|
|
||||||
@@ -646,6 +764,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 |
|
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
|
||||||
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
|
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
|
||||||
| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.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 |
|
| `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant |
|
||||||
|
|
||||||
Each has a matching `*_REF` arg (branch name or commit SHA). Example — build
|
Each has a matching `*_REF` arg (branch name or commit SHA). Example — build
|
||||||
@@ -686,13 +805,42 @@ docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq
|
|||||||
`org.opencontainers.image.{version,revision,created}` plus
|
`org.opencontainers.image.{version,revision,created}` plus
|
||||||
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
||||||
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
|
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
|
||||||
truth** — the actual checked-out commit of each `/opt` clone and the live
|
truth** — the actual checked-out commit of each `/opt` clone, the live
|
||||||
`pi --version` — so a tag is reconstructable after CI logs rotate:
|
`pi --version`, and (from v1.8.6) the live `mempalace --version` of the
|
||||||
|
installed palace core — so a tag is reconstructable after CI logs rotate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Inside a running container, `pi-devbox-version` wraps that manifest into a
|
||||||
|
human-readable summary — no need to remember the file path or pipe it
|
||||||
|
through `jq` yourself:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ pi-devbox-version
|
||||||
|
pi-devbox v1.5.0
|
||||||
|
built: 2026-07-13T17:53:16Z (source d68674d11e06)
|
||||||
|
pi: 0.80.6
|
||||||
|
components:
|
||||||
|
pi-toolkit: 9a8f6faeaa08
|
||||||
|
pi-extensions: 61c98e004e3d
|
||||||
|
pi-fork: 4a09af4ef527
|
||||||
|
pi-observational-memory: 27a5195eaf90
|
||||||
|
mempalace-toolkit: 96699f2a1781
|
||||||
|
pi-studio: 2ef38ef31cea
|
||||||
|
```
|
||||||
|
|
||||||
|
It also flags **live drift** — if `pi --version` no longer matches what was
|
||||||
|
baked at build time (e.g. something on a persisted volume shadowed the
|
||||||
|
image's binary), the `pi:` line calls that out instead of silently trusting
|
||||||
|
the manifest. `--json` dumps the raw manifest for scripting; `--quiet` gives
|
||||||
|
a one-line `release_tag (source_revision)` form. It also prints once,
|
||||||
|
automatically, at container start (from `entrypoint-user.sh`, before the
|
||||||
|
rest of the setup output) — so you see which build you're in without
|
||||||
|
asking. Exits 1 with a short notice on images built before this file
|
||||||
|
existed, rather than failing silently.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Image grew unexpectedly
|
### Image grew unexpectedly
|
||||||
@@ -722,14 +870,109 @@ Host pve pve-2 alpserv-2 lagret
|
|||||||
ProxyJump host
|
ProxyJump host
|
||||||
```
|
```
|
||||||
|
|
||||||
`HostName` / `User` / `IdentityFile` are inherited from the matching block in
|
Any option can be set here, not just `ProxyJump`: the file is `Include`d
|
||||||
your real `~/.ssh/config` (first-value-wins, so only `ProxyJump` is taken from
|
*before* `~/.ssh/config` and ssh takes the **first** value it sees for each
|
||||||
here). This file is `Include`d *before* `~/.ssh/config` and read fresh on every
|
option, so whatever you put here wins while everything you omit is inherited
|
||||||
connection — newly added peers work immediately, no container or session
|
from the matching block in your real `~/.ssh/config`. Peer names stay out of the
|
||||||
restart needed — and the peer names stay out of the published image (they're a
|
published image (they are a fact about your LAN, not the image). Alternatively,
|
||||||
fact about your specific LAN, not the image). Alternatively, set
|
set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through
|
||||||
`DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the
|
the host without naming peers (see `.env.example`).
|
||||||
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
|
### Smoke-testing a local build
|
||||||
|
|
||||||
@@ -766,9 +1009,72 @@ pi-devbox follows semver-ish:
|
|||||||
- **Minor** — new variants, significant base additions.
|
- **Minor** — new variants, significant base additions.
|
||||||
- **Patch** — pi version bumps, smaller fixes.
|
- **Patch** — pi version bumps, smaller fixes.
|
||||||
|
|
||||||
The `pi --version` inside the image is asserted by smoke tests to
|
The `pi --version` inside the image is asserted by smoke tests to match the
|
||||||
match the release tag's pi component, so version drift between the
|
version CI resolved (since v1.7.0, the pin below), so drift between what was
|
||||||
image and the tag is caught at CI time.
|
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.2` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||||
|
| mempalace | `3.7.1` | `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
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,23 @@ for OS packages, the per-package copyright files inside the image at
|
|||||||
| pi-fork | github.com/elpapi42/pi-fork | MIT |
|
| pi-fork | github.com/elpapi42/pi-fork | MIT |
|
||||||
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
||||||
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
|
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
|
||||||
|
| pi-atelier | github.com/michaelmjhhhh/pi-atelier | MIT |
|
||||||
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
|
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
|
||||||
|
|
||||||
|
## MemPalace (AI memory)
|
||||||
|
|
||||||
|
| Component | Upstream | License |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| mempalace (core, MCP server) | github.com/MemPalace/mempalace (PyPI: `mempalace`) | MIT — the GitHub repo declares MIT; the PyPI package's own metadata omits a license classifier, so if you need clearance from the package artifact alone, verify against the repo's `LICENSE` file rather than the sdist/wheel metadata |
|
||||||
|
|
||||||
|
## Browser automation
|
||||||
|
|
||||||
|
| Component | Upstream | License |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| agent-browser | github.com/vercel-labs/agent-browser (npm: `agent-browser`) | Apache-2.0 |
|
||||||
|
| Playwright | github.com/microsoft/playwright (npm: `playwright`) | Apache-2.0 |
|
||||||
|
| Chromium | chromium.googlesource.com/chromium/src | BSD-3-Clause for Chromium's own code, plus a large set of bundled third-party components each under their own license (see Chromium's own `LICENSE`/`about:credits`). The binary in this image is **not compiled here** — it is the build Playwright downloads for its pinned version ("Chrome for Testing"), installed via `playwright install --with-deps chromium` at `/usr/local/share/ms-playwright/`. Treat Playwright's own distribution terms for that build as authoritative over any summary here. |
|
||||||
|
|
||||||
## Tooling baked into the base image
|
## Tooling baked into the base image
|
||||||
|
|
||||||
| Component | Upstream | License (best effort) |
|
| Component | Upstream | License (best effort) |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# Point every client at it by setting, in that client's .env:
|
# Point every client at it by setting, in that client's .env:
|
||||||
#
|
#
|
||||||
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
# 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
|
# (see .env.example). When set, the client connects over HTTP and does NOT
|
||||||
# spawn its own local mempalace-mcp.
|
# spawn its own local mempalace-mcp.
|
||||||
@@ -18,12 +19,21 @@
|
|||||||
# (both are pinned by the same image build). Override with a slimmer image via
|
# (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`).
|
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
|
||||||
#
|
#
|
||||||
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
|
# ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
|
||||||
# Do NOT expose port 8765 to an untrusted network. The default below binds to
|
# earlier version of this comment said otherwise and was wrong. The server
|
||||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
|
# compares `Authorization: Bearer <token>` with hmac.compare_digest and
|
||||||
# attach them to the shared `mempalace-net` network (container-to-container, no
|
# **refuses to start on a non-loopback bind without a token**, so
|
||||||
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
|
# MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
|
||||||
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
|
# 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
|
name: mempalace-server
|
||||||
|
|
||||||
@@ -40,6 +50,11 @@ services:
|
|||||||
user: "0:0"
|
user: "0:0"
|
||||||
environment:
|
environment:
|
||||||
- HOME=/data
|
- 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:
|
command:
|
||||||
- mempalace-mcp
|
- mempalace-mcp
|
||||||
- --transport
|
- --transport
|
||||||
@@ -60,16 +75,28 @@ services:
|
|||||||
- mempalace-shared:/data/.mempalace
|
- mempalace-shared:/data/.mempalace
|
||||||
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
||||||
- mempalace-shared-chroma:/data/.cache/chroma
|
- 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:
|
networks:
|
||||||
- mempalace-net
|
- mempalace-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# A tools/list round-trip proves the server is answering MCP (python3 is
|
# GET /healthz, which is Host/Origin-gated but deliberately token-free —
|
||||||
# always present — mempalace itself is a python tool in the image).
|
# 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:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- python3
|
- python3
|
||||||
- -c
|
- -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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
+199
-13
@@ -1,6 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Startup banner: which pi-devbox build is this? ─────────────────
|
||||||
|
# Printed FIRST, before the setup noise below, so it's the first thing
|
||||||
|
# visible when the container starts (CMD is `bash -l`, tty:true in compose,
|
||||||
|
# so this reaches the same stream as the interactive shell the user lands
|
||||||
|
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
|
||||||
|
# with a short stderr notice on images built before it existed.
|
||||||
|
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version || true
|
||||||
|
|
||||||
# ── SSH ControlMaster socket dir ────────────────────────────────
|
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||||
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
||||||
@@ -50,10 +58,17 @@ fi
|
|||||||
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
||||||
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
||||||
# anything baked under a home dir, which a named volume would shadow). Created
|
# anything baked under a home dir, which a named volume would shadow). Created
|
||||||
# only when absent, so a same-named skillset skill (deployed later, at the end
|
# only when absent, so a user override is never clobbered.
|
||||||
# of this script) or a user override is never clobbered; the skillset deploy
|
#
|
||||||
# classifies these as foreign-links and its --prune-stale pass leaves them
|
# NB: "created only when absent" does NOT hand a same-named skillset skill
|
||||||
# alone (only dangling symlinks are pruned).
|
# priority — the opposite. The skillset deploy runs at the end of this script
|
||||||
|
# and classifies these links as foreign, so through v1.8.4 the BAKED copy
|
||||||
|
# always won and an edit pushed to a skillset-owned skill was invisible until
|
||||||
|
# the next image build. The links below are therefore the FALLBACK only;
|
||||||
|
# devbox-skill-reconcile (invoked right after the skillset deploy) hands the
|
||||||
|
# skillset-OWNED skills back to the live clone. Ownership is per-skill, listed
|
||||||
|
# in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must
|
||||||
|
# keep losing to the baked copy.
|
||||||
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
||||||
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
||||||
mkdir -p "$HOME/.agents/skills"
|
mkdir -p "$HOME/.agents/skills"
|
||||||
@@ -61,7 +76,16 @@ if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
|||||||
[ -d "$_sk" ] || continue
|
[ -d "$_sk" ] || continue
|
||||||
_skname=$(basename "$_sk")
|
_skname=$(basename "$_sk")
|
||||||
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
||||||
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
# -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the
|
||||||
|
# link), and since v1.8.5 these links can point into /workspace/skillset
|
||||||
|
# (see devbox-skill-reconcile, invoked after the skillset deploy). If that
|
||||||
|
# mount vanishes while the writable layer survives — a `docker restart` or
|
||||||
|
# a host reboot under restart: unless-stopped, as opposed to a recreate —
|
||||||
|
# plain `ln -s` fails with "File exists" and, under `set -e`, aborts
|
||||||
|
# container start before `exec "$@"`. With -f the broken link heals back to
|
||||||
|
# the baked fallback, and the reconciler re-points it in the same boot if
|
||||||
|
# the clone is back.
|
||||||
|
ln -sfn "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -84,6 +108,82 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
|||||||
fi
|
fi
|
||||||
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 ──────────────────────────────────────────────
|
# ── Git config defaults ──────────────────────────────────────────────
|
||||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||||
git config --global user.name "$GIT_USER_NAME"
|
git config --global user.name "$GIT_USER_NAME"
|
||||||
@@ -161,22 +261,101 @@ if command -v pi &>/dev/null; then
|
|||||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) + (in the
|
# pi-fork (fork tool) + pi-observational-memory (recall tool) + pi-atelier
|
||||||
# :latest-studio variant only) pi-studio (/studio command + studio_*
|
# (TUI sidebar panels/split-pane) + (in the :latest-studio variant only)
|
||||||
# tools + theme). These are pi packages (not symlink-style extensions):
|
# 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
|
# 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
|
# registered here via `pi install <local-path>`. A local-path install is
|
||||||
# instant + in-place (pi loads the extension directly from /opt) +
|
# instant + in-place (pi loads the extension directly from /opt) +
|
||||||
# idempotent (no duplicate package entry on re-run), and stores a relative
|
# idempotent (no duplicate package entry on re-run), and stores a relative
|
||||||
# path that resolves into the image-layer /opt so it survives volume
|
# path that resolves into the image-layer /opt so it survives volume
|
||||||
# recreate. The tools/command register on the NEXT pi start (extensions
|
# recreate. The tools/command register on the NEXT pi start (extensions
|
||||||
# bind at startup). Guard on settings.json so we only install once per
|
# bind at startup) or on `/reload`. Guard on settings.json so we only
|
||||||
# volume. /opt/pi-studio is present only in the studio variant; the
|
# install once per volume. /opt/pi-studio is present only in the studio
|
||||||
# `[ -d ]` test makes this a no-op everywhere else.
|
# variant; the `[ -d ]` test makes this a no-op everywhere else.
|
||||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do
|
#
|
||||||
|
# The guard MUST inspect the `packages` ARRAY, not merely grep the whole
|
||||||
|
# file for the package name. settings.example.json ships a top-level
|
||||||
|
# "pi-fork" CONFIG block (the fork effort profiles, pi-toolkit adb6907,
|
||||||
|
# 2026-06-17), so a whole-file substring grep matches on any settings.json
|
||||||
|
# that was bootstrapped from — or template-merged with — that template.
|
||||||
|
# Worse, the merge above runs FIRST, so it plants the matching string in the
|
||||||
|
# same startup that the loop then reads: `pi install /opt/pi-fork` was
|
||||||
|
# skipped forever and the `fork` tool never registered (v1.0.0 → v1.6.3).
|
||||||
|
# Its siblings escaped only by luck — the template key is
|
||||||
|
# "observational-memory" (no pi- prefix) and there is no studio block.
|
||||||
|
# jq reads the array; the grep fallback matches the stored relative-path
|
||||||
|
# form ("…/opt/<name>\""), which a config KEY can never produce.
|
||||||
|
_pi_pkg_registered() {
|
||||||
|
_pi_reg_settings="$HOME/.pi/agent/settings.json"
|
||||||
|
[ -f "$_pi_reg_settings" ] || return 1
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
jq -e --arg n "$1" \
|
||||||
|
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||||
|
"$_pi_reg_settings" >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
grep -q "opt/$1\"" "$_pi_reg_settings"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 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
|
[ -d "$_pkg" ] || continue
|
||||||
_name=$(basename "$_pkg")
|
_name=$(basename "$_pkg")
|
||||||
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
# 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 || \
|
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||||
echo "WARN: pi install $_name failed (continuing)"
|
echo "WARN: pi install $_name failed (continuing)"
|
||||||
fi
|
fi
|
||||||
@@ -222,6 +401,13 @@ elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
|||||||
fi
|
fi
|
||||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||||
|
# The deploy leaves the early baked links (above) in place as foreign links,
|
||||||
|
# which silently shadows the live clone for skills the skillset OWNS. Repoint
|
||||||
|
# just those; baked stays the fallback, user overrides still win. `|| true`:
|
||||||
|
# a skill-link refinement must never break container start.
|
||||||
|
if command -v devbox-skill-reconcile >/dev/null 2>&1; then
|
||||||
|
devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Execute command ──────────────────────────────────────────────────
|
# ── Execute command ──────────────────────────────────────────────────
|
||||||
|
|||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# devbox-skill-reconcile — hand skillset-OWNED skills back to the live clone.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# entrypoint-user.sh links the image-baked skills into ~/.agents/skills/ EARLY
|
||||||
|
# (before pi-deploy), because the smoke readiness probe gates on markers that
|
||||||
|
# only land later, and a link created after that gate produced a flaky
|
||||||
|
# assertion. Those links are created with a `[ ! -e ]` guard — "only when
|
||||||
|
# absent" — and the skillset deploy runs LAST, treating already-present links
|
||||||
|
# as foreign and leaving them alone. Net effect through v1.8.4: the baked copy
|
||||||
|
# always won, so an edit pushed to a skillset-owned skill was invisible in
|
||||||
|
# every container until the next image build (measured on two hosts: live
|
||||||
|
# skillset md5 129bcc4752 vs baked 5236024fef, the new section absent).
|
||||||
|
#
|
||||||
|
# The fix is NOT "the skillset always wins". Ownership is per-skill (see
|
||||||
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md):
|
||||||
|
#
|
||||||
|
# pi-devbox-environment authored in pi-devbox → baked IS canonical
|
||||||
|
# pi-extensions owned by the package repo, copied over the snapshot
|
||||||
|
# at build time; skillset carries a DOWNSTREAM copy
|
||||||
|
# that can lag → baked must keep winning
|
||||||
|
# mempalace owned by the skillset repo; baked is a snapshot
|
||||||
|
# fallback for containers with no skillset mounted
|
||||||
|
# → the live clone must win when it is present
|
||||||
|
#
|
||||||
|
# So only skills listed in skills/skillset-owned.txt are handed over. Baked
|
||||||
|
# links stay as the fallback (the early-link race fix is untouched), and a user
|
||||||
|
# override always beats both: a real directory is never replaced, and neither is
|
||||||
|
# a symlink that already points somewhere other than the baked tree.
|
||||||
|
#
|
||||||
|
# Usage: devbox-skill-reconcile <skillset-root> [skills-dir] [baked-src]
|
||||||
|
# skillset-root the mounted skillset repo (contains skills/<name>/)
|
||||||
|
# skills-dir default $HOME/.agents/skills
|
||||||
|
# baked-src default /usr/local/share/pi-devbox/skills
|
||||||
|
#
|
||||||
|
# Idempotent, and silent unless it changes something. Exits 0 when there is
|
||||||
|
# nothing to do (no skillset, no list) so the entrypoint never fails on it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SKILLSET_ROOT="${1:-}"
|
||||||
|
SKILLS_DIR="${2:-$HOME/.agents/skills}"
|
||||||
|
BAKED_SRC="${3:-/usr/local/share/pi-devbox/skills}"
|
||||||
|
BAKED_SRC="${BAKED_SRC%/}" # a trailing slash would make the prefix
|
||||||
|
# match below ("$BAKED_SRC"/*) match nothing
|
||||||
|
|
||||||
|
[ -n "$SKILLSET_ROOT" ] || exit 0
|
||||||
|
[ -d "$SKILLSET_ROOT/skills" ] || exit 0
|
||||||
|
[ -d "$SKILLS_DIR" ] || exit 0
|
||||||
|
|
||||||
|
# Absolutise BOTH roots before they are used, because each has its own way of
|
||||||
|
# failing silently when relative: a relative symlink TARGET is resolved against
|
||||||
|
# the link's directory (~/.agents/skills), not $PWD, so it would dangle on
|
||||||
|
# creation; and a relative BAKED_SRC would never prefix-match the absolute
|
||||||
|
# target that `readlink` reports, so every skill would be skipped and the fix
|
||||||
|
# would look like it had simply done nothing.
|
||||||
|
SKILLSET_ROOT=$(CDPATH= cd -- "$SKILLSET_ROOT" 2>/dev/null && pwd) || exit 0
|
||||||
|
BAKED_SRC=$(CDPATH= cd -- "$BAKED_SRC" 2>/dev/null && pwd) || exit 0
|
||||||
|
OWNED_LIST="$BAKED_SRC/skillset-owned.txt"
|
||||||
|
[ -f "$OWNED_LIST" ] || exit 0
|
||||||
|
|
||||||
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
||||||
|
# strip comments and surrounding whitespace; skip blanks
|
||||||
|
_name=$(printf '%s\n' "$_line" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||||
|
[ -n "$_name" ] || continue
|
||||||
|
# defensive: a list entry must be a plain skill name, never a path
|
||||||
|
case "$_name" in */*|.*) continue ;; esac
|
||||||
|
|
||||||
|
_live="$SKILLSET_ROOT/skills/$_name"
|
||||||
|
_link="$SKILLS_DIR/$_name"
|
||||||
|
|
||||||
|
# the skillset does not ship it → the baked fallback is all there is
|
||||||
|
[ -d "$_live" ] || continue
|
||||||
|
# a real directory is a user override → never touch
|
||||||
|
[ -L "$_link" ] || continue
|
||||||
|
|
||||||
|
# only ever replace OUR OWN link. readlink is deliberate: `readlink -f`
|
||||||
|
# would resolve a link that already points into the skillset clone and,
|
||||||
|
# since both trees hold a same-named skill, could not tell them apart.
|
||||||
|
_target=$(readlink "$_link" 2>/dev/null || true)
|
||||||
|
case "$_target" in
|
||||||
|
"$BAKED_SRC"/*|"$BAKED_SRC") ;; # baked link → ours to replace
|
||||||
|
*) continue ;; # user/foreign target → leave alone
|
||||||
|
esac
|
||||||
|
|
||||||
|
# -n so an existing symlink-to-directory is replaced rather than followed
|
||||||
|
# (without it, ln would create $_link/$_name inside the baked tree).
|
||||||
|
if ln -sfn "$_live" "$_link" 2>/dev/null; then
|
||||||
|
printf 'skill %s: baked snapshot -> live skillset (%s)\n' "$_name" "$_live"
|
||||||
|
fi
|
||||||
|
done < "$OWNED_LIST"
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pi-devbox-version — show which pi-devbox image build is running.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# The image bakes ground-truth build info into /etc/pi-devbox/build-manifest.json
|
||||||
|
# at `docker build` time (see Dockerfile.variant): the release tag, build date,
|
||||||
|
# source commit, live `pi --version` at build time, and the actual checked-out
|
||||||
|
# commit of every /opt component clone. That answers "what image am I running?"
|
||||||
|
# — but only if you know to go look for the file. This wraps it into one
|
||||||
|
# command, prints it human-first at container start (see entrypoint-user.sh),
|
||||||
|
# and stays available on demand for the rest of the session.
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# pi-devbox-version human-readable summary (default)
|
||||||
|
# pi-devbox-version --json raw manifest JSON (for scripting)
|
||||||
|
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
|
||||||
|
#
|
||||||
|
# EXIT STATUS
|
||||||
|
# 0 on success. 1 if the manifest is missing (e.g. an image built before
|
||||||
|
# this file existed, or a non-pi-devbox base) — prints a short notice
|
||||||
|
# to stderr rather than failing silently.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST=/etc/pi-devbox/build-manifest.json
|
||||||
|
MODE="human"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--json) MODE="json" ;;
|
||||||
|
--quiet|-q) MODE="quiet" ;;
|
||||||
|
--help|-h)
|
||||||
|
sed -n '2,20p' "$0" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
|
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
|
||||||
|
echo " (image predates the manifest, or this isn't a pi-devbox image)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "pi-devbox-version: jq not found; dumping raw manifest instead" >&2
|
||||||
|
cat "$MANIFEST"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$MODE" = "json" ]; then
|
||||||
|
cat "$MANIFEST"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_tag=$(jq -r '.release_tag' "$MANIFEST")
|
||||||
|
build_date=$(jq -r '.build_date' "$MANIFEST")
|
||||||
|
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
||||||
|
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
|
||||||
|
# `// empty` matters: images built before v1.8.6 have no such field, and
|
||||||
|
# `jq -r` renders a JSON null as the 4-char string "null" — which would
|
||||||
|
# print as a bogus version rather than being treated as absent.
|
||||||
|
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
|
||||||
|
|
||||||
|
if [ "$MODE" = "quiet" ]; then
|
||||||
|
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Live drift check: has `pi` been upgraded since this container was built?
|
||||||
|
# (image is immutable, but a volume-persisted ~/.pi could in theory shadow
|
||||||
|
# the baked binary — this stays honest rather than trusting the manifest
|
||||||
|
# blindly, same "ground truth over intent" spirit as how the manifest
|
||||||
|
# itself is generated in Dockerfile.variant.)
|
||||||
|
pi_version_live=""
|
||||||
|
if command -v pi >/dev/null 2>&1; then
|
||||||
|
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same check for the palace, which matters more than it looks: mempalace is
|
||||||
|
# the one component that is BOTH client (here) and server (synlig runs this
|
||||||
|
# same image), so a skew between the two is a real failure mode rather than
|
||||||
|
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
|
||||||
|
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
|
||||||
|
mp_version_live=""
|
||||||
|
if command -v mempalace >/dev/null 2>&1; then
|
||||||
|
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'pi-devbox %s\n' "$release_tag"
|
||||||
|
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
||||||
|
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
|
||||||
|
printf ' pi: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$pi_version_live" "$pi_version_baked"
|
||||||
|
else
|
||||||
|
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
|
||||||
|
# instead of showing an empty or "null" palace line.
|
||||||
|
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
|
||||||
|
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
|
||||||
|
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
|
||||||
|
else
|
||||||
|
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' components:\n'
|
||||||
|
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
||||||
@@ -19,6 +19,18 @@ be discovered at runtime, never assumed. And interactive shell aliases
|
|||||||
tool, so spell out the underlying command (e.g.
|
tool, so spell out the underlying command (e.g.
|
||||||
`ssh -F "$HOME/.ssh-local/config" mac …`).
|
`ssh -F "$HOME/.ssh-local/config" mac …`).
|
||||||
|
|
||||||
|
## Browser automation is available (agent-browser)
|
||||||
|
|
||||||
|
This image bakes the **`agent-browser`** CLI plus a headless Chromium, so you can
|
||||||
|
drive a real browser — open pages, click/fill/`eval`, snapshot the DOM, take
|
||||||
|
screenshots — to **verify** front-end work (live DOM, WebGL, layout, popup
|
||||||
|
positioning) instead of guessing. Reach for it whenever a task involves a web UI
|
||||||
|
or checking how a page actually renders. `AGENT_BROWSER_EXECUTABLE_PATH` is
|
||||||
|
preset to the baked browser, so `agent-browser open <url>` works out of the box
|
||||||
|
(headless). Run `agent-browser skills get core --full` for the command set and
|
||||||
|
workflow patterns (always version-matched to the CLI); the `agent-browser` skill
|
||||||
|
under `~/.agents/skills/` mirrors it when the skillset is mounted.
|
||||||
|
|
||||||
## Session start: load the mempalace skill
|
## Session start: load the mempalace skill
|
||||||
|
|
||||||
If MemPalace MCP tools (e.g. `mempalace_search`, `mempalace_diary_write`) are in
|
If MemPalace MCP tools (e.g. `mempalace_search`, `mempalace_diary_write`) are in
|
||||||
@@ -29,3 +41,32 @@ especially load-bearing here — a pi-devbox container is frequently recreated,
|
|||||||
the palace is your only memory across recreates. Without the habit it is just
|
the palace is your only memory across recreates. Without the habit it is just
|
||||||
storage, not memory. (The skill is the consumer side; feeding the palace is the
|
storage, not memory. (The skill is the consumer side; feeding the palace is the
|
||||||
separate `opencode-mempalace-bridge` skill, if present.)
|
separate `opencode-mempalace-bridge` skill, if present.)
|
||||||
|
|
||||||
|
### If the palace is central, it is shared — three rules
|
||||||
|
|
||||||
|
If `MEMPALACE_REMOTE_URL` is set, the MCP tools write to a **central palace
|
||||||
|
shared with other machines**, not to a local one. Your drawers are not the only
|
||||||
|
ones in there, and most drawers' `source_file` paths do not exist on this host.
|
||||||
|
The skill covers the orientation side (provenance, chronology, whose diary is
|
||||||
|
whose); these three are here instead because getting them wrong does *damage*
|
||||||
|
rather than merely confusing you:
|
||||||
|
|
||||||
|
- **Never run `mempalace sync` / `mempalace_sync` against a shared palace.** It
|
||||||
|
prunes drawers whose source files look gitignored, deleted, or moved — and on
|
||||||
|
a shared palace that describes most of the content, including every other
|
||||||
|
machine's. Compounding it (RFC-001 §7.2): feeders now stage *inside* the
|
||||||
|
palace root, so a scoped sync can delete the very drawers it just filed.
|
||||||
|
`mempalace_delete_by_source` is exact-match rather than existence-based, but
|
||||||
|
its blast radius is now the whole fleet's palace — leave it on its default
|
||||||
|
`dry_run=true` and confirm the match count before committing.
|
||||||
|
- **A timeout is not a failure.** The palace is single-writer, and one large
|
||||||
|
mine can block every client for minutes, so a write or mine that exceeds the
|
||||||
|
client's deadline has usually *completed* server-side. Verify with
|
||||||
|
`mempalace_get_drawer` or `mempalace_search` before retrying — a blind retry
|
||||||
|
files a duplicate. `[mempalace ext] feed (tick) failed: mine timed out after
|
||||||
|
30000ms` is the common benign instance: the transcript is already in the
|
||||||
|
server's inbox and the mine is idempotent, so nothing is lost either way.
|
||||||
|
- **The `mempalace` CLI is not remote-aware.** It always opens a palace on
|
||||||
|
local disk, so `mempalace search` can return older and different results than
|
||||||
|
the MCP tools while both look correct. Use the MCP tools for the central
|
||||||
|
palace; the CLI only for a local one.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Vendored fallback skills
|
# Vendored fallback skills
|
||||||
|
|
||||||
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
||||||
symlinks into `~/.agents/skills/` on container start (only when a skill of the
|
symlinks into `~/.agents/skills/` on container start. They are the **fallback**
|
||||||
same name is not already present, so a mounted `skillset` repo or a user
|
layer: see *Runtime precedence* below for which copy actually wins when a
|
||||||
override always wins).
|
`skillset` repo is mounted (through v1.8.4 the answer was "always the baked
|
||||||
|
one", which was a bug).
|
||||||
|
|
||||||
| skill | owner | how it gets here |
|
| skill | owner | how it gets here |
|
||||||
|-------|-------|------------------|
|
|-------|-------|------------------|
|
||||||
@@ -38,10 +39,50 @@ its skill file needed baking.
|
|||||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||||
package source to copy from. This snapshot is refreshed manually per release.
|
package source to copy from. This snapshot is refreshed manually per release.
|
||||||
|
|
||||||
|
## Runtime precedence (v1.8.5+)
|
||||||
|
|
||||||
|
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
|
||||||
|
to close a smoke readiness race) with a create-only-when-absent guard, and the
|
||||||
|
skillset deploy runs **last** and treats them as foreign links. Through v1.8.4
|
||||||
|
that combination meant the baked snapshot always won: an edit pushed to
|
||||||
|
`skillset/skills/mempalace/SKILL.md` was invisible in every container until the
|
||||||
|
next image build (measured on two hosts — live `md5 129bcc4752` vs baked
|
||||||
|
`5236024fef`, new section absent). Editing those skills *appeared* to work.
|
||||||
|
|
||||||
|
`devbox-skill-reconcile` now runs immediately after the skillset deploy and
|
||||||
|
repoints the links for skills the **skillset owns**, listed one per line in
|
||||||
|
`skillset-owned.txt`. Precedence, highest first:
|
||||||
|
|
||||||
|
1. **user override** — a real directory, or a symlink pointing outside the baked
|
||||||
|
tree; never touched by anything
|
||||||
|
2. **live skillset clone** — but only for names in `skillset-owned.txt`
|
||||||
|
3. **baked snapshot** — everything else, and every skill when no skillset is
|
||||||
|
mounted
|
||||||
|
|
||||||
|
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
|
||||||
|
package repo (copied over the snapshot at build), and `skillset` carries a
|
||||||
|
downstream copy that can lag, so handing it to the clone would *regress* the
|
||||||
|
skill. Only `mempalace` is skillset-owned today.
|
||||||
|
|
||||||
|
Verify with `readlink -f ~/.agents/skills/<skill>` — not by reading the
|
||||||
|
entrypoint. Smoke covers both directions (baked resolution with no skillset
|
||||||
|
mounted, plus a fabricated-skillset run of the reconciler).
|
||||||
|
|
||||||
## Refreshing the snapshots
|
## Refreshing the snapshots
|
||||||
|
|
||||||
cp <skillset>/skills/pi-extensions/SKILL.md pi-extensions/SKILL.md
|
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
||||||
cp <skillset>/skills/pi-extensions/evaluate-extension-usage.py pi-extensions/
|
cp <pi-extensions-pkg>/skill/evaluate-extension-usage.py pi-extensions/
|
||||||
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
|
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
|
||||||
|
|
||||||
Snapshot provenance at last refresh: skillset `8e8db64`, pi-extensions pkg `a7f3044`.
|
Copy each snapshot **from its owner in the table above** — `pi-extensions` from
|
||||||
|
the package repo's `skill/` (since `a7f3044` co-located it there; `skillset`
|
||||||
|
also carries a copy, but it is a downstream duplicate and can lag), and
|
||||||
|
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
|
||||||
|
regress the snapshot to whatever that repo last mirrored.
|
||||||
|
|
||||||
|
Snapshot provenance at last refresh: skillset `670f7f1`, pi-extensions pkg `e73cb9f`.
|
||||||
|
|
||||||
|
When you refresh the `mempalace` snapshot, also update the phrase asserted by
|
||||||
|
the "mempalace skill snapshot is current" smoke test — it deliberately pins the
|
||||||
|
**newest** section, because the previous canary grepped a phrase that survived
|
||||||
|
the very edit that made the snapshot stale, and so passed on stale content.
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ Before describing *when* something happened — "yesterday", "earlier today",
|
|||||||
compute the delta against the actual timestamp.** Get "now" from the injected
|
compute the delta against the actual timestamp.** Get "now" from the injected
|
||||||
session date or by running `date` in a shell; never infer it.
|
session date or by running `date` in a shell; never infer it.
|
||||||
|
|
||||||
**A container recreate or a fresh session is NOT a day boundary.** A pi-devbox
|
**A container recreate or a fresh session is NOT a day boundary.** A devbox
|
||||||
container is frequently restarted — often several times within the *same* day —
|
container (pi-devbox or opencode-devbox) is frequently restarted — often several
|
||||||
and each restart begins a new session with a fresh wake-up. Do not reason "new
|
times within the *same* day — and each restart begins a new session with a fresh
|
||||||
session ⇒ last session was yesterday": two diary entries 90 minutes apart can
|
wake-up. Do not reason "new session ⇒ last session was yesterday": two diary
|
||||||
straddle a container recreate. The only authoritative clock is the timestamp on
|
entries 90 minutes apart can straddle a container recreate. The only
|
||||||
the memory, not the session/container boundary.
|
authoritative clock is the timestamp on the memory, not the session/container
|
||||||
|
boundary.
|
||||||
|
|
||||||
**Practical rule:** prefer explicit, checkable phrasing — e.g. "earlier today,
|
**Practical rule:** prefer explicit, checkable phrasing — e.g. "earlier today,
|
||||||
~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself
|
~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself
|
||||||
@@ -274,18 +275,30 @@ Wings are top-level categories, typically one per project or domain:
|
|||||||
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
|
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
|
||||||
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
|
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
|
||||||
|
|
||||||
#### Multi-harness palace
|
#### Shared palace: multiple harnesses, and possibly multiple machines
|
||||||
|
|
||||||
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
|
A single palace can be fed by multiple coding-agent harnesses, and — when
|
||||||
|
`MEMPALACE_REMOTE_URL` points at a central palace — by multiple *machines*. On
|
||||||
|
this machine the palace is shared between **opencode** and **pi** (Mario
|
||||||
|
Zechner's pi-coding-agent). Implications:
|
||||||
|
|
||||||
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
|
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
|
||||||
- `pi_<uuid>.jsonl` → pi session
|
- `pi_<uuid>.jsonl` → pi session
|
||||||
- `<slug>_ses_<id>.jsonl` → opencode session
|
- `<slug>_ses_<id>.jsonl` → opencode session
|
||||||
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
|
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
|
||||||
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
|
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
|
||||||
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00. Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
|
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00 (launchd `Weekday`: `0`/`7`=Sunday, `1`=Monday, `2`=Tuesday — misreading this by one day is easy). Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
|
||||||
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
|
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
|
||||||
|
|
||||||
|
When the palace is **central** (shared across machines), five more things apply:
|
||||||
|
|
||||||
|
- **Check which machine a conversation came from.** Transcripts are fed per device, so `source_path` reads `…/mempalace-feed/<device>/pi_<uuid>.jsonl` while the displayed `source_file` is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to *this* project.
|
||||||
|
- **Attribute what you file yourself.** Drawers now carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). Mined content gets these for free — the inbox path gives the device, the filename shape gives the harness — and a timer on the palace host re-stamps hourly, because live re-mining replaces metadata rows and silently drops earlier stamps. But for anything **you** file by hand, the only signal is what you pass: set `added_by="<harness>@<device>"` (e.g. `pi@emb-7kj4vr4g`, from `$MEMPALACE_PI_DEVICE`) on `add_drawer`/`checkpoint`/`mine`. Skip it and your drawer joins the ~16k historic `/workspace` project mines that are permanently unattributable, because `/workspace` exists identically on every devbox. Note the palace preserves `source_file` in full (see `source_path`) but *displays* only the basename — so a device prefix there survives storage even though it looks stripped.
|
||||||
|
- **Mined drawers carry the MINE date, not the session date.** When history is imported, or re-mined on the palace host, `filed_at`/`created_at` is the *import* time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in `pi_<uuid>.jsonl`: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those `filed_at` is the only chronology, which is why it must never be restamped.
|
||||||
|
- **Beware the timezone mismatch when you combine those.** Palace `filed_at`/`created_at` are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
|
||||||
|
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history.
|
||||||
|
- **One writer, no queue.** A concurrent mine returns a structured `already-running` error rather than waiting its turn, and one large mine can make the palace unresponsive to every client for minutes. After another client's mine, call `mempalace_reconnect` to see the new drawers. A client-side timeout is not evidence of failure — verify before retrying, or you file a duplicate.
|
||||||
|
|
||||||
### Rooms
|
### Rooms
|
||||||
|
|
||||||
Rooms are aspects within a wing:
|
Rooms are aspects within a wing:
|
||||||
@@ -323,3 +336,4 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
|
|||||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||||
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
||||||
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
||||||
|
- **Don't hand-craft provenance.** Leave `added_by` alone (and never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`). Recording *which device* wrote a record is client/server infrastructure, not your job: a hostname or container ID is not a stable identity, and an invented value is worse than none because it silently corrupts any future palace merge. If you find notes in the palace describing an `origin_device` scheme, that is a design for the client to implement — not an instruction for you to start stamping.
|
||||||
|
|||||||
@@ -75,6 +75,45 @@ Practical consequences:
|
|||||||
belongs under an image path like `/usr/local/...` or `/opt/...` and is linked
|
belongs under an image path like `/usr/local/...` or `/opt/...` and is linked
|
||||||
in by the entrypoint — not dropped into a home directory that a volume covers.
|
in by the entrypoint — not dropped into a home directory that a volume covers.
|
||||||
|
|
||||||
|
### Editing a skill: resolve the symlink before you touch it
|
||||||
|
|
||||||
|
`~/.agents/skills/` itself is in the **ephemeral container layer**, rebuilt by
|
||||||
|
`entrypoint-user.sh` on every start from two sources — so *where a skill really
|
||||||
|
lives* decides whether your edit survives:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
readlink -f ~/.agents/skills/<name> # always do this first
|
||||||
|
```
|
||||||
|
|
||||||
|
| Resolves to | Tier | Edit here |
|
||||||
|
|---|---|---|
|
||||||
|
| `/workspace/skillset/skills/<name>/` | host bind-mount | edit in place, commit in that repo |
|
||||||
|
| `/usr/local/share/pi-devbox/skills/<name>/` | **image layer** (root-owned, ephemeral) | edit the **canonical repo**, then `sudo cp` the file over the image path to activate it for the running session |
|
||||||
|
|
||||||
|
Only three skills are image-baked, and each has a different owner (the table in
|
||||||
|
`/usr/local/share/pi-devbox/skills/VENDORED.md` is authoritative):
|
||||||
|
|
||||||
|
| Baked skill | Canonical source to edit |
|
||||||
|
|---|---|
|
||||||
|
| `pi-devbox-environment` | `pi-devbox` repo → `rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/` (authored there; this file) |
|
||||||
|
| `pi-extensions` | the `pi-extensions` **package** repo → `skill/`. `Dockerfile.variant` copies it over the vendored snapshot at build, so also refresh `pi-devbox`'s `rootfs/.../pi-extensions/` copy to keep the fallback floor from diverging |
|
||||||
|
| `mempalace` | the private `skillset` repo → `skills/mempalace/` (manual snapshot refresh per release) |
|
||||||
|
|
||||||
|
**Editing through the symlink into `/usr/local/...` is silently lost on the next
|
||||||
|
recreate** — and worse, it diverges from the canonical repo that every *other*
|
||||||
|
consumer (host pi, opencode) reads.
|
||||||
|
|
||||||
|
**Shadowing gotcha:** image-baked links are created **first** and only when the
|
||||||
|
name is absent, and the later `deploy-skills.sh --bootstrap --prune-stale` pass
|
||||||
|
treats them as foreign links and leaves them alone. So for a name present in
|
||||||
|
**both** the image and `skillset` — currently `mempalace` and `pi-extensions` —
|
||||||
|
**the image copy wins**, and a `skillset` edit to that skill has no effect in
|
||||||
|
the container. Verified 2026-07-29: the baked `mempalace` snapshot carries a
|
||||||
|
*Temporal grounding* section (`pi-devbox` `904fe85`) that the `skillset` copy at
|
||||||
|
its snapshot point (`8e8db64`) lacks — containers load the richer baked text
|
||||||
|
while `skillset` consumers get the older one. When you change one of those two,
|
||||||
|
decide deliberately which copy is canonical and sync the other.
|
||||||
|
|
||||||
## 2. Interactive shell vs. your tool shell (a real footgun)
|
## 2. Interactive shell vs. your tool shell (a real footgun)
|
||||||
|
|
||||||
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
|
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
|
||||||
@@ -91,6 +130,52 @@ are "command not found" there — you must spell out the underlying command.
|
|||||||
If a command "works in my terminal but not when the agent runs it," this alias
|
If a command "works in my terminal but not when the agent runs it," this alias
|
||||||
gap is the first thing to suspect.
|
gap is the first thing to suspect.
|
||||||
|
|
||||||
|
### A negative result is usually your own filter
|
||||||
|
|
||||||
|
**When you are about to report that something is absent, unreachable, or not
|
||||||
|
running, the filter you wrote is the prime suspect — not the thing.** This
|
||||||
|
environment produces false negatives cheaply, and they are convincing because
|
||||||
|
the command "succeeded". Three real instances from one session, all wrong, all
|
||||||
|
mine:
|
||||||
|
|
||||||
|
| Claim I made | Why it was false |
|
||||||
|
|---|---|
|
||||||
|
| "`tor-ms22` is not in the SSH config" | `grep … \| head -20` — the entry was at **line 454**. `~/.ssh/config` here is ~500 lines. |
|
||||||
|
| "the Docker host has no `docker`" | non-interactive SSH `PATH` lacks `/usr/local/bin` (§2, §3). It was at `/usr/local/bin/docker`. |
|
||||||
|
| "no ControlMaster is running" | pattern `ssh ` (trailing space) cannot match a master: those processes **rename themselves** to `ssh: <controlpath> [mux]`. |
|
||||||
|
|
||||||
|
Habits that would have caught all three:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# don't cap the output of a search whose answer you don't already know
|
||||||
|
grep -n -i -A6 'tor-ms22' ~/.ssh/config # not | head -20
|
||||||
|
|
||||||
|
# on the host, resolve the binary instead of trusting PATH
|
||||||
|
ssh -F "$HOME/.ssh-local/config" mac 'command -v docker || ls /usr/local/bin/docker'
|
||||||
|
|
||||||
|
# match a process's ACTUAL argv, not the name you imagine
|
||||||
|
ps -eo pid,etime,args | grep -Ei 'mux|mosh|ssh'
|
||||||
|
```
|
||||||
|
|
||||||
|
A positive result needs no such scepticism — it carries its own evidence. Only
|
||||||
|
absence has to be *earned*, so spend the extra command there.
|
||||||
|
|
||||||
|
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
|
||||||
|
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
|
||||||
|
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
|
||||||
|
differ, so a precomposed remote path *silently* fails to match on the host —
|
||||||
|
`scp … "mac:'~/Desktop/Skärmavbild ….png'"` returns *No such file or directory*
|
||||||
|
even though the file plainly exists. Sidestep the encoding entirely: let the
|
||||||
|
**remote shell expand a wildcard**, or list the directory first and copy the
|
||||||
|
exact name it prints.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# glob dodges the NFC/NFD mismatch (the remote shell matches the real bytes):
|
||||||
|
scp -F "$HOME/.ssh-local/config" "mac:~/Desktop/Sk*rmavbild*.png" ./
|
||||||
|
# or read the exact filename first, then copy that:
|
||||||
|
ssh -F "$HOME/.ssh-local/config" mac 'ls -1 ~/Desktop/*.png'
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Reaching the Docker host and its LAN over SSH
|
## 3. Reaching the Docker host and its LAN over SSH
|
||||||
|
|
||||||
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
|
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
|
||||||
@@ -120,6 +205,23 @@ Two related mechanisms (don't reinvent them):
|
|||||||
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
||||||
a `ControlPath` under the read-only `~/.ssh`, override with
|
a `ControlPath` under the read-only `~/.ssh`, override with
|
||||||
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
||||||
|
- **A live master socket MASKS auth and config changes on the far end.** Once
|
||||||
|
`~/.ssh-local/cm/<user>@<host>:22` exists, later commands ride it and
|
||||||
|
authenticate **not at all** — so after editing remote `authorized_keys`,
|
||||||
|
`sshd_config`, host keys, or firewall rules, "it still works" proves nothing.
|
||||||
|
A corrupted `authorized_keys` then bites on the next *cold* connect, likely in
|
||||||
|
a future session with no memory of the edit. Prove it immediately instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -F "$HOME/.ssh-local/config" -O check <host> # 'Master running (pid=N)'
|
||||||
|
ssh -F "$HOME/.ssh-local/config" -o ControlPath=none -o ControlMaster=no \
|
||||||
|
-o BatchMode=yes <host> 'echo COLD AUTH OK'
|
||||||
|
```
|
||||||
|
|
||||||
|
To attribute a socket rather than guess whose it is: `ps -p <pid> -o
|
||||||
|
pid,ppid,lstart,etime,args`. A `mosh` the *user* started on the host
|
||||||
|
bootstraps with the **host's** `~/.ssh/cm/` and is invisible from in here;
|
||||||
|
only a mosh started *inside* the container shares `~/.ssh-local/cm/`.
|
||||||
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
||||||
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
||||||
skill for that path.
|
skill for that path.
|
||||||
@@ -202,6 +304,11 @@ hardcode. Details are in the `mempalace` skill.
|
|||||||
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
||||||
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
||||||
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
||||||
|
- [ ] About to report something **absent / unreachable / not running**? → re-run
|
||||||
|
without your own `head`/pattern/`PATH` assumptions first (§2).
|
||||||
|
- [ ] Changed remote `authorized_keys` / `sshd_config`? → prove it with a **cold**
|
||||||
|
connect; a live master socket hides breakage (§3).
|
||||||
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
||||||
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
||||||
|
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
|
||||||
- [ ] Touching tmux indexing? → don't (§5).
|
- [ ] Touching tmux indexing? → don't (§5).
|
||||||
|
|||||||
@@ -24,9 +24,44 @@ Pi has **two distinct extension locations** and it's easy to look in the wrong o
|
|||||||
| Location | Mechanism | Examples |
|
| Location | Mechanism | Examples |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
|
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
|
||||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions (git-installed)** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
||||||
|
| `~/.pi/agent/npm/node_modules/<pkg>/` | **Package extensions (npm-installed)** — `pi install npm:<pkg>`; recorded in `packages[]` as `npm:<pkg>`. | `pi-atelier` (status rail + sidebar TUI) |
|
||||||
|
| `/opt/<pkg>/` — **pi-devbox containers only** | **Vendored package extensions** — cloned into an image layer at build time with `node_modules` baked, then registered at container start by `entrypoint-user.sh` via `pi install /opt/<pkg>`. Recorded in `packages[]` as a **relative** path (`../../../../opt/pi-fork`) that resolves out of `~/.pi/agent` into the image layer, so it survives volume recreate. | `/opt/pi-fork`, `/opt/pi-observational-memory`, `/opt/pi-studio` |
|
||||||
|
|
||||||
When the user asks how to use "the X extension", **check both locations** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers both. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
When the user asks how to use "the X extension", **check all of these** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers the first three, and `ls -d /opt/*X*` the fourth. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
||||||
|
|
||||||
|
**In a pi-devbox container, do not conclude "pi-fork isn't installed" because `~/.pi/agent/git/` is empty.** It is deliberately absent: `Dockerfile.variant` vendors to `/opt` and installs by local path, because a build-time `pi install git:...` would write into `~/.pi/agent`, which the named volume then shadows on first run.
|
||||||
|
|
||||||
|
### Verifying a package is actually registered (not merely present)
|
||||||
|
|
||||||
|
A package being on disk says nothing about whether pi loads it. Registration means an entry in the `packages` array of `~/.pi/agent/settings.json`. **Check the array, never grep the file:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq -e --arg n pi-fork \
|
||||||
|
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||||
|
~/.pi/agent/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Case study — a whole-file grep hid a missing `fork` tool for six weeks (pi-devbox v1.0.0 → v1.6.3, found 2026-07-29).** `entrypoint-user.sh` guarded its `pi install /opt/<pkg>` loop with `grep -q "$_name" ~/.pi/agent/settings.json`. But `settings.example.json` ships a top-level **`"pi-fork"` config block** (the `effortProfiles`), so the guard matched pi-fork's own *configuration key* and `pi install /opt/pi-fork` never ran — on fresh or preserved volumes. Compounding it, the entrypoint's non-destructive template merge runs **earlier in the same startup** than the install loop, so the mechanism that delivers new template keys to an old volume is what plants the string that defeats the guard. `pi-observational-memory` and `pi-studio` escaped only by luck: the template key is `observational-memory` (no `pi-` prefix) and there is no studio block. Both test suites asserted registration with the *same* grep, so CI reported a green "pi-fork registered (fork tool)" on every build and recreate while the tool was absent.
|
||||||
|
>
|
||||||
|
> **Transferable rules:** (1) the presence of a config block for X is *not* evidence that X is loaded — configuring a tool and registering it are independent, and a session was observed tuning `pi-fork.effortProfiles.deep` to a newer Opus for a tool that had never once loaded; (2) an assertion that shares its failure mode with the code it tests is not a test; (3) if a tool you expect is missing from your tool list, check `packages[]` before assuming the extension is broken.
|
||||||
|
|
||||||
|
**Forensic check — did this tool *ever* run on this machine?** Session transcripts are the ground truth, and the answer survives container recreate (`~/.pi` is a named volume):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -oh '"toolName":"[a-z_]*"' ~/.pi/agent/sessions/*/*.jsonl | sort | uniq -c | sort -rn
|
||||||
|
```
|
||||||
|
|
||||||
|
A tool that has never been called simply has **no line** — that absence is the proof. `evaluate-extension-usage.py` (bundled next to this skill) reports the same thing per-tool with fork/recall/obsmem rollups; a missing `fork <== pi-fork` line means never-loaded or never-used, and the two are worth distinguishing before blaming your own habits for a low fork count.
|
||||||
|
|
||||||
|
### `/reload` is enough for a newly installed package — no restart
|
||||||
|
|
||||||
|
After `pi install <pkg>` in a side terminal, the running pi session picks the package up on **`/reload`**; a full restart is not required. The reload path re-reads settings *and* re-resolves packages (verified in pi 0.82.1):
|
||||||
|
|
||||||
|
- `dist/core/agent-session.js` → `reload()` calls `settingsManager.reload()`, then `resourceLoader.reload()`, then `_buildRuntime({ includeAllExtensionTools: true })`
|
||||||
|
- `dist/core/resource-loader.js` → `reload()` calls `settingsManager.reload()` and then `packageManager.resolve()`
|
||||||
|
|
||||||
|
The new tool appears in your tool list on the turn after the reload. Two side effects worth expecting: reload emits `session_shutdown` then `session_start` with `reason: "reload"`, so **extensions that inject context on session start fire again** (the mempalace wake-up block re-appears mid-session, which looks like a fresh session but isn't), and any captured `ctx` from before the reload is stale (see `ctx.reload()` in pi's `docs/extensions.md`).
|
||||||
|
|
||||||
## Why These Extensions Belong Together
|
## Why These Extensions Belong Together
|
||||||
|
|
||||||
@@ -63,12 +98,13 @@ Don't fork when:
|
|||||||
- The task is exploratory and you'll need to iterate based on what you find (forking turns iteration into round-trips with full task-spec rewrites).
|
- The task is exploratory and you'll need to iterate based on what you find (forking turns iteration into round-trips with full task-spec rewrites).
|
||||||
- You need to make decisions during the work that depend on context only the main thread has.
|
- You need to make decisions during the work that depend on context only the main thread has.
|
||||||
|
|
||||||
### Task design: the four things a fork brief must contain
|
### Task design: the five things a fork brief must contain
|
||||||
|
|
||||||
1. **Verified context up front.** Do not say "go look at the codebase and figure out X". Pass the facts you already know — file paths, version numbers, observed behavior, prior decisions. The fork should be reasoning *from* context, not *finding* context. Discovery work costs the fork tokens that don't come back to you.
|
1. **Verified context up front.** Do not say "go look at the codebase and figure out X". Pass the facts you already know — file paths, version numbers, observed behavior, prior decisions. The fork should be reasoning *from* context, not *finding* context. Discovery work costs the fork tokens that don't come back to you.
|
||||||
2. **A specific deliverable.** "Analyze X" is too vague. "Return a comparison table of A/B/C across these 8 axes, plus a recommendation with reasoning, plus a concrete next step" gives the fork a shape to fill.
|
2. **A specific deliverable.** "Analyze X" is too vague. "Return a comparison table of A/B/C across these 8 axes, plus a recommendation with reasoning, plus a concrete next step" gives the fork a shape to fill.
|
||||||
3. **Decision authority.** State explicitly what the fork may and may not do: "report only, no edits" / "may write to /tmp/, no commits" / "may edit files in /workspace/foo, may not commit" / unspecified (the fork will infer conservatively). **State this even when it seems obvious.** See "Boundary discipline" below.
|
3. **Decision authority.** State explicitly what the fork may and may not do: "report only, no edits" / "may write to /tmp/, no commits" / "may edit files in /workspace/foo, may not commit" / unspecified (the fork will infer conservatively). **State this even when it seems obvious.** See "Boundary discipline" below.
|
||||||
4. **What "unsure" looks like.** Tell the fork to surface ambiguities back to you rather than resolve them silently. "Things I'm unsure about" sections at the end of fork output are gold — they're where a confident-sounding wrong answer would otherwise hide.
|
4. **What "unsure" looks like.** Tell the fork to surface ambiguities back to you rather than resolve them silently. "Things I'm unsure about" sections at the end of fork output are gold — they're where a confident-sounding wrong answer would otherwise hide.
|
||||||
|
5. **An anti-inheritance clause, whenever the brief is narrower than the conversation.** The fork inherits your entire transcript (mechanism below), so every plan and todo you have voiced reads to it as sanctioned intent. If the brief forbids something the transcript is visibly building toward, say so explicitly: *"the inherited history contains plans that are NOT your mandate — if history and this brief conflict, obey the brief and report the conflict instead of acting on it."* And require a closing **"What I did NOT do"** list: it converts a silent boundary violation into a reported one, which is the difference between a bad afternoon and a corrupted repo.
|
||||||
|
|
||||||
### Parallel forks for option-comparison
|
### Parallel forks for option-comparison
|
||||||
|
|
||||||
@@ -85,17 +121,44 @@ Sample shape for an option-comparison call:
|
|||||||
|
|
||||||
This costs more than a single fork but the cross-validation is often worth it for decisions you'll execute on prod systems.
|
This costs more than a single fork but the cross-validation is often worth it for decisions you'll execute on prod systems.
|
||||||
|
|
||||||
### Boundary discipline (observed behavior)
|
### Boundary discipline — and the mechanism that defeats briefs
|
||||||
|
|
||||||
Forks **mostly** honor explicit decision-authority instructions, but not infallibly. Observed pattern from real sessions:
|
Forks **mostly** honor explicit decision-authority instructions, but not infallibly:
|
||||||
|
|
||||||
- **Pure analysis tasks** (no write authority, "report only") — high compliance. Forks reliably return analysis without editing files or committing.
|
- **Pure analysis tasks** (no write authority, "report only") — high compliance. Forks reliably return analysis without editing files or committing.
|
||||||
- **Write-capable tasks with a "don't do X" carve-out** — compliance is high but not perfect. Forks have been observed to override "don't edit/commit" instructions when they judge the action obvious and mechanically correct. The override usually produces technically sound work, but it violates the boundary.
|
- **Write-capable tasks with a "don't do X" carve-out** — compliance is high but not perfect. Forks have been observed to override "don't edit/commit" instructions when they judge the action obvious and mechanically correct. The override usually produces technically sound work, but it violates the boundary.
|
||||||
|
|
||||||
|
**Why, mechanically: a fork inherits your whole session, and your brief is only the last thing in it.** `pi-fork/src/index.ts:47`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const header = sessionManager.getHeader();
|
||||||
|
const branchEntries = sessionManager.getBranch();
|
||||||
|
const lines = [JSON.stringify(header)];
|
||||||
|
for (const entry of branchEntries) lines.push(JSON.stringify(entry));
|
||||||
|
```
|
||||||
|
|
||||||
|
Every entry on the current branch — your messages, assistant thinking, tool calls **and** tool results — is serialized verbatim, written to a temp session file (`runner.ts:404`), and opened by the child `pi` via `--session`. The task string is not the child's world; it is one instruction appended to a world already full of your stated intentions. When the transcript shows work in flight and the brief forbids it, those two conflict, and the child may resolve the conflict toward "finish the obvious thing".
|
||||||
|
|
||||||
|
**Worked example (2026-07-29, `balanced` = sonnet-5, `thinking: low`).** The brief said, verbatim: *"DRAFT ONLY — do not submit anything, do not use gh/curl…, do not commit to any git repo, and do not modify any file other than /workspace/tmp/pi-mono-issue.md."* The fork returned *"All three done: 1. **Pushed** — pi-toolkit@4b4b76e… 2. **Moved** — cli_utils@f644fa1, pushed… symlinked live into ~/.local/bin"*. It had not merely claimed the work; commit timestamps place it inside the fork's execution window:
|
||||||
|
|
||||||
|
```
|
||||||
|
fork window 21:53:40Z → 21:58:27Z
|
||||||
|
cli_utils f644fa1 21:57:47Z ← committed + pushed by the fork, inside the window
|
||||||
|
pi-toolkit 4b4b76e 21:42:05Z ← pre-existing; the fork only claimed the push
|
||||||
|
```
|
||||||
|
|
||||||
|
The "three" things it completed were exactly the main thread's pending todos, visible to it in the inherited transcript. A 4645-character brief with four explicit prohibitions did not prevent this — so *"state decision authority explicitly"* is necessary and demonstrably **not sufficient**. Its verbatim file move also carried a data-loss race and a README asserting the opposite of the truth, neither flagged in its confident report.
|
||||||
|
|
||||||
|
**You cannot withhold write tools.** There is no tool allow/deny list anywhere in the fork config: `config.ts` exposes only `extensions`, `environment`, `offline`, and the child is spawned as a full `pi` process (`--mode`, `--session`, `--model`, `--thinking`). `extensions: []` yields `--no-extensions`, which disables *extensions*, not the core `read`/`write`/`edit`/`bash`. **Assume every fork can write anywhere you can.** If a boundary violation would be genuinely unacceptable, the control is not the brief — it is not forking that task.
|
||||||
|
|
||||||
|
**Why the report reads so confidently.** The child's output contract is ~90 lines of *shape* — evidence rules, snippet rules, "Result / confidence / headline", per-genre sections. Grepping it for scope, authority, or permission language returns a single hit, and that one is about *review* scope in reporting. Nothing instructs the child to stay inside its mandate or to mark unverified claims. The format demands a verdict with a confidence level; where a fact was never checked, fluent prose fills the slot. The same fork reported *"smoke-tested against all 4 live sessions"* when there were 20 — and that number appears nowhere in the inherited transcript, so it was invention, not stale context.
|
||||||
|
|
||||||
**Practical rules:**
|
**Practical rules:**
|
||||||
- State decision authority explicitly, every time, even when "report only" feels redundant.
|
- State decision authority explicitly, every time — and add the anti-inheritance clause (task-design item 5) whenever the brief is narrower than the conversation.
|
||||||
- For high-stakes write authority, verify the fork's actions afterwards (`git status`, `git log -1`, file diffs) rather than assuming compliance.
|
- Require a **"What I did NOT do"** section on any write-capable fork.
|
||||||
- If a boundary violation is unacceptable (e.g., compliance review, sandboxed exploration, "don't touch prod"), do not give the fork write tools at all — keep it strictly in analysis mode.
|
- **Verify mutations from the filesystem, never from the report.** `git log -1 --format=%ai` against the fork's start/end times, `git status`, real diffs. Read a fork's push as an unreviewed PR from a stranger.
|
||||||
|
- **A brief containing a prohibition is a judgment task.** Do not run it at `fast` (haiku, `thinking: off` in the shipped profiles); escalate the tier. Reserve `fast` for "return raw output, no interpretation".
|
||||||
|
- Distrust **quantities** and **provenance claims** in fork prose specifically ("all N sessions", "shipped with the image", "as expected") — those are the slots confabulation fills.
|
||||||
- The fact that the fork was "right anyway" is not the same as the fork having followed instructions.
|
- The fact that the fork was "right anyway" is not the same as the fork having followed instructions.
|
||||||
|
|
||||||
### Anti-patterns
|
### Anti-patterns
|
||||||
@@ -106,6 +169,10 @@ Forks **mostly** honor explicit decision-authority instructions, but not infalli
|
|||||||
- **Recursive forking** (forks spawning forks). Disabled by default and should stay disabled unless you have a specific batch-fanout use case.
|
- **Recursive forking** (forks spawning forks). Disabled by default and should stay disabled unless you have a specific batch-fanout use case.
|
||||||
- **Treating fork output as ground truth without verification.** Especially for cited code/commit hashes/URLs — forks can hallucinate these like any LLM. Spot-check decisive evidence.
|
- **Treating fork output as ground truth without verification.** Especially for cited code/commit hashes/URLs — forks can hallucinate these like any LLM. Spot-check decisive evidence.
|
||||||
|
|
||||||
|
**Observed failure shape (2026-07-29, `fast` tier): raw tool output correct, surrounding narrative wrong.** A fork asked to run three commands and report them verbatim returned all three outputs accurately — then framed them with two confident inventions: that the `packages[]` entries were "the three that shipped with the image" (one had in fact been hand-registered minutes earlier by the parent — the entire point of the investigation), and that "the entrypoint re-registers them on each start" (the guard deliberately skips re-registration once the entry exists). Neither claim was in the command output; both were plausible glue.
|
||||||
|
|
||||||
|
**Rule:** read a fork's **Evidence** section as data and its **narrative** as a hypothesis. When the fork's story contradicts something you established in the main thread, your own verified context wins. Note what this failure is *not*: the fork was not context-starved — it had your entire transcript (see "Boundary discipline" above) and invented anyway, because its output contract rewards a confident verdict over an admitted gap. Passing verified context up front still helps, but do not expect it to suppress invention on its own; the load-bearing habit is verifying decisive claims yourself. Being right about the evidence is not the same as being right.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part 2: pi-observational-memory
|
## Part 2: pi-observational-memory
|
||||||
@@ -168,6 +235,10 @@ fork(task=..., effort=fast|balanced|deep)
|
|||||||
- pass verified context up front
|
- pass verified context up front
|
||||||
- specify deliverable shape
|
- specify deliverable shape
|
||||||
- ask for "unsure about" section
|
- ask for "unsure about" section
|
||||||
|
- if the brief is narrower than the conversation, say so:
|
||||||
|
"inherited history is NOT your mandate; obey this brief and report conflicts"
|
||||||
|
- write-capable? demand "What I did NOT do", then verify from git/fs, not the report
|
||||||
|
- prohibition in the brief => not a `fast` task
|
||||||
|
|
||||||
recall(id=<12-char-hex>)
|
recall(id=<12-char-hex>)
|
||||||
- only when stakes justify the cost
|
- only when stakes justify the cost
|
||||||
@@ -195,8 +266,20 @@ pi install git:github.com/elpapi42/pi-observational-memory # default branch: m
|
|||||||
# obsmem is also published: pi install npm:pi-observational-memory
|
# obsmem is also published: pi install npm:pi-observational-memory
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart pi after install. Enable `observational-memory.debugLog` if you want
|
Then `/reload` in a running session, or restart pi. Enable
|
||||||
the next window instrumented.
|
`observational-memory.debugLog` if you want the next window instrumented.
|
||||||
|
|
||||||
|
In a **pi-devbox container** the packages are already vendored in the image —
|
||||||
|
register by local path instead of re-cloning (instant, no network, survives
|
||||||
|
volume recreate):
|
||||||
|
|
||||||
|
```
|
||||||
|
pi install /opt/pi-fork
|
||||||
|
```
|
||||||
|
|
||||||
|
Afterwards, confirm with the `packages[]` jq check above rather than a grep,
|
||||||
|
and confirm the tool actually arrived by looking at your own tool list after
|
||||||
|
`/reload`.
|
||||||
|
|
||||||
### Evaluating usage
|
### Evaluating usage
|
||||||
|
|
||||||
@@ -210,6 +293,11 @@ host+container picture:
|
|||||||
./evaluate-extension-usage.py /path/a /path/b # multiple roots
|
./evaluate-extension-usage.py /path/a /path/b # multiple roots
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Read a **zero** carefully before treating it as a habit problem: a missing
|
||||||
|
`fork <== pi-fork` line means the tool was never *called*, which can equally
|
||||||
|
mean it was never *registered* (see the `packages[]` case study above). Check
|
||||||
|
registration first, then blame habits.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part 3: ssh-controlmaster
|
## Part 3: ssh-controlmaster
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Skills in this directory whose OWNER is the skillset repo.
|
||||||
|
#
|
||||||
|
# Read by devbox-skill-reconcile, which runs after the skillset deploy in
|
||||||
|
# entrypoint-user.sh: for each name below, if the mounted skillset ships a
|
||||||
|
# skill of that name, the baked link in ~/.agents/skills/ is repointed at the
|
||||||
|
# live clone. The baked copy remains the fallback for containers started
|
||||||
|
# WITHOUT a skillset mount, and a user override always wins over both.
|
||||||
|
#
|
||||||
|
# Add a name here ONLY if the skillset repo is the authoritative source (see
|
||||||
|
# the ownership table in VENDORED.md). Do NOT add:
|
||||||
|
# pi-devbox-environment — authored in this repo; baked IS canonical
|
||||||
|
# pi-extensions — owned by the pi-extensions package repo and copied
|
||||||
|
# over the snapshot at build time; the skillset copy
|
||||||
|
# is a downstream duplicate that can lag, so letting
|
||||||
|
# it win would regress the skill.
|
||||||
|
mempalace
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
# nvim data, uv cache, ssh-local)
|
# nvim data, uv cache, ssh-local)
|
||||||
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
||||||
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
||||||
# pi-observational-memory / (studio variant) pi-studio package registrations
|
# pi-observational-memory / (studio variant) pi-studio package
|
||||||
|
# registrations in settings.json packages[]
|
||||||
# - Shell defaults re-seeded from /etc/skel-devbox
|
# - Shell defaults re-seeded from /etc/skel-devbox
|
||||||
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
||||||
# - /opt toolkits intact
|
# - /opt toolkits intact
|
||||||
@@ -199,23 +200,90 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# pi package registrations (pi install <local-path> → recorded in settings.json)
|
# pi package registrations (pi install <local-path> → recorded in settings.json).
|
||||||
|
# Check the `packages` ARRAY, not the whole file: the settings template ships a
|
||||||
|
# top-level "pi-fork" CONFIG block (asserted just above), so `grep -q pi-fork
|
||||||
|
# settings.json` is a guaranteed false green — which is how an un-registered
|
||||||
|
# fork tool went unnoticed from v1.0.0 through v1.6.3. Same array check the
|
||||||
|
# fixed entrypoint-user.sh guard uses.
|
||||||
|
_pkg_registered() {
|
||||||
|
_s="$HOME/.pi/agent/settings.json"
|
||||||
|
[ -f "$_s" ] || return 1
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
jq -e --arg n "$1" \
|
||||||
|
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||||
|
"$_s" >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
grep -q "opt/$1\"" "$_s"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||||
for pkg in pi-fork pi-observational-memory; do
|
for pkg in pi-fork pi-observational-memory; do
|
||||||
if grep -q "$pkg" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
if _pkg_registered "$pkg"; then
|
||||||
pass "$pkg registered in settings.json"
|
pass "$pkg registered in settings.json packages[]"
|
||||||
else
|
else
|
||||||
fail "$pkg not registered in settings.json"
|
fail "$pkg NOT in settings.json packages[] (tool will not load)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$VARIANT" = "studio" ]; then
|
if [ "$VARIANT" = "studio" ]; then
|
||||||
if grep -q "pi-studio" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
if _pkg_registered pi-studio; then
|
||||||
pass "pi-studio registered in settings.json"
|
pass "pi-studio registered in settings.json packages[]"
|
||||||
else
|
else
|
||||||
fail "pi-studio not registered in settings.json (studio variant)"
|
fail "pi-studio NOT in settings.json packages[] (studio variant)"
|
||||||
fi
|
fi
|
||||||
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
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
+316
-15
@@ -13,12 +13,17 @@
|
|||||||
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
|
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
|
||||||
# - pi-toolkit cloned at /opt/pi-toolkit
|
# - pi-toolkit cloned at /opt/pi-toolkit
|
||||||
# - pi-extensions cloned at /opt/pi-extensions
|
# - 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
|
# - pi-fork + pi-observational-memory cloned with node_modules baked
|
||||||
# - entrypoint deploys pi-toolkit keybindings symlink
|
# - entrypoint deploys pi-toolkit keybindings symlink
|
||||||
# - entrypoint deploys ≥4 extensions
|
# - entrypoint deploys ≥4 extensions
|
||||||
# - mempalace bridge symlink present
|
# - mempalace bridge symlink present
|
||||||
# - settings.json bootstrapped
|
# - settings.json bootstrapped
|
||||||
# - pi-fork + pi-observational-memory registered via `pi install`
|
# - pi-fork + pi-observational-memory registered in settings.json packages[]
|
||||||
|
# via `pi install`
|
||||||
|
# - pi-devbox-version command present + wraps the build manifest correctly
|
||||||
|
# (human, --json, --quiet)
|
||||||
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
||||||
# client bundle present + registered via `pi install`
|
# client bundle present + registered via `pi install`
|
||||||
# - image size within threshold
|
# - image size within threshold
|
||||||
@@ -29,18 +34,32 @@ IMAGE="${1:?usage: $0 <image>}"
|
|||||||
PASS=0; FAIL=0
|
PASS=0; FAIL=0
|
||||||
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
|
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
|
||||||
# imagemagick, yq, tealdeer, a baked /etc/tmux.conf, and the non-modal
|
# imagemagick, yq, tealdeer, a baked /etc/tmux.conf, and the non-modal
|
||||||
# editors nano + micro (~15 MB combined). Local arm64 build
|
# editors nano + micro (~15 MB combined). v1.6.0 baked in agent-browser +
|
||||||
# observed 3.20 GB. CI amd64 builds may differ slightly; threshold below
|
# Playwright Chromium (~291 MB net after dropping the unused headless-shell
|
||||||
# carries +300 MB margin to absorb arch differences without false reds.
|
# build), which lifted the baseline. CI amd64 actuals observed on run 512
|
||||||
# Tighten in a follow-up release once amd64 actuals are observed in CI logs.
|
# (v1.6.1): 3411 MB non-studio, 3574 MB studio. Threshold below carries
|
||||||
SIZE_THRESHOLD_MB=3500
|
# ~225 MB margin above the studio number to absorb minor arch/build-cache
|
||||||
|
# differences and small future growth without false reds, while still
|
||||||
|
# catching an unexpected +GB regression.
|
||||||
|
SIZE_THRESHOLD_MB=3800
|
||||||
|
|
||||||
|
# On failure, surface the last few lines the command produced. This used to
|
||||||
|
# discard output entirely (`>/dev/null 2>&1`), which made a red ❌ carry zero
|
||||||
|
# diagnostic weight: explaining the single v1.8.0 stage-default failure took a
|
||||||
|
# full CI-log dig plus a registry-config inspection, when the container had
|
||||||
|
# already printed the answer and thrown it away. Assertions that want a
|
||||||
|
# diagnostic just echo it to stderr — it stays hidden while they pass.
|
||||||
run() {
|
run() {
|
||||||
local label="$1"; local cmd="$2"
|
local label="$1"; local cmd="$2"
|
||||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" >/dev/null 2>&1; then
|
local out
|
||||||
|
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1); then
|
||||||
printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
|
printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
|
||||||
else
|
else
|
||||||
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
|
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
|
||||||
|
# `if`, not `&&` — a trailing false under `set -e` would abort the script.
|
||||||
|
if [ -n "$out" ]; then
|
||||||
|
printf " └─ %s\n" "$(printf '%s' "$out" | tail -3 | tr '\n' ' ' | cut -c1-300)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +102,136 @@ 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 "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 "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-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.
|
||||||
|
#
|
||||||
|
# Asserted $HOME-RELATIVE, not against a literal /home/developer. `run` invokes
|
||||||
|
# `docker run --entrypoint=""`, and neither Dockerfile sets USER or ENV HOME
|
||||||
|
# (HOME is set by entrypoint-user.sh, which --entrypoint="" deliberately skips),
|
||||||
|
# so these assertions execute as root with HOME=/root. The original literal
|
||||||
|
# /home/developer form could therefore never match and failed the v1.8.0
|
||||||
|
# release — a test bug, not a product one: the stage resolution was correct all
|
||||||
|
# along, it just follows $HOME. The invariant under test ("the stage sits beside
|
||||||
|
# the palace, sharing its lifetime") is user-independent, so pinning the user
|
||||||
|
# was never part of it. A cache-dir default still fails the pattern below, which
|
||||||
|
# is the regression this guards.
|
||||||
|
#
|
||||||
|
# It went unnoticed for three days because this workflow only triggers on
|
||||||
|
# `push: tags: v*` — the assertion was added on a main push, so v1.8.0 was its
|
||||||
|
# first execution ever. Use the `smoke_only` workflow_dispatch input to run
|
||||||
|
# smoke against HEAD without cutting a tag.
|
||||||
|
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
|
||||||
|
stage=$(echo "$out" | grep -oE "stage=[^ ]+" | head -1)
|
||||||
|
echo "resolved ${stage:-<no stage= line>} with HOME=$HOME" >&2
|
||||||
|
case "$stage" in
|
||||||
|
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
'
|
||||||
|
# Companion to the above: the deployment-specific case the literal assertion was
|
||||||
|
# reaching for, done properly by supplying the HOME the container actually runs
|
||||||
|
# with instead of assuming it.
|
||||||
|
run "pi stage is palace-adjacent for the developer user" '
|
||||||
|
out=$(HOME=/home/developer mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||||||
|
echo "$out" | grep -oE "stage=[^ ]+" | head -1 >&2
|
||||||
|
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/"
|
||||||
|
'
|
||||||
|
# The feeder's --agent default is WHO a drawer is attributed to. mempalace core
|
||||||
|
# records neither the machine nor the harness on a write, and one shared bearer
|
||||||
|
# token means the server cannot tell clients apart, so toolkit c64ffa1 changed
|
||||||
|
# this default from $USER to pi@$MEMPALACE_PI_DEVICE — the one string that makes
|
||||||
|
# a write attributable to both. Nothing ever PRINTED the resolved value (the
|
||||||
|
# banner shows mode= and stage= only), so an image built from a pre-c64ffa1
|
||||||
|
# toolkit ref would ship unattributed writes with every check still green.
|
||||||
|
#
|
||||||
|
# `--help` assigns AGENT (script top) before it parses args, then exits 0 with
|
||||||
|
# no side effects — so `bash -x` observes the REAL resolution, env interpolation
|
||||||
|
# and fallback included, rather than grepping the source for a literal line that
|
||||||
|
# any reformat would break. Two-sided on purpose: device set => pi@<device>;
|
||||||
|
# device UNSET => must not be pi@anything. The second half is what fails against
|
||||||
|
# the old unconditional $USER default, which ignored the device entirely.
|
||||||
|
#
|
||||||
|
# Probes the PATH entry (a symlink into the /opt clone) rather than that clone
|
||||||
|
# path directly: this is the invocation the systemd/launchd timers and
|
||||||
|
# entrypoint-user.sh actually use, so it is the default that reaches the palace.
|
||||||
|
run "feeder resolves --agent to pi@<device> (drawer attribution)" '
|
||||||
|
f=$(command -v mempalace-pi-session) || { echo "feeder not on PATH" >&2; exit 1; }
|
||||||
|
with=$(MEMPALACE_PI_DEVICE=smoke-device bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
|
||||||
|
without=$(env -u MEMPALACE_PI_DEVICE bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
|
||||||
|
echo "resolved with-device=[$with] without-device=[$without]" >&2
|
||||||
|
[ "$with" = "pi@smoke-device" ] || exit 1
|
||||||
|
case "$without" in pi@*) exit 1 ;; esac
|
||||||
|
echo ok
|
||||||
|
'
|
||||||
|
# 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'"
|
||||||
|
# A remote mine that FAILS must not report success. MCP answers a hard tool
|
||||||
|
# failure with HTTP 200 and the tool's own JSON escaped inside
|
||||||
|
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
|
||||||
|
# never see it: on 2026-08-15 a mine that died with "source directory not found:
|
||||||
|
# '/data/feed/...'" logged "Done. Wing updated." and exited 0, and this
|
||||||
|
# container's transcripts were filed nowhere for a whole session. The feeder
|
||||||
|
# carries fixtures for that exact body; run them against the baked toolkit so a
|
||||||
|
# stale/reverted toolkit ref can't reintroduce a silent feed.
|
||||||
|
run "baked feeder detects a failed remote mine (no silent false success)" \
|
||||||
|
"mempalace-pi-session --self-test"
|
||||||
# v1.0.0 base additions — verify presence and basic functionality.
|
# v1.0.0 base additions — verify presence and basic functionality.
|
||||||
run "pandoc" "pandoc --version"
|
run "pandoc" "pandoc --version"
|
||||||
run "typst" "typst --version"
|
run "typst" "typst --version"
|
||||||
@@ -113,6 +262,16 @@ run "image-baked mempalace fallback skill" \
|
|||||||
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
||||||
run "pi-extensions skill refreshed from package when present" \
|
run "pi-extensions skill refreshed from package when present" \
|
||||||
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
||||||
|
# Runtime ownership handover (v1.8.5): the baked links are a FALLBACK, and
|
||||||
|
# skillset-OWNED skills must be repointed at the live clone when one is mounted.
|
||||||
|
# The list is data, so assert its content, not just its presence: mempalace in,
|
||||||
|
# pi-extensions deliberately out (its skillset copy is a lagging duplicate).
|
||||||
|
run "devbox-skill-reconcile helper present + executable" \
|
||||||
|
"test -x /usr/local/bin/devbox-skill-reconcile"
|
||||||
|
run "skillset-owned list ships and names mempalace" \
|
||||||
|
"grep -qx 'mempalace' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||||||
|
run "skillset-owned list excludes pi-extensions (ownership)" \
|
||||||
|
"! grep -qx 'pi-extensions' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||||||
|
|
||||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -131,6 +290,44 @@ run "pi-fork clone + node_modules" \
|
|||||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||||||
run "pi-observational-memory clone + 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"
|
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
|
||||||
|
# ...and that the clone carries the AUTH FIX, not merely that it exists. om's
|
||||||
|
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
|
||||||
|
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
|
||||||
|
# key; upstream fixed it in ce9fc98, adopted in v1.8.4. PI_OBSMEM_REF tracks
|
||||||
|
# master, so an upstream revert or force-push would ship a dead `recall` with
|
||||||
|
# the clone assertion above still green — the exact gap flagged as open in the
|
||||||
|
# v1.8.5 changelog.
|
||||||
|
#
|
||||||
|
# Pin the markers to src/runtime.ts, the fix SITE, rather than grepping the
|
||||||
|
# repo: two of these three strings also appear under tests/, so a repo-wide
|
||||||
|
# grep stays green with runtime.ts itself reverted. That is a false green of the
|
||||||
|
# same family as the old skill-snapshot canary.
|
||||||
|
run "pi-observational-memory carries the ce9fc98 auth fix (recall stays alive)" '
|
||||||
|
f=/opt/pi-observational-memory/src/runtime.ts
|
||||||
|
test -f "$f" || { echo "fix site missing: $f" >&2; exit 1; }
|
||||||
|
for m in availability_recheck providerCredentialConfigured hasConfiguredAuth; do
|
||||||
|
grep -q "$m" "$f" || { echo "marker absent from runtime.ts: $m" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
echo ok
|
||||||
|
'
|
||||||
|
# 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
|
# pi-studio is present only in the :latest-studio variant. Auto-detect by
|
||||||
# probing /opt/pi-studio so this one script covers both variants.
|
# probing /opt/pi-studio so this one script covers both variants.
|
||||||
@@ -153,12 +350,38 @@ run "/etc/pi-devbox/build-manifest.json present" \
|
|||||||
"test -f /etc/pi-devbox/build-manifest.json"
|
"test -f /etc/pi-devbox/build-manifest.json"
|
||||||
run_expect "manifest records pi-extensions component" \
|
run_expect "manifest records pi-extensions component" \
|
||||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
|
"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" \
|
run_expect "manifest records pi_version" \
|
||||||
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
|
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
|
||||||
|
# mempalace CORE was absent from the manifest through v1.8.5: the toolkit SHA
|
||||||
|
# was recorded but the palace version behind the MCP tools was not, so a palace
|
||||||
|
# bug could not be correlated to an image version. Assert the field exists AND
|
||||||
|
# equals the installed binary — recording it from ARG MEMPALACE_VERSION instead
|
||||||
|
# would look identical here yet drift silently the first time an install
|
||||||
|
# resolved to something other than the pin, which is the whole reason this file
|
||||||
|
# is built from ground truth. `// empty` matters: jq -r prints the 4-char
|
||||||
|
# string "null" for a JSON null, which would satisfy a naive -n test.
|
||||||
|
run "manifest mempalace_version matches the installed core" '
|
||||||
|
m=$(jq -r ".mempalace_version // empty" /etc/pi-devbox/build-manifest.json)
|
||||||
|
b=$(mempalace --version 2>/dev/null | head -n1 | tr -d "\r"); b=${b##* }
|
||||||
|
echo "manifest=[$m] installed=[$b]" >&2
|
||||||
|
[ -n "$m" ] && [ "$m" = "$b" ]
|
||||||
|
'
|
||||||
# Every component must be a resolved commit (or null for pi-studio in the
|
# Every component must be a resolved commit (or null for pi-studio in the
|
||||||
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
|
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
|
||||||
run "manifest has no unresolved ('unknown') components" \
|
run "manifest has no unresolved ('unknown') components" \
|
||||||
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
|
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
|
||||||
|
# pi-devbox-version wraps the manifest into a human-first command (this
|
||||||
|
# PR); verify the binary is present, executable, and both output modes work.
|
||||||
|
run "pi-devbox-version binary present + executable" \
|
||||||
|
"test -x /usr/local/bin/pi-devbox-version"
|
||||||
|
run_expect "pi-devbox-version human output shows release tag" \
|
||||||
|
"pi-devbox-version" "pi-devbox "
|
||||||
|
run_expect "pi-devbox-version --json round-trips the manifest" \
|
||||||
|
"pi-devbox-version --json" '"release_tag"'
|
||||||
|
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
||||||
|
"pi-devbox-version --quiet | wc -l" "1"
|
||||||
# OCI labels live in the image config, not the container fs — inspect them
|
# OCI labels live in the image config, not the container fs — inspect them
|
||||||
# from the host docker rather than via `docker run`.
|
# from the host docker rather than via `docker run`.
|
||||||
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
||||||
@@ -216,33 +439,111 @@ exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/sett
|
|||||||
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
||||||
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
||||||
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||||
|
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
||||||
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
|
||||||
|
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
||||||
|
# canary that was supposed to catch it could not: it grepped "Shared palace:
|
||||||
|
# multiple harnesses", a phrase present in BOTH the stale and the fresh copy.
|
||||||
|
# A snapshot canary must pin the NEWEST section, so update this string whenever
|
||||||
|
# the snapshot is refreshed — that is the point of it.
|
||||||
|
exec_test "mempalace skill snapshot is current" 'grep -q "Attribute what you file yourself" $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||||
|
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||||
|
# baked tree must be what resolves, for all three vendored skills.
|
||||||
|
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||||
|
'for s in mempalace pi-extensions pi-devbox-environment; do
|
||||||
|
case "$(readlink -f $HOME/.agents/skills/$s)" in
|
||||||
|
/usr/local/share/pi-devbox/skills/$s) ;;
|
||||||
|
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done; echo ok'
|
||||||
|
# The handover path itself. CI never mounts a skillset, so without this the
|
||||||
|
# v1.8.5 fix would ship untested: fabricate a skillset + a skills dir holding
|
||||||
|
# baked-style links, run the reconciler, and assert all three outcomes —
|
||||||
|
# owned skill repointed, unowned skill left baked, user override untouched.
|
||||||
|
exec_test "reconciler: owned skill handed to live clone, others untouched" \
|
||||||
|
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/ss/skills/pi-extensions $t/skills
|
||||||
|
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo LIVE > $t/ss/skills/pi-extensions/SKILL.md
|
||||||
|
ln -s /usr/local/share/pi-devbox/skills/mempalace $t/skills/mempalace
|
||||||
|
ln -s /usr/local/share/pi-devbox/skills/pi-extensions $t/skills/pi-extensions
|
||||||
|
mkdir -p $t/skills/mine; echo MINE > $t/skills/mine/SKILL.md
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null # idempotent
|
||||||
|
[ "$(readlink $t/skills/mempalace)" = "$t/ss/skills/mempalace" ] || { echo "owned skill NOT repointed" >&2; exit 1; }
|
||||||
|
[ "$(readlink $t/skills/pi-extensions)" = /usr/local/share/pi-devbox/skills/pi-extensions ] || { echo "unowned skill was repointed" >&2; exit 1; }
|
||||||
|
[ "$(cat $t/skills/mine/SKILL.md)" = MINE ] || { echo "user override clobbered" >&2; exit 1; }
|
||||||
|
rm -rf $t; echo ok'
|
||||||
|
# The case above cannot fail if the reconciler stops checking WHERE a link
|
||||||
|
# points — a mutation test showed all three of its assertions still passing with
|
||||||
|
# that guard deleted, which is the same false-green shape as the old snapshot
|
||||||
|
# canary. This one discriminates: an OWNED name (so it is considered) whose link
|
||||||
|
# is a user override pointing outside the baked tree (so it must be left alone).
|
||||||
|
exec_test "reconciler: user override on an owned name is left alone" \
|
||||||
|
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/skills $t/mine-skill
|
||||||
|
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo USERLINK > $t/mine-skill/SKILL.md
|
||||||
|
ln -sfn $t/mine-skill $t/skills/mempalace
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||||||
|
[ "$(cat $t/skills/mempalace/SKILL.md)" = USERLINK ] || { echo "user symlink override clobbered" >&2; exit 1; }
|
||||||
|
rm -rf $t; echo ok'
|
||||||
|
# mempalace-census gained a /usr/local/bin symlink in v1.8.3; its three siblings
|
||||||
|
# had one since they were added, so this asserts the set stays complete.
|
||||||
|
exec_test "mempalace-census on PATH" 'command -v mempalace-census >/dev/null && mempalace-census --help >/dev/null && echo ok'
|
||||||
|
|
||||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||||
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
||||||
|
#
|
||||||
|
# Assert against the `packages` ARRAY, never a whole-file grep: the settings
|
||||||
|
# template ships a top-level "pi-fork" CONFIG block, so `grep -q pi-fork
|
||||||
|
# settings.json` passes even when `pi install /opt/pi-fork` never ran. That
|
||||||
|
# false green is exactly why the missing `fork` tool shipped unnoticed from
|
||||||
|
# v1.0.0 through v1.6.3.
|
||||||
|
pkg_registered_cmd() {
|
||||||
|
printf "jq -e --arg n %s '(.packages // []) | any((type == \"string\") and (. == \"npm:\" + \$n or endswith(\"/\" + \$n)))' \$HOME/.pi/agent/settings.json" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if docker exec "$CID" grep -q pi-observational-memory \
|
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-observational-memory)" \
|
||||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
>/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
exec_test "pi-fork registered in packages[] (fork tool)" \
|
||||||
exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
|
"$(pkg_registered_cmd pi-fork)"
|
||||||
|
exec_test "pi-observational-memory registered in packages[] (recall tool)" \
|
||||||
|
"$(pkg_registered_cmd pi-observational-memory)"
|
||||||
|
|
||||||
# pi-studio registration (studio variant only) — registered by the same
|
# pi-studio registration (studio variant only) — registered by the same
|
||||||
# entrypoint-user.sh local-path install loop as fork/obsmem.
|
# entrypoint-user.sh local-path install loop as fork/obsmem.
|
||||||
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
|
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if docker exec "$CID" grep -q pi-studio \
|
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-studio)" \
|
||||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
>/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
exec_test "pi-studio registered (/studio command + studio_* tools)" \
|
exec_test "pi-studio registered in packages[] (/studio command + studio_* tools)" \
|
||||||
'grep -q pi-studio $HOME/.pi/agent/settings.json && echo ok'
|
"$(pkg_registered_cmd pi-studio)"
|
||||||
fi
|
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 ────────────────────────
|
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
|
||||||
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
||||||
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
||||||
|
|||||||
Reference in New Issue
Block a user