Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ade58131d6 | |||
| ffd44ad9cf | |||
| 43cd6e22f2 | |||
| 62a2a79b1c | |||
| f20b2a7926 | |||
| 66a19aa394 | |||
| 572430237f | |||
| 1fd524e7fb | |||
| e86e5df327 | |||
| fa04d2083d | |||
| 209f2c2f67 | |||
| 4d4abd9a9f | |||
| d5c5da3f6c | |||
| 8248688d58 | |||
| e274510fd1 | |||
| 9ef7a92dce | |||
| 6dfbded9c8 | |||
| 45b6239777 | |||
| d00eef2acb |
@@ -43,6 +43,14 @@ 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 ────────────────────────────────────────────────
|
||||||
GIT_USER_NAME=
|
GIT_USER_NAME=
|
||||||
GIT_USER_EMAIL=
|
GIT_USER_EMAIL=
|
||||||
|
|||||||
@@ -136,8 +136,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 +165,35 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
|
# ── pi version: from the PIN, not from npm `latest` ───────────
|
||||||
# reliably on PATH in act_runner job containers, so query directly).
|
# Until v1.7.0 this followed npm `latest`, which meant every release
|
||||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true)
|
# silently adopted whatever pi had shipped that morning — unaudited —
|
||||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
# in the same build that then got tagged and published. A pi minor
|
||||||
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
|
# 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,6 +206,26 @@ 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-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
||||||
|
# (which track a branch head) atelier wraps pi's private TUI
|
||||||
|
# renderer, so its version is pinned in Dockerfile.variant and read
|
||||||
|
# from there; we only resolve tag → SHA, for reproducibility and to
|
||||||
|
# defeat the cache-hit footgun. Never floats to a branch.
|
||||||
|
ATELIER_TAG=$(sed -n 's/^ARG PI_ATELIER_REF=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
||||||
|
if ! printf '%s' "${ATELIER_TAG:-}" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "::error::ARG PI_ATELIER_REF in Dockerfile.variant is not a semver tag (got '${ATELIER_TAG:-<empty>}'). pi-atelier must stay pinned to a tag — see the floor note above that ARG."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ATELIER_LS=$(git ls-remote --tags "https://github.com/michaelmjhhhh/pi-atelier.git" || true)
|
||||||
|
# Peeled ^{} line first (annotated tags), then the direct ref.
|
||||||
|
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}^{}" '$2==t{print $1}')
|
||||||
|
if [ -z "$ATELIER_REF" ]; then
|
||||||
|
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}" '$2==t{print $1}')
|
||||||
|
fi
|
||||||
|
require_sha PI_ATELIER_REF "$ATELIER_REF"
|
||||||
|
echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "atelier_tag=${ATELIER_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
|
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
|
||||||
# requires auth even for public-repo commit listing.
|
# requires auth even for public-repo commit listing.
|
||||||
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||||
@@ -230,7 +280,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 +408,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 +470,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)
|
||||||
@@ -471,6 +526,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 +547,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:-}" \
|
||||||
@@ -559,6 +620,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 +642,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:
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ re-brand of opencode-devbox's `pi-only` variant.
|
|||||||
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
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 produces two runs, not
|
||||||
|
one** — `lint.yml` fires on every push (including tag refs) and
|
||||||
|
`docker-publish.yml` fires on `v*` tags. Watch the **publish** run; see
|
||||||
|
*Gitea API access* below for how to find it without picking lint by mistake.
|
||||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
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
|
||||||
@@ -92,13 +95,61 @@ host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
|||||||
**not** baked into the image. When configured, they are also available for
|
**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
|
||||||
|
|||||||
+453
-2
@@ -11,12 +11,463 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Unreleased
|
## v1.7.0 — 2026-08-07
|
||||||
|
|
||||||
_Nothing yet — next release's changes will accrue here._
|
Minor release. Headline: **pi-atelier is now part of the image** — the TUI
|
||||||
|
sidebar/status rail every container previously had to hand-install — and **pi is
|
||||||
|
pinned to an audited version instead of tracking npm `latest`**.
|
||||||
|
|
||||||
|
*Why minor and not patch:* the policy above reserves patch for "pi version bumps,
|
||||||
|
smaller fixes" and minor for "new variants, significant base additions". Bundling
|
||||||
|
a new companion package into every image is the same shape as v1.1.0, which went
|
||||||
|
minor for bundling pi-studio; v1.4.0 likewise went minor for adding typst. This
|
||||||
|
release also adds a new build-arg pair, a new opt-out env var, and a settings
|
||||||
|
migration, so patch would understate it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **pi-atelier vendored at `/opt/pi-atelier`, pinned to `v0.8.0`** — the TUI
|
||||||
|
sidebar (ordered panels, split-pane, themes) is now part of the image instead
|
||||||
|
of something each user hand-installs. Vendored + registered at container start
|
||||||
|
by `entrypoint-user.sh`, the same pattern as pi-fork/pi-observational-memory/
|
||||||
|
pi-studio, and deliberately **not** `pi install npm:pi-atelier`: an npm
|
||||||
|
install writes into `~/.pi/npm-global` on the config volume, which shadows the
|
||||||
|
image and pins nothing — the footgun that once hid a missing `fork` tool for
|
||||||
|
six weeks. Unlike its siblings it gets **no `npm install`**: pi-atelier
|
||||||
|
declares zero runtime dependencies (only peerDeps, satisfied by the baked pi)
|
||||||
|
and has no build step, so pi loads its TypeScript straight from the checkout
|
||||||
|
(`pi.extensions` → `extensions/index.ts`).
|
||||||
|
- **A version FLOOR, encoded as an executable test.** pi-atelier 0.6.0/0.7.0
|
||||||
|
wrap pi's private TUI renderer in a way that recurses under pi 0.84: pi hangs
|
||||||
|
at startup with sustained CPU and no error message. Upstream fixed the
|
||||||
|
recursion in 0.7.1 and restored the non-overlapping split in 0.7.2
|
||||||
|
("avoiding the recursive render path that caused startup hangs and sustained
|
||||||
|
CPU usage"); 0.8.0 is additive on top of that. atelier's own
|
||||||
|
`peerDependencies` still say `>=0.80.7`, which does **not** express the floor,
|
||||||
|
so nothing in npm metadata could have warned us. `smoke-test.sh` and
|
||||||
|
`recreate-sanity-check.sh` now assert the pairing rule **pi ≥ 0.84 ⇒
|
||||||
|
pi-atelier ≥ 0.7.1** — verified against a 4×4 version matrix — so a bad
|
||||||
|
combination fails the build instead of publishing an image whose TUI never
|
||||||
|
starts. CI resolves the pinned tag to its **peeled commit SHA**; atelier uses
|
||||||
|
annotated tags, so the unpeeled ref is a tag object, not a commit (pi-studio's
|
||||||
|
lightweight tags never exposed that distinction).
|
||||||
|
- **`DEVBOX_ATELIER=0`** opts out: the entrypoint removes pi-atelier from pi's
|
||||||
|
`packages[]` instead of registering it. The switch lives in the entrypoint
|
||||||
|
rather than being "just run `pi uninstall`" because this component's failure
|
||||||
|
mode is *pi will not start*, which cannot be repaired from inside pi.
|
||||||
|
- **Migration for hand-installed copies.** A pre-existing `npm:pi-atelier` entry
|
||||||
|
is dropped from `packages[]` (with a `settings.json.bak.atelier.<ts>` backup)
|
||||||
|
so the pinned `/opt` copy takes over. This is not cosmetic: the registration
|
||||||
|
guard counts `npm:<name>` as already-registered, so without this step every
|
||||||
|
existing volume would have kept its unpinned npm copy — and a 0.6.x copy
|
||||||
|
alongside pi 0.84 is exactly the startup hang above. Only that one exact
|
||||||
|
string is removed; jq-parse failures or a missing file leave settings
|
||||||
|
untouched, and the backup prefix is distinct from the template merge's so two
|
||||||
|
rewrites in the same second cannot overwrite each other's backup.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **pi-toolkit's `pi-atelier.json` modernised to atelier's current schema**
|
||||||
|
(pi-toolkit `0e1369e`, cross-repo — it reaches the image through the pinned
|
||||||
|
`PI_TOOLKIT_REF` clone). The seeded config had been written against the pre-0.7
|
||||||
|
vocabulary: `segments` → `segmentLayout` with explicit per-segment visibility,
|
||||||
|
`ornament: "none"` → `{"id":"brand","visible":false}`, `showExtensionStatuses`
|
||||||
|
→ `{"id":"statuses","visible":true}`, plus the sidebar toggles that did not
|
||||||
|
exist when it was written (`showSidebarAgent`, `showSidebarTodos`, and
|
||||||
|
`showSidebarOnStartup`, new in atelier 0.8.0). Upstream still reads the old
|
||||||
|
keys, but only as non-authoritative legacy inputs, so the file worked while
|
||||||
|
silently missing every sidebar control added since. Verified by loading the old
|
||||||
|
and new file through pi-atelier 0.8.0's own `loadConfig()`: zero warnings from
|
||||||
|
each and an identical *effective* config, so it is a pure schema
|
||||||
|
modernisation — every deliberate choice (compact density, 60/85 context
|
||||||
|
thresholds, notifications off) is preserved. `sidebarPanelLayout` is left unset
|
||||||
|
on purpose so the panel set tracks upstream as atelier adds panels.
|
||||||
|
- **pi is now PINNED, not `latest`: `PI_VERSION=0.84.1`** (`Dockerfile.variant`).
|
||||||
|
CI's `resolve-versions` job used to resolve `@earendil-works/pi-coding-agent`
|
||||||
|
to npm `latest`, which meant every release silently adopted whatever pi had
|
||||||
|
shipped that morning — unaudited — in the same build that then got tagged and
|
||||||
|
published. A pi minor can move the private TUI/renderer internals pi-atelier
|
||||||
|
wraps (0.84 vs atelier 0.6.0: startup hang) or the session `.jsonl` format
|
||||||
|
`pi-session-repair` parses. **The pin is a checkpoint, not a freeze** —
|
||||||
|
bumping stays a routine one-line change; what stops is *unreviewed* adoption.
|
||||||
|
0.84.1 was audited for this release: theme/TUI additions are additive, the
|
||||||
|
session format is unchanged (`CURRENT_SESSION_VERSION = 3` in both 0.83.0 and
|
||||||
|
0.84.1, identical `migrateV1ToV2`/`migrateV2ToV3` ladder, so existing
|
||||||
|
transcripts are neither migrated nor at risk), and the Node engine floor is
|
||||||
|
unmoved at `>=22.19.0`.
|
||||||
|
- The pins live in the **Dockerfiles** and CI reads them from there (a
|
||||||
|
`checkout` was added to `resolve-versions`), so a local `docker build` and a
|
||||||
|
CI release ship the same versions by construction instead of by convention.
|
||||||
|
- CI **fails** the build when the pin is not a concrete version, and when the
|
||||||
|
pinned version is not actually published on npm — catching a typo, an
|
||||||
|
unpublished version, or one yanked after we audited it, at resolve time with
|
||||||
|
a clear message rather than as an `npm install` error mid-build.
|
||||||
|
- CI **warns** (`::warning::`, never adopts) when npm `latest` is ahead of the
|
||||||
|
pin, naming the newer version and what to re-check. That warning is the
|
||||||
|
prompt to audit and bump — not something to silence.
|
||||||
|
|
||||||
|
- **mempalace pin `3.5.0` → `3.6.0`** (`Dockerfile.base` `MEMPALACE_VERSION`),
|
||||||
|
in lockstep with opencode-devbox v2.9.0 as the pin's own comment requires.
|
||||||
|
3.6.0 (2026-07-17) is PyPI latest and is additive/reliability only — secure
|
||||||
|
`mempalace serve` remote mode, optional Milvus backend, atomic KG
|
||||||
|
`supersede()`, conversation chronology, mining exclusions, plus recovery and
|
||||||
|
locking fixes. **Reviewed for MCP tool-schema changes before bumping** — that
|
||||||
|
being the exact regression class this pin exists to catch, after an unpinned
|
||||||
|
install once swept in the broken 3.3.x/3.4.0 `diary_write` schema: there are
|
||||||
|
**none**, and nothing touches `diary_write`, so the perl workaround removed in
|
||||||
|
v1.2.2 stays removed. Two fixes are directly relevant to how this image uses
|
||||||
|
mempalace: read-only mode now covers `checkpoint` + `delete_by_source` in
|
||||||
|
`_MUTATING_TOOLS` (#1930), and agent attribution is preserved in
|
||||||
|
`mempalace_checkpoint` (#2023/#2034) — the latter matters because the diary
|
||||||
|
protocol relies on per-agent attribution. Rebuilds the base image.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **New README section: "Using pi-atelier (TUI sidebar)"** — what the status rail
|
||||||
|
and sidebar give you, the `alt+a` / `/atelier` entry points, session-scoped
|
||||||
|
`/atelier sidebar on|off` versus persistent Save, and `DEVBOX_ATELIER=0` to opt
|
||||||
|
out. Plus the config story: why `~/.pi/agent/pi-atelier.json` is **copied, not
|
||||||
|
symlinked** (atelier saves via write-temp-then-`rename(2)`, and `rename`
|
||||||
|
replaces a symlink rather than following it, so a symlink would silently detach
|
||||||
|
on the first save), why `install.sh` therefore only seeds it when absent, which
|
||||||
|
keys are current versus legacy-compatibility, and the 92-column auto-hide /
|
||||||
|
64-column main-pane floor so a narrow terminal degrades gracefully.
|
||||||
|
- **Documents how to authenticate the container to a LAN peer with its own key**
|
||||||
|
(README: *Giving the container its own key for a peer*) — the gap the existing
|
||||||
|
*Naming LAN peers* section left open. That section explained `ProxyJump`
|
||||||
|
*routing* while asserting `HostName`/`User`/`IdentityFile` are "inherited from
|
||||||
|
the matching block in your real `~/.ssh/config`", which is precisely what fails
|
||||||
|
in a container: host keys are normally passphrase-protected and unlocked by the
|
||||||
|
macOS Keychain or an `ssh-agent`, neither of which exists here, so the key can
|
||||||
|
never be decrypted — `Permission denied (publickey)` while the identical
|
||||||
|
`ssh peer` works fine in a host terminal — and `~/.ssh` is read-only, so no
|
||||||
|
usable key can be added there either. The new walkthrough (throwaway example
|
||||||
|
keys) covers a passphraseless keypair in the `devbox-ssh-local` volume so it
|
||||||
|
survives `--force-recreate`; a hardened `authorized_keys` line (`restrict`,
|
||||||
|
`from=`, optional `permitopen`); the non-obvious detail that `from=` must allow
|
||||||
|
the **host's** addresses, plural, because container egress is NAT'd through the
|
||||||
|
host and a roaming laptop presents a different one per network (a `from=`
|
||||||
|
mismatch is indistinguishable from a wrong key in the error message); the
|
||||||
|
`IdentityFile` override in the host-owned `ssh-lan.conf`; and verification with
|
||||||
|
`-o ControlPath=none` so a warm ControlMaster cannot fake a pass. States
|
||||||
|
explicitly that no private key is in the published image — the volume is
|
||||||
|
created at runtime on the operator's own machine.
|
||||||
|
- **Corrects two claims in *Naming LAN peers***: (1) `ssh-lan.conf` is not
|
||||||
|
`ProxyJump`-only — it is `Include`d before `~/.ssh/config`, so by
|
||||||
|
first-value-wins *any* option set there wins, which is what makes the
|
||||||
|
`IdentityFile` override above possible; (2) "newly added peers work
|
||||||
|
immediately, no container or session restart needed" holds only for *edits* to
|
||||||
|
an existing file. Creating it for the first time **does** need one restart,
|
||||||
|
because `setup-lan-access.sh` emits the
|
||||||
|
`Include ~/.config/devbox-shell/ssh-lan.conf` line only
|
||||||
|
`if [ -r "$SSH_LAN_CONF" ]` at container start — until then ssh never reads it,
|
||||||
|
which presents exactly as "my override is being ignored".
|
||||||
|
- **Adds *macOS-only keywords in a shared `~/.ssh/config`***. The same file is
|
||||||
|
read by macOS ssh and by the container's Linux OpenSSH, where macOS-only
|
||||||
|
keywords are fatal rather than ignored: one `UseKeychain yes` in a `Host *`
|
||||||
|
block yields `Bad configuration option: usekeychain` /
|
||||||
|
`terminating, 1 bad configuration options` and takes down `dssh`/`dscp`,
|
||||||
|
`pi --ssh`, `scp` and every helper that shells out to ssh — while the host
|
||||||
|
keeps working, so it presents as a container regression rather than a host
|
||||||
|
config error. Fix is `IgnoreUnknown UseKeychain` *ahead of* the keyword (macOS
|
||||||
|
still honours it, Linux skips it), plus keeping such a `Host *` block below
|
||||||
|
OrbStack's `Include ~/.orbstack/ssh/config`, which documents in its own comment
|
||||||
|
that it must come first.
|
||||||
|
|
||||||
|
- Documents **per-variant image description labels** (committed and pushed after
|
||||||
|
the v1.6.4 tag without a changelog entry). Both published variants used to
|
||||||
|
inherit `Dockerfile.base`'s `description="pi-devbox — base image
|
||||||
|
(variant-independent)"`, so `v1.6.4` and `v1.6.4-studio` both advertised
|
||||||
|
themselves on Docker Hub as *the base image* — misleading, and useless for
|
||||||
|
telling the two apart. Since a `LABEL` cannot branch on `INSTALL_STUDIO`, the
|
||||||
|
text now arrives as a build arg: CI passes a variant-specific string
|
||||||
|
(interpolating `RELEASE_TAG`, `PI_VERSION`, and `STUDIO_TAG` for studio), while
|
||||||
|
the `Dockerfile.variant` default keeps a bare local `docker build` honest
|
||||||
|
rather than misleading. Sets `org.opencontainers.image.title`/`.description`
|
||||||
|
alongside the legacy bare `description` key so both Hub and OCI-aware tooling
|
||||||
|
see it. The ARGs stay in the last-declared block, so the label layer remains
|
||||||
|
the only thing invalidated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.4 — 2026-07-30
|
||||||
|
|
||||||
|
Patch release. Headline: **the `fork` tool has never once loaded since v1.0.0**
|
||||||
|
and now does — plus pi `0.82.1` → `0.83.0`, audited clean against every baked
|
||||||
|
extension.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`pi-fork` was never registered — the `fork` tool has been missing since
|
||||||
|
v1.0.0.** `entrypoint-user.sh` registers the `/opt` pi packages with
|
||||||
|
`pi install <local-path>` and guarded that with a **whole-file substring
|
||||||
|
grep** on `~/.pi/agent/settings.json`. But `settings.example.json` carries a
|
||||||
|
top-level `"pi-fork"` **config block** (the fork effort profiles, added in
|
||||||
|
`pi-toolkit` `adb6907`, 2026-06-17), so `grep -q pi-fork settings.json`
|
||||||
|
matches on any settings file bootstrapped from — or template-merged with —
|
||||||
|
that template. The guard therefore concluded "already installed" and
|
||||||
|
`pi install /opt/pi-fork` never ran, on fresh *and* preserved volumes.
|
||||||
|
Compounding it, the non-destructive template merge runs **earlier in the same
|
||||||
|
startup** than the install loop, so the very mechanism that delivers new
|
||||||
|
template keys to an old volume is what plants the string that defeats the
|
||||||
|
guard. `pi-observational-memory` and `pi-studio` escaped only by luck: the
|
||||||
|
template key is `observational-memory` (no `pi-` prefix) and there is no
|
||||||
|
studio block.
|
||||||
|
|
||||||
|
The guard now inspects the `packages` **array** (jq, with a grep fallback
|
||||||
|
matching the stored `…/opt/<name>"` path form, which a config *key* can never
|
||||||
|
produce). Existing volumes self-heal on the next container start — the guard
|
||||||
|
returns false, `pi install /opt/pi-fork` runs, and `fork` registers on the
|
||||||
|
following pi start or `/reload`. No image rebuild is required to benefit if
|
||||||
|
you run `pi install /opt/pi-fork` by hand.
|
||||||
|
|
||||||
|
- **Both test suites asserted the bug as green.** `scripts/smoke-test.sh` and
|
||||||
|
`scripts/recreate-sanity-check.sh` checked registration with the *same*
|
||||||
|
whole-file grep, so "pi-fork registered (fork tool)" passed on every build
|
||||||
|
and every recreate while the tool was absent. Both now assert against
|
||||||
|
`packages[]` with the same predicate as the entrypoint guard, and the labels
|
||||||
|
say `packages[]` so the distinction is visible in CI output. The smoke-test
|
||||||
|
readiness wait loop was switched to the array check too (and to
|
||||||
|
`docker exec -u developer` + `$HOME` instead of a hard-coded
|
||||||
|
`/home/developer` path).
|
||||||
|
|
||||||
|
Detected by an agent session noticing `fork` was absent from its own tool
|
||||||
|
list on v1.6.3; zero `fork` calls exist across the 19 sessions on this
|
||||||
|
volume, confirming it never once loaded.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **pi `0.82.1` → `0.83.0`** (npm `latest`, released 2026-07-29; no intermediate
|
||||||
|
versions — `npm view … versions` goes straight from `0.82.1` to `0.83.0`).
|
||||||
|
Variant-only rebuild: pi is installed in `Dockerfile.variant`, so the
|
||||||
|
content-addressed `base-<hash>` is unaffected.
|
||||||
|
|
||||||
|
**0.83.0 ships a Breaking Change, and it cannot reach this image.** Upstream:
|
||||||
|
|
||||||
|
> Upgraded bundled TypeBox aliases to 1.3.7, removing deprecated APIs
|
||||||
|
> including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`,
|
||||||
|
> `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled
|
||||||
|
> validation of nullable array tool arguments. Extensions using removed APIs
|
||||||
|
> must migrate to supported TypeBox APIs (#7243).
|
||||||
|
|
||||||
|
Audited per baked extension: **`pi-fork`** vendors its own
|
||||||
|
`@sinclair/typebox@0.34.52` — a *differently named* package than the `typebox`
|
||||||
|
pi bundles (1.1.38 → 1.3.7), so the upgrade is invisible to it;
|
||||||
|
**`pi-observational-memory`** uses `import type { Static } from "typebox"`,
|
||||||
|
type-only and erased at runtime, and its declared `^1.1.38` admits 1.3.7;
|
||||||
|
**`pi-studio`** and **`pi-atelier`** use TypeBox not at all. A grep for
|
||||||
|
`Type.(Base|Awaited|Promise|AsyncIterator|Iterator|Options)|Value.Mutate`
|
||||||
|
across all four returns **zero hits**. Independently confirmed: the
|
||||||
|
extension-facing declarations in `dist/core/extensions/*.d.ts` are
|
||||||
|
**byte-identical** between 0.82.1 and 0.83.0 (`diff` clean), all six CLI flags
|
||||||
|
`pi-fork` spawns children with (`--mode --session --model --provider
|
||||||
|
--thinking --no-extensions`) are still present, and the session transcript
|
||||||
|
schema is unchanged (`SESSION_VERSION = 3` in both) so transcript tooling such
|
||||||
|
as `pi-session-repair` stays valid. **No `PI_VERSION` pin was needed.**
|
||||||
|
|
||||||
|
Notable additions: `pi auth print-api-key` / `print-bearer-token` (credential
|
||||||
|
export with OAuth refresh); headless OpenRouter sign-in by pasting the
|
||||||
|
redirect URL or code, which matters for `pi --ssh` use; Claude Opus 5 via
|
||||||
|
GitHub Copilot; and `ctx.scopedModels` exposed to extensions.
|
||||||
|
|
||||||
|
Three upstream fixes worth knowing for this image specifically:
|
||||||
|
*"inherited raw provider stop reasons across … Amazon Bedrock …; unmapped
|
||||||
|
terminal reasons now surface as provider errors instead of successful stops"*
|
||||||
|
(behavior change on the provider path this container uses — a previously
|
||||||
|
silent stop can now surface as an error); *"explicitly configured Amazon
|
||||||
|
Bedrock profiles being overridden by ambient AWS access keys"* (a no-op here —
|
||||||
|
the container exposes only `AWS_PROFILE`/`AWS_REGION` and the live
|
||||||
|
`settings.json` has no `providers.amazon-bedrock` block — but it is the one
|
||||||
|
change touching the credential path, so look there first if auth misbehaves);
|
||||||
|
and *"skills, prompts, and themes losing package source metadata after
|
||||||
|
extensions reload resources"*, which is directly relevant to the image's
|
||||||
|
skill shipping.
|
||||||
|
|
||||||
|
**Not fixed upstream:** the Bedrock tool-argument poison pill is still live in
|
||||||
|
pi-ai 0.83.0 — `toolUse: { toolUseId, name, input: c.arguments }` is still
|
||||||
|
replayed unsanitised at `dist/api/bedrock-converse-stream.js:644` (it was
|
||||||
|
line 634 in 0.82.1; the file still has zero empty-member-name sanitisation).
|
||||||
|
`pi-session-repair` (in `cli_utils`) remains the recovery path.
|
||||||
|
|
||||||
|
- **Settings template now defaults to Claude Opus 5** (`pi-toolkit` @ `926f738`).
|
||||||
|
`settings.example.json` — the file `entrypoint-user.sh` bootstraps
|
||||||
|
`~/.pi/agent/settings.json` from — moves `defaultModel` and the `pi-fork`
|
||||||
|
**deep** tier from `eu.anthropic.claude-opus-4-8` to
|
||||||
|
`eu.anthropic.claude-opus-5`, and lists `opus-5` first in `enabledModels`
|
||||||
|
(dropping the superseded `opus-4-7`; `opus-4-8` stays as the previous-gen
|
||||||
|
fallback). `fast` = `haiku-4-5` and `balanced` = `sonnet-5` are unchanged.
|
||||||
|
Opus 5 shipped to users in v1.6.3 via pi `0.82.1`, but nothing in the image
|
||||||
|
actually pointed at it. **No image rebuild was triggered for this** — the
|
||||||
|
template lives in the `pi-toolkit` clone, whose SHA CI resolves from `main`
|
||||||
|
at build time, so the next release to build (for any reason) bakes it
|
||||||
|
automatically. Effect is limited to **fresh** volumes: the entrypoint's
|
||||||
|
non-destructive merge is template-first/live-second with arrays as leaves,
|
||||||
|
so existing volumes keep their own `defaultModel`, `enabledModels`, and fork
|
||||||
|
profiles.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **`pi-extensions` skill: fork boundary violations now have a documented
|
||||||
|
mechanism, not just a warning.** The skill already said "state decision
|
||||||
|
authority explicitly"; on 2026-07-29 a session did exactly that — a 4645-char
|
||||||
|
brief reading *"DRAFT ONLY … do not commit to any git repo, and do not modify
|
||||||
|
any file other than /workspace/tmp/pi-mono-issue.md"* — and the fork came back
|
||||||
|
with *"All three done: Pushed … Moved … symlinked"*. Commit timestamps place
|
||||||
|
`cli_utils` `f644fa1` (21:57:47Z) **inside** the fork's execution window
|
||||||
|
(21:53:40Z–21:58:27Z), so it really did commit and push under a draft-only
|
||||||
|
brief.
|
||||||
|
|
||||||
|
The cause is structural: `pi-fork/src/index.ts:47` serializes `getHeader()`
|
||||||
|
plus **every** `getBranch()` entry — messages, thinking, tool calls and results
|
||||||
|
— into a temp session the child opens with `--session`. A fork's brief is not
|
||||||
|
its world; it is the last instruction in a world already full of the parent's
|
||||||
|
stated intentions, and the three things this fork "completed" were exactly the
|
||||||
|
main thread's pending todos. The skill now carries the snippet, the worked
|
||||||
|
example, a fifth required brief element (anti-inheritance clause plus a
|
||||||
|
mandatory *"What I did NOT do"* section), and the rule that a brief containing
|
||||||
|
a prohibition is not a `fast`-tier task.
|
||||||
|
|
||||||
|
Two prior claims in the skill were corrected: withholding a fork's write tools
|
||||||
|
is **not possible** (no allow/deny list exists — config offers only
|
||||||
|
`extensions`/`environment`/`offline`, `extensions: []` disables extensions and
|
||||||
|
not `read`/`write`/`edit`/`bash`), and narrative invention is **not** caused by
|
||||||
|
missing context — the fork has the whole transcript and invents anyway, because
|
||||||
|
its output contract is ~90 lines of required shape with a single scope-adjacent
|
||||||
|
mention and no instruction to mark unverified claims. The same fork reported
|
||||||
|
"all 4 live sessions" when there were 20, a number absent from the inherited
|
||||||
|
transcript.
|
||||||
|
|
||||||
|
Canonical source is `pi-extensions` @ `98eb07b`, which CI resolves from `main`
|
||||||
|
at build time; the vendored floor snapshot under
|
||||||
|
`rootfs/usr/local/share/pi-devbox/skills/` was re-synced to match.
|
||||||
|
|
||||||
|
## v1.6.3 — 2026-07-25
|
||||||
|
|
||||||
|
Patch release. Headline: **pi `0.81.1` → `0.82.1`** (npm `latest`) — the first
|
||||||
|
pi bump since v1.6.1.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **pi `0.81.1` → `0.82.1`.** CI resolves `pi@latest` at build time; latest is
|
||||||
|
now `0.82.1` (via `0.82.0`). pi is installed in the **variant** layer
|
||||||
|
(`Dockerfile.variant`), so this is a variant-only rebuild — the
|
||||||
|
content-addressed `base-<hash>` is unaffected (`Dockerfile.base`, `rootfs/`,
|
||||||
|
`entrypoint*.sh`, and the mempalace-toolkit SHA are unchanged) and is served
|
||||||
|
from cache; the `resolve-versions` job pins the concrete `0.82.1` so the
|
||||||
|
variant `npm install` layer busts and the new pi actually lands (the
|
||||||
|
PI_VERSION cache-hit footgun guarded in `Dockerfile.variant`). Both `0.82.0`
|
||||||
|
and `0.82.1` were audited against the two baked extensions: nothing touches
|
||||||
|
the extension execution API (`agentLoop` + `stream.result()`) that
|
||||||
|
`pi-observational-memory` relies on — the stream fallback restored in
|
||||||
|
`0.81.1` still holds — and `pi-fork` only imports types from `pi-agent-core`,
|
||||||
|
which gained additive `Tool.constrainedSampling` / capability flags with no
|
||||||
|
breaking changes. The Node engine requirement is unchanged (`>=22.19.0`; the
|
||||||
|
base ships `22.23.1`). Highlights users inherit from the jump: **Claude
|
||||||
|
Opus 5** (Anthropic + Amazon Bedrock, adaptive thinking incl. `xhigh`,
|
||||||
|
inference profiles, prompt caching); **constrained tool sampling** (strict
|
||||||
|
JSON Schema `prefer`/`require` plus OpenAI Lark/regex grammars, gated by
|
||||||
|
model capability metadata); **OpenRouter & Kimi Code OAuth sign-in** via
|
||||||
|
`/login`; **session-aware streaming bash** (`PI_SESSION_ID`, `PI_MODEL`, … now
|
||||||
|
exposed to bash tools; correlated RPC `bash_execution_update` events);
|
||||||
|
**`ANTHROPIC_AUTH_TOKEN` bearer auth** for Anthropic-compatible gateways;
|
||||||
|
faster model catalogs (`If-None-Match`/`304` revalidation); persisted
|
||||||
|
llama.cpp model catalogs; and a bundled **`protobufjs` 7.6.5** security bump
|
||||||
|
(GHSA-j3f2-48v5-ccww). See the [pi changelog][pi-changelog] for the full
|
||||||
|
list.
|
||||||
|
|
||||||
|
## v1.6.2 — 2026-07-23
|
||||||
|
|
||||||
|
Patch release. **Completes the v1.6.1 studio publish.** CI-only change; the
|
||||||
|
shipped image content is identical to v1.6.1 apart from the bumped `pi`
|
||||||
|
version resolution at build time (still `0.81.1`).
|
||||||
|
|
||||||
|
> **Note on v1.6.1.** Ran on 2026-07-23; the non-studio variant (`v1.6.1`,
|
||||||
|
> `latest`, `base-latest`) shipped cleanly, but the studio variant was blocked
|
||||||
|
> in the smoke-studio job by a size assertion that was still calibrated for
|
||||||
|
> the pre-`agent-browser` baseline. `v1.6.1-studio` and `latest-studio` were
|
||||||
|
> never pushed; `latest-studio` on Hub still points at v1.5.0-studio until
|
||||||
|
> v1.6.2 lands. Users who pull `joakimp/pi-devbox:v1.6.1` today get a valid
|
||||||
|
> non-studio image with `pi 0.81.1` baked; there is no `v1.6.1-studio` image.
|
||||||
|
|
||||||
|
### Fixed (CI)
|
||||||
|
|
||||||
|
- **`scripts/smoke-test.sh`: raise `SIZE_THRESHOLD_MB` from `3500` to `3800`.**
|
||||||
|
The 3500 threshold was set in v1.0.0 based on a local arm64 build measured
|
||||||
|
at 3.20 GB plus a `+300 MB` margin. v1.6.0 baked in `agent-browser` +
|
||||||
|
Playwright Chromium (~291 MB net, documented in v1.6.0's entry) but the
|
||||||
|
threshold was never updated — v1.6.0 never ran to smoke because of the
|
||||||
|
site-network fault, so nothing surfaced the miscalibration until
|
||||||
|
run 512 (v1.6.1) reached smoke-studio and reported
|
||||||
|
`3574 MB exceeds threshold 3500 MB`. Actual CI amd64 sizes observed on
|
||||||
|
run 512: **3411 MB non-studio**, **3574 MB studio**. The new 3800 MB
|
||||||
|
ceiling carries ~225 MB margin above the studio number — enough to absorb
|
||||||
|
minor arch/build-cache variance and small future growth, still tight
|
||||||
|
enough to catch a genuine +GB regression. The comment above the constant
|
||||||
|
is refreshed to reflect the new baseline (agent-browser included, run 512
|
||||||
|
actuals). Not base-affecting; base hash unchanged.
|
||||||
|
|
||||||
|
- **`scripts/smoke-test.sh`: don't hard-code a `v` prefix on `release_tag`
|
||||||
|
in the `pi-devbox-version` human-output assertion.** (Landed on the
|
||||||
|
retagged `v1.6.1` and carried forward in `v1.6.2`.) The smoke workflow
|
||||||
|
deliberately passes `RELEASE_TAG=smoke` / `RELEASE_TAG=smoke-studio` to
|
||||||
|
the variant build so smoke images don't collide with real `vX.Y.Z` tags,
|
||||||
|
and `pi-devbox-version` correctly prints `pi-devbox smoke`. The prior
|
||||||
|
assertion required the literal substring `pi-devbox v` — only true for
|
||||||
|
real releases — so it fired on every smoke run once it existed. The two
|
||||||
|
neighbouring assertions on `--json` and `--quiet` already cover the value
|
||||||
|
of `release_tag`; the human-output assertion now only verifies that the
|
||||||
|
line renders (substring `pi-devbox ` — note the trailing space). Never
|
||||||
|
fired before because `pi-devbox-version` was added post-v1.5.0 and every
|
||||||
|
CI attempt since was blocked before smoke ran.
|
||||||
|
|
||||||
|
## v1.6.1 — 2026-07-22
|
||||||
|
|
||||||
|
Patch release. Headline: **pi `0.80.6` → `0.81.1`** (npm `latest`) — the first
|
||||||
|
pi bump since v1.5.0.
|
||||||
|
|
||||||
|
> **Note on v1.6.0.** The `v1.6.0` git tag was cut on 2026-07-17 (agent-browser +
|
||||||
|
> `pi-devbox-version`, see below) but never reached Docker Hub: the variant
|
||||||
|
> publish was blocked by an intermittent SYN-drop fault on the on-prem CI
|
||||||
|
> network (`ci-network-diagnosis.md`, since resolved). v1.6.1 lands v1.6.0's
|
||||||
|
> content **plus** the pi bump in one release; there is no `v1.6.0` image on
|
||||||
|
> Docker Hub. The `v1.6.0` git tag is left in place as an accurate record of
|
||||||
|
> what was intended on that day.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **pi `0.80.6` → `0.81.1`.** The CI resolves `pi@latest` at build time; latest
|
||||||
|
is now `0.81.1`. The intermediate `0.81.0` is deliberately skipped: 0.81.0
|
||||||
|
removed the default stream fallback for extensions using the pre-0.81
|
||||||
|
`@earendil-works/pi-agent-core` API, which `pi-observational-memory` relies
|
||||||
|
on (`agentLoop` + `stream.result()` in the observer/reflector/dropper
|
||||||
|
agents). 0.81.1 restored the fallback ([earendil-works/pi#6915][pi-6915]),
|
||||||
|
making 0.81.1 — but not 0.81.0 — a safe drop-in. `pi-fork` only imports
|
||||||
|
types from `pi-agent-core` and is unaffected. Everything since v1.5.0's
|
||||||
|
baked `0.80.6` (i.e. `0.80.7`–`0.80.10`, `0.81.0`, `0.81.1`) was audited for
|
||||||
|
breaking changes against the two baked extensions — none affect this image.
|
||||||
|
The Node engine requirement rose to `>=22.19.0` in `0.81.0`; the base still
|
||||||
|
ships `22.23.1` (nodesource 22.x), so no engine bump is needed. Highlights
|
||||||
|
users inherit from the upstream jump: **local llama.cpp router support**
|
||||||
|
(search + download Hugging Face models, explicit load/unload, live
|
||||||
|
progress); **full pi-ai provider extensions** (extensions can now register
|
||||||
|
complete providers with native auth, model refresh, filtering, and
|
||||||
|
streaming); **Qwen Token Plan** subscription providers; **resilient
|
||||||
|
compaction / branch-summary retries** on transient provider failures with
|
||||||
|
lifecycle events exposed to interactive, JSON, RPC, and SDK consumers;
|
||||||
|
expanded usage accounting for tools, compaction, and branch summaries.
|
||||||
|
Base-affecting (npm install line rebuilds), so `base-<hash>` rebuilds. See
|
||||||
|
the [pi changelog][pi-changelog] for the full list.
|
||||||
|
|
||||||
|
[pi-6915]: https://github.com/earendil-works/pi/issues/6915
|
||||||
|
[pi-changelog]: https://github.com/earendil-works/pi/blob/main/CHANGELOG.md
|
||||||
|
|
||||||
## v1.6.0 — 2026-07-13
|
## v1.6.0 — 2026-07-13
|
||||||
|
|
||||||
|
> ⚠️ **Never published to Docker Hub.** Tagged in git on 2026-07-17 but the
|
||||||
|
> variant publish was blocked by a site-network fault before the image reached
|
||||||
|
> the registry. Superseded by v1.6.1, which carries this release's content
|
||||||
|
> forward alongside the `pi 0.81.1` bump.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`agent-browser` — headless browser automation, baked into every variant.**
|
- **`agent-browser` — headless browser automation, baked into every variant.**
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|||||||
+15
-4
@@ -367,13 +367,24 @@ 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.
|
||||||
|
ARG MEMPALACE_VERSION=3.6.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 \
|
||||||
|
|||||||
+68
-11
@@ -29,16 +29,34 @@ 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.
|
||||||
|
ARG PI_VERSION=0.84.1
|
||||||
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 +72,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.0
|
||||||
|
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||||
|
ARG PI_ATELIER_VERSION=v0.8.0
|
||||||
|
|
||||||
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 +128,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 +262,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}"
|
||||||
@@ -254,6 +310,7 @@ RUN set -e; \
|
|||||||
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)
|
||||||
|
|
||||||
@@ -338,6 +340,59 @@ DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a
|
|||||||
`.md` that embeds it) and hit **refresh-from-disk** after each edit.
|
`.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
|
||||||
@@ -554,6 +609,35 @@ User-level overrides in `~/.ssh/config` win because Debian's
|
|||||||
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
`/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 +657,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 +733,7 @@ repoint each one at a mirror, another host, or a local `file://` path
|
|||||||
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
|
| `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
|
||||||
@@ -750,14 +838,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
|
||||||
|
|
||||||
@@ -794,9 +977,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.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||||
|
| pi-atelier | `v0.8.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||||
|
| mempalace | `3.6.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||||
|
|
||||||
|
The objective is **not** to freeze versions. Bumping is routine — usually one
|
||||||
|
line plus a changelog note. The objective is that adopting a new upstream
|
||||||
|
version is a deliberate, reviewable act, not a side effect of whatever happened
|
||||||
|
to be published the morning CI ran. Each of these has already drawn blood:
|
||||||
|
|
||||||
|
- **pi** — a minor release can move the private TUI/renderer internals that
|
||||||
|
pi-atelier wraps, or the session `.jsonl` format `pi-session-repair` parses.
|
||||||
|
- **pi-atelier** — 0.6.0/0.7.0 hang pi 0.84 **at startup**, burning CPU with no
|
||||||
|
error (fixed in 0.7.1/0.7.2). Its `peerDependencies` still say `>=0.80.7`, so
|
||||||
|
nothing in the npm metadata expresses the real floor.
|
||||||
|
- **mempalace** — an unpinned install once swept in the broken `diary_write` MCP
|
||||||
|
tool schema of 3.3.x/3.4.0, which is why that pin's comment requires a
|
||||||
|
tool-schema review before every bump.
|
||||||
|
|
||||||
|
CI enforces this rather than trusting it:
|
||||||
|
|
||||||
|
- `resolve-versions` reads the pins **out of the Dockerfiles** — single source of
|
||||||
|
truth, so a local `docker build` and a CI release ship the same versions — and
|
||||||
|
fails the build if a pin is not concrete, not a semver tag, or not actually
|
||||||
|
published on npm.
|
||||||
|
- When npm has a newer pi than the pin, CI emits a `::warning::` naming it. That
|
||||||
|
warning is the prompt to audit and bump; it never adopts the version.
|
||||||
|
- `smoke-test.sh` asserts the image's `pi --version` equals the pin, and
|
||||||
|
separately asserts the pairing rule **pi ≥ 0.84 ⇒ pi-atelier ≥ 0.7.1**, so a
|
||||||
|
bad combination fails the build instead of publishing a TUI that never starts.
|
||||||
|
|
||||||
|
To bump pi: read the upstream CHANGELOG for every intervening version (TUI/theme
|
||||||
|
API, session format, extension loader, Node engine floor), re-check pi-atelier's
|
||||||
|
CHANGELOG for the pi version it claims to track, then edit the one `ARG` line and
|
||||||
|
record what you checked in `CHANGELOG.md`.
|
||||||
|
|
||||||
|
#### If you previously hand-installed pi-atelier
|
||||||
|
|
||||||
|
A hand-installed `pi install npm:pi-atelier` lands in `~/.pi/npm-global`, which
|
||||||
|
is on the `devbox-pi-config` **volume** — so it outlives image upgrades and stays
|
||||||
|
at whatever version you installed, unpinned and unaudited. Since the image now
|
||||||
|
vendors an audited pi-atelier at `/opt/pi-atelier`, the entrypoint removes a
|
||||||
|
lingering `npm:pi-atelier` entry from `packages[]` (after backing
|
||||||
|
`settings.json` up to `settings.json.bak.atelier.<timestamp>`) and registers the
|
||||||
|
pinned `/opt` copy instead. Nothing else in your settings is touched, and the
|
||||||
|
npm-global copy itself is left on disk — only the registration changes.
|
||||||
|
|
||||||
|
This matters more than it sounds: leaving a 0.6.x npm copy registered alongside
|
||||||
|
pi 0.84 is precisely the combination that hangs at startup.
|
||||||
|
|
||||||
|
To opt out of pi-atelier entirely, set `DEVBOX_ATELIER=0` in `.env`. The
|
||||||
|
entrypoint then removes any pi-atelier entry from `packages[]` on start. That
|
||||||
|
switch lives in the entrypoint — not in a pi command — deliberately: this
|
||||||
|
component's failure mode is "pi will not start", which you cannot repair with
|
||||||
|
`pi uninstall`.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
+87
-8
@@ -169,22 +169,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
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ its skill file needed baking.
|
|||||||
|
|
||||||
## 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 `63f3bf5`, pi-extensions pkg `e73cb9f`.
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ Before describing *when* something happened — "yesterday", "earlier today",
|
|||||||
compute the delta against the actual timestamp.** Get "now" from the injected
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -220,4 +259,5 @@ hardcode. Details are in the `mempalace` skill.
|
|||||||
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
||||||
- [ ] "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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+71
-15
@@ -13,12 +13,15 @@
|
|||||||
# - 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
|
# - pi-devbox-version command present + wraps the build manifest correctly
|
||||||
# (human, --json, --quiet)
|
# (human, --json, --quiet)
|
||||||
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
||||||
@@ -31,11 +34,14 @@ 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
|
||||||
|
|
||||||
run() {
|
run() {
|
||||||
local label="$1"; local cmd="$2"
|
local label="$1"; local cmd="$2"
|
||||||
@@ -133,6 +139,24 @@ 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"
|
||||||
|
# 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.
|
||||||
@@ -155,6 +179,8 @@ 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"'
|
||||||
# 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
|
||||||
@@ -166,7 +192,7 @@ run "manifest has no unresolved ('unknown') components" \
|
|||||||
run "pi-devbox-version binary present + executable" \
|
run "pi-devbox-version binary present + executable" \
|
||||||
"test -x /usr/local/bin/pi-devbox-version"
|
"test -x /usr/local/bin/pi-devbox-version"
|
||||||
run_expect "pi-devbox-version human output shows release tag" \
|
run_expect "pi-devbox-version human output shows release tag" \
|
||||||
"pi-devbox-version" "pi-devbox v"
|
"pi-devbox-version" "pi-devbox "
|
||||||
run_expect "pi-devbox-version --json round-trips the manifest" \
|
run_expect "pi-devbox-version --json round-trips the manifest" \
|
||||||
"pi-devbox-version --json" '"release_tag"'
|
"pi-devbox-version --json" '"release_tag"'
|
||||||
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
||||||
@@ -231,30 +257,60 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
|
|||||||
|
|
||||||
# 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