Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf265f6d54 | |||
| 4efa84bb42 | |||
| fa38e3e102 | |||
| 1d53e008d4 | |||
| 28e324d68f | |||
| 3da7312166 | |||
| 4ee94da523 | |||
| 3f46f5fd29 | |||
| 09ce100c92 | |||
| 11e512e497 | |||
| eb70928b16 | |||
| 32448c7353 | |||
| 56e3742a2d | |||
| 784fad78f3 | |||
| c2c42c34a2 | |||
| f3b3748223 | |||
| 12f99c49e3 | |||
| 93eb3dbca7 | |||
| e026f6f65e | |||
| 9d0b3dec0b | |||
| cb8969ef2c | |||
| 31f36182bf | |||
| 2278b22ba7 | |||
| 37fcbfcf04 | |||
| 16fddebd43 | |||
| e3b38cdb0b | |||
| ee6cb9e62a | |||
| 0d324f1855 |
@@ -169,6 +169,30 @@ GIT_USER_EMAIL=
|
|||||||
# AWS_ACCESS_KEY_ID=
|
# AWS_ACCESS_KEY_ID=
|
||||||
# AWS_SECRET_ACCESS_KEY=
|
# AWS_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
# ── AWS SSO keeper (pi-extensions aws-sso.ts; all optional) ──────────
|
||||||
|
# Renews the AWS SSO session for Bedrock when it ends (aws sso login); you or
|
||||||
|
# your signed-in browser still approve each login. Off until `/aws-sso on`.
|
||||||
|
# Guide: pi-extensions docs/aws-sso.md. Recreate the container after editing.
|
||||||
|
# PI_AWS_SSO_REFRESH=1 # force the keeper on (0 = force off)
|
||||||
|
# PI_AWS_SSO_MODE=local # local (default): browser on the Docker host
|
||||||
|
# # remote: device code + push (headless/ssh+tmux)
|
||||||
|
# # (a mode set with /aws-sso local|remote wins)
|
||||||
|
# PI_AWS_SSO_NTFY= # push target: https://ntfy.sh/<long-random-topic>
|
||||||
|
# # anyone knowing the topic can read the pushes
|
||||||
|
# PI_AWS_SSO_NTFY_TOKEN= # token for a protected topic (own ntfy server)
|
||||||
|
# PI_AWS_SSO_SSH_HOST=host # local: SSH alias of the Docker host
|
||||||
|
# PI_AWS_SSO_LOCAL_CMD= # local: command run on the host; {session} =
|
||||||
|
# # sso-session name. Default (POSIX host):
|
||||||
|
# # exec "${SHELL:-/bin/sh}" -lc 'aws sso login --sso-session {session}'
|
||||||
|
# # (Compose expands ${...} in .env: write $$ for a literal $)
|
||||||
|
|
||||||
|
# ── Shared custom model providers (pi models.json) ────────────────────
|
||||||
|
# If /opt/fleet-pi/models.json exists (mount a host DIRECTORY there, read-only),
|
||||||
|
# the entrypoint links ~/.pi/agent/models.json to it on every start. A real file
|
||||||
|
# or a link of your own is never replaced. README: "Custom model providers".
|
||||||
|
# Put API keys in this .env and reference them as "$NAME" in models.json.
|
||||||
|
# PI_MODELS_JSON=/opt/fleet-pi/models.json # another path; off = disable
|
||||||
|
|
||||||
# ── Skillset (agent skills and instructions) ─────────────────────────
|
# ── Skillset (agent skills and instructions) ─────────────────────────
|
||||||
# If you have a skillset repo, the entrypoint auto-deploys skills and
|
# If you have a skillset repo, the entrypoint auto-deploys skills and
|
||||||
# instructions on container start using relative symlinks.
|
# instructions on container start using relative symlinks.
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ name: Publish Docker Image
|
|||||||
# Pipeline shape:
|
# Pipeline shape:
|
||||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||||
# + entrypoints; probe Docker Hub for existing tag.
|
# + entrypoints; probe Docker Hub for existing tag.
|
||||||
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
|
# 2. resolve-versions pi and mempalace from their pins (checked on npm/
|
||||||
# to commit SHAs (defeats registry-buildcache
|
# PyPI); pi-obsmem and pi-atelier from their pins in
|
||||||
# cache-hit footgun on byte-identical build args).
|
# Dockerfile.variant; pi-fork and the Gitea repos to
|
||||||
|
# their branch heads, pi-studio to its newest tag;
|
||||||
|
# all refs to commit SHAs (defeats
|
||||||
|
# registry-buildcache cache-hit footgun on
|
||||||
|
# byte-identical build args).
|
||||||
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
|
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
|
||||||
# 4. smoke amd64-only build of the variant FROMing the base
|
# 4. smoke amd64-only build of the variant FROMing the base
|
||||||
# tag; runs scripts/smoke-test.sh.
|
# tag; runs scripts/smoke-test.sh.
|
||||||
@@ -108,7 +112,14 @@ jobs:
|
|||||||
id: compute
|
id: compute
|
||||||
run: |
|
run: |
|
||||||
# Hash inputs that determine the base image's contents.
|
# Hash inputs that determine the base image's contents.
|
||||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
# Order is fixed via `find -print0 | LC_ALL=C sort -z`. The LC_ALL=C is
|
||||||
|
# load-bearing: sort collates per locale, and a dictionary locale
|
||||||
|
# (sv_SE/en_US.UTF-8) orders rootfs differently from byte order, giving
|
||||||
|
# a different hash for identical content (measured 2026-09-22:
|
||||||
|
# base-f40c4b7b103d under C/C.UTF-8 vs base-d8df62216a81 under
|
||||||
|
# sv_SE.UTF-8). The runner ships LANG=C.UTF-8 today, so this pin
|
||||||
|
# changes nothing now; it stops the hash depending on that accident.
|
||||||
|
# Predicting base_tag locally MUST use the same prefix on this sort.
|
||||||
# Junk filters: __pycache__/*.pyc and macOS metadata are gitignored
|
# Junk filters: __pycache__/*.pyc and macOS metadata are gitignored
|
||||||
# locally but still picked up by `find rootfs -type f` on a clean CI
|
# locally but still picked up by `find rootfs -type f` on a clean CI
|
||||||
# checkout. Exclude them defensively.
|
# checkout. Exclude them defensively.
|
||||||
@@ -120,7 +131,7 @@ jobs:
|
|||||||
! -name '*.pyc' \
|
! -name '*.pyc' \
|
||||||
! -name '.DS_Store' \
|
! -name '.DS_Store' \
|
||||||
! -name '._*' \
|
! -name '._*' \
|
||||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
-print0 2>/dev/null | LC_ALL=C sort -z | xargs -0 cat 2>/dev/null
|
||||||
cat entrypoint.sh entrypoint-user.sh
|
cat entrypoint.sh entrypoint-user.sh
|
||||||
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
||||||
# resolves to a SHA; fold it in so base_tag changes when the
|
# resolves to a SHA; fold it in so base_tag changes when the
|
||||||
@@ -174,6 +185,29 @@ jobs:
|
|||||||
#
|
#
|
||||||
# ~40 s, ahead of everything expensive, and it runs scripts/lint-shell.sh --
|
# ~40 s, ahead of everything expensive, and it runs scripts/lint-shell.sh --
|
||||||
# the same file lint.yml calls, not a second copy that drifts.
|
# the same file lint.yml calls, not a second copy that drifts.
|
||||||
|
#
|
||||||
|
# scripts/check-doc-drift.sh is here for the same reason and closes the same
|
||||||
|
# gap -- and for it the gap is strictly worse. Shellcheck judges the TREE:
|
||||||
|
# green on main is still green at the tag, because the bytes did not move.
|
||||||
|
# Check 9 judges the tree against UPSTREAM NOW, and the floating refs it
|
||||||
|
# watches (PI_FORK_REF=master and friends, which resolve-versions below turns
|
||||||
|
# into SHAs) move with no commit in this repo at all -- so a green reading on
|
||||||
|
# main carries no information about tag time, and that window is exactly where
|
||||||
|
# releases live. Worked example: pi-observational-memory moved cba0334 ->
|
||||||
|
# e7d77dc the day AFTER v1.9.3 was tagged. Nothing went red; it surfaced only
|
||||||
|
# because someone ran the gate by hand. Without this step a tag can publish a
|
||||||
|
# component that no CHANGELOG entry names, and the floating ref means no other
|
||||||
|
# file in the repo would record it either.
|
||||||
|
#
|
||||||
|
# Adds ~8 s. No token and no built image: it takes the last published vX.Y.Z
|
||||||
|
# from the Hub tags API, that release's baked labels from the anonymous
|
||||||
|
# registry API, and `git ls-remote`s each upstream -- so a plain checkout is
|
||||||
|
# enough, with no tags or history to fetch. Offline it SKIPs loudly and
|
||||||
|
# counted rather than passing, so an outage degrades it to a visible skip
|
||||||
|
# instead of a false green. Residual, accepted: it resolves the refs seconds
|
||||||
|
# before resolve-versions resolves them again, so an upstream push landing
|
||||||
|
# inside that window still slips through -- and check 9 on the NEXT release
|
||||||
|
# would then name it.
|
||||||
lint-gate:
|
lint-gate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
@@ -189,6 +223,16 @@ jobs:
|
|||||||
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
|
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
|
||||||
run: bash scripts/lint-shell.sh
|
run: bash scripts/lint-shell.sh
|
||||||
|
|
||||||
|
- name: Components the next build would bake differently are named in the CHANGELOG
|
||||||
|
run: bash scripts/check-doc-drift.sh
|
||||||
|
|
||||||
|
# Runs HERE as well as in lint.yml deliberately. A guard that gates only
|
||||||
|
# `push` lets a tag regress — the adoption slip that let doc-drift land
|
||||||
|
# 27 h after the v1.9.3 tag. This one protects the tag build from the
|
||||||
|
# zero-failing-step failure mode that killed v1.10.0 and v1.10.1.
|
||||||
|
- name: Dockerfile.variant still opens with its BuildKit check directive
|
||||||
|
run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant
|
||||||
|
|
||||||
resolve-versions:
|
resolve-versions:
|
||||||
# Gated: a defective tree must not reach a 46-minute base build.
|
# Gated: a defective tree must not reach a 46-minute base build.
|
||||||
needs: [lint-gate]
|
needs: [lint-gate]
|
||||||
@@ -346,15 +390,37 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "mempalace_version=${MEMPALACE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "mempalace_version=${MEMPALACE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
# pi-fork (GitHub) → master's commit SHA. Floats by design.
|
||||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||||
require_sha PI_FORK_REF "$FORK_REF"
|
require_sha PI_FORK_REF "$FORK_REF"
|
||||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
|
||||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
|
||||||
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
|
|
||||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# pi-observational-memory → the PINNED SHA in Dockerfile.variant.
|
||||||
|
# Until v1.11.2 this resolved `master` and passed it as --build-arg,
|
||||||
|
# which OVERRIDES the ARG default: the pin bound only local builds,
|
||||||
|
# and CI baked it only while master == pin (v1.9.5, v1.10.2, v1.11.0).
|
||||||
|
# The pin is a master SHA, not a tag, on purpose: obsmem tags rarely
|
||||||
|
# and lands what pi needs on master (PR #83, pi 0.87's finishTurn,
|
||||||
|
# never reached a tag). Its peerDependencies are `*`, so a mismatch
|
||||||
|
# with pi is silent at runtime. Adopting master is therefore a
|
||||||
|
# deliberate one-line bump after audit, never a side effect of the day
|
||||||
|
# CI ran. Read like atelier's tag, and require the full 40 chars: a
|
||||||
|
# short SHA would also downgrade check-doc-drift.sh's check to a SKIP.
|
||||||
|
OBSMEM_REF=$(sed -n 's/^ARG PI_OBSMEM_REF=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
||||||
|
if ! printf '%s' "${OBSMEM_REF:-}" | grep -qiE '^[0-9a-f]{40}$'; then
|
||||||
|
echo "::error::ARG PI_OBSMEM_REF in Dockerfile.variant is not a full 40-char commit SHA (got '${OBSMEM_REF:-<empty>}'). pi-observational-memory must stay pinned to an audited commit — see the note above that ARG."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
# Informational only, like pi's npm drift warning: master moving past
|
||||||
|
# the pin is how a fix pi needs becomes visible. `|| true` so a GitHub
|
||||||
|
# hiccup cannot fail a release whose pin is already in the repo.
|
||||||
|
OBSMEM_MASTER=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||||
|
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
||||||
|
if printf '%s' "${OBSMEM_MASTER:-}" | grep -qiE '^[0-9a-f]{40}$' && [ "${OBSMEM_MASTER}" != "${OBSMEM_REF}" ]; then
|
||||||
|
echo "::warning::pi-observational-memory master is ${OBSMEM_MASTER:0:7}; this build ships the audited pin ${OBSMEM_REF:0:7}. To adopt it: read https://github.com/elpapi42/pi-observational-memory/compare/${OBSMEM_REF}...${OBSMEM_MASTER} (the observer/reflector/dropper workers and their pi API use), re-run the turn-cap probe against the pinned pi, then bump ARG PI_OBSMEM_REF in Dockerfile.variant and name the SHA in CHANGELOG.md."
|
||||||
|
fi
|
||||||
|
|
||||||
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
||||||
# (which track a branch head) atelier wraps pi's private TUI
|
# (which track a branch head) atelier wraps pi's private TUI
|
||||||
@@ -443,7 +509,7 @@ jobs:
|
|||||||
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
|
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
|
||||||
echo "Resolved MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_LATEST:-unknown})"
|
echo "Resolved MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_LATEST:-unknown})"
|
||||||
echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
|
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} (pinned; master is ${OBSMEM_MASTER:-unknown})"
|
||||||
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})"
|
||||||
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ jobs:
|
|||||||
# actionlint so the more precise diagnostic surfaces first.
|
# actionlint so the more precise diagnostic surfaces first.
|
||||||
run: bash scripts/check-workflow-shell.sh .gitea/workflows
|
run: bash scripts/check-workflow-shell.sh .gitea/workflows
|
||||||
|
|
||||||
|
- name: link-pi-models.sh contract (two-sided, no docker needed)
|
||||||
|
# The entrypoint links ~/.pi/agent/models.json to a mounted shared file
|
||||||
|
# and must never replace a real file or a user's own link. Tested here
|
||||||
|
# on every push, because the smoke test only runs at release time.
|
||||||
|
run: bash scripts/test-link-pi-models.sh
|
||||||
|
|
||||||
|
- name: Dockerfile.variant still opens with its BuildKit check directive
|
||||||
|
# See scripts/check-dockerfile-directives.sh: without that first line the
|
||||||
|
# smoke jobs fail with `invalid format delimiter 'ghadelimiter_...'` and
|
||||||
|
# NO failing step. Also wired into docker-publish.yml's lint-gate.
|
||||||
|
run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant
|
||||||
|
|
||||||
- name: Install actionlint (pinned)
|
- name: Install actionlint (pinned)
|
||||||
env:
|
env:
|
||||||
ACTIONLINT_VERSION: 1.7.12
|
ACTIONLINT_VERSION: 1.7.12
|
||||||
|
|||||||
+1032
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -49,13 +49,16 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
|||||||
- **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 `{{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-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)** — 10 user-facing extensions: `ext-toggle`, `todo`, `task`, `fork-gate`, `secret-guard`, `aws-sso`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||||
|
- **MCP servers** — pi's built-in MCP support: declare servers in `~/.pi/agent/mcp.json` (or `pi mcp add`), manage them with `/mcp`
|
||||||
- **`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
|
||||||
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
|
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
|
||||||
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
|
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
|
||||||
|
|
||||||
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||||
|
|
||||||
|
**Terminal UI mode — fullscreen by default.** pi 1.0.0 made the TUI fullscreen, and this image adopts upstream's default. Fullscreen uses the terminal's alternate screen, so the transcript no longer lands in your terminal's (or tmux's) native scrollback. To get the previous behaviour back, set `"tuiMode": "regular"` in `~/.pi/agent/settings.json`, or pass `pi --tui-mode regular` for a single session. The bundled pi-atelier sidebar works in both modes. See the README for the related `fullscreenExitOutput` / `fullscreenScrollbar` / `fullscreenCopyOnSelect` / `fullscreenWheelScrollLines` settings — the last one behaves differently over SSH, which is how this container is usually driven.
|
||||||
|
|
||||||
### MemPalace (persistent agent memory)
|
### MemPalace (persistent agent memory)
|
||||||
|
|
||||||
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
|
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
|
||||||
@@ -80,7 +83,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
|||||||
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
|
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
|
||||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||||
- **Display**: bat, eza, htop, tree
|
- **Display**: bat, eza, htop, tree
|
||||||
- **Data**: jq, yq
|
- **Data**: jq, yq, sqlite3, bc/dc, column
|
||||||
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
|
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
|
||||||
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
|
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
|
||||||
- **Build**: gcc, g++, make, patch
|
- **Build**: gcc, g++, make, patch
|
||||||
|
|||||||
+110
-10
@@ -14,7 +14,7 @@
|
|||||||
# content-addressed over this file, so any byte change invalidates the
|
# content-addressed over this file, so any byte change invalidates the
|
||||||
# cache. Recommended cadence: once per release for security updates.
|
# cache. Recommended cadence: once per release for security updates.
|
||||||
#
|
#
|
||||||
# BASE_REBUILD_DATE: 2026-09-19 (v1.9.3 — mempalace-version label, mempalace-toolkit 817b3a8 mine deadline, pi-extensions skill floor 25c1265; the marker had been stale since 2026-07-13 through the v1.9.0 Node 24 and v1.9.2 npm-residue rebuilds, updated now because the base is rebuilding anyway)
|
# BASE_REBUILD_DATE: 2026-10-14 (v1.11.1 — rootfs skill floor to pi-extensions 750b2a8, mempalace-toolkit d5a03f4; apt and base tools re-resolve (upstream unchanged since 2026-10-12: uv 0.13.0, nvim 0.12.6, fzf 0.74.5); previous marker 2026-10-12 / v1.11.0)
|
||||||
#
|
#
|
||||||
# ── Lineage note ─────────────────────────────────────────────────────
|
# ── Lineage note ─────────────────────────────────────────────────────
|
||||||
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
||||||
@@ -162,6 +162,42 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
# /dev/tcp does reachability checks with zero packages
|
# /dev/tcp does reachability checks with zero packages
|
||||||
# (verified against gitea.egl.lan:3000). Recorded here so the
|
# (verified against gitea.egl.lan:3000). Recorded here so the
|
||||||
# omission reads as a decision rather than an oversight.
|
# omission reads as a decision rather than an oversight.
|
||||||
|
# sqlite3 — the `sqlite3` CLI. Added 2026-09-28 at ALC's request. 587 KB.
|
||||||
|
# This is the one that was genuinely missing rather than merely
|
||||||
|
# absent: MemPalace keeps BOTH the palace and the logstream as
|
||||||
|
# SQLite files (palace/chroma.sqlite3, logstream.sqlite3),
|
||||||
|
# mempalace_status reports a sqlite_integrity block, and every
|
||||||
|
# integrity or forensics check on this fleet has so far been
|
||||||
|
# done through a python3 -c one-liner because the CLI did not
|
||||||
|
# exist in the image.
|
||||||
|
# bc, dc — arbitrary-precision calculators. 236 KB + 149 KB. SEPARATE
|
||||||
|
# binary packages on Debian (dc split out of bc before
|
||||||
|
# bookworm), so both must be named — verified with apt-cache on
|
||||||
|
# a trixie host, not assumed. Added 2026-09-28. HONEST
|
||||||
|
# RATIONALE: low value on their own, because awk, python3 and
|
||||||
|
# perl are all already baked and each is strictly more capable.
|
||||||
|
# They are here because copy-pasted shell snippets assume bc
|
||||||
|
# exists. Note what actually went wrong on 2026-09-28, since it
|
||||||
|
# was NOT bc's absence: `printf '%6.2f'` was handed the empty
|
||||||
|
# output of the missing bc and rendered it as a confident
|
||||||
|
# "0.00 days" for a figure that was really 4.81 days. A missing
|
||||||
|
# tool that formats as a plausible number is worse than one
|
||||||
|
# that fails loudly, and no package fixes that — only not
|
||||||
|
# taking a formatted value on trust does.
|
||||||
|
# bsdextrautils— ships /usr/bin/column. 339 KB. Added 2026-09-28. Confirmed
|
||||||
|
# with `dpkg -S /usr/bin/column` on trixie rather than inferred
|
||||||
|
# from the pre-bullseye bsdmainutils name, which is where column
|
||||||
|
# used to live and is the obvious way to get this wrong.
|
||||||
|
# MEASURED COST OF ALL FOUR: 1311 KB total and ZERO transitive
|
||||||
|
# packages — libsqlite3-0, libreadline8t64, zlib1g,
|
||||||
|
# libsmartcols1 and libtinfo6 are each already present in the
|
||||||
|
# image (checked with dpkg-query, all five report
|
||||||
|
# "install ok installed"), so under --no-install-recommends
|
||||||
|
# nothing new is pulled.
|
||||||
|
# NOT added — datamash, xsv/csvkit: considered 2026-09-28 and
|
||||||
|
# declined. Python's stdlib csv module handled a real 7-file
|
||||||
|
# Excel-export concatenation that day (BOM, CRLF, embedded
|
||||||
|
# newlines inside quoted fields) correctly and without them.
|
||||||
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
|
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
|
||||||
# shellcheck above: a gate this repo ALREADY OWNS could not be
|
# shellcheck above: a gate this repo ALREADY OWNS could not be
|
||||||
# run locally by anyone. scripts/check-workflow-shell.sh — the
|
# run locally by anyone. scripts/check-workflow-shell.sh — the
|
||||||
@@ -220,6 +256,10 @@ RUN apt-get update && \
|
|||||||
ldap-utils \
|
ldap-utils \
|
||||||
xxd \
|
xxd \
|
||||||
python3-yaml \
|
python3-yaml \
|
||||||
|
sqlite3 \
|
||||||
|
bc \
|
||||||
|
dc \
|
||||||
|
bsdextrautils \
|
||||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -532,14 +572,22 @@ ARG INSTALL_MEMPALACE=true
|
|||||||
# the part that should stay manual.
|
# the part that should stay manual.
|
||||||
#
|
#
|
||||||
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
||||||
# central palace host) serves mempalace 3.8.0 SERVER-SIDE via
|
# central palace host) serves mempalace SERVER-SIDE as a `uv tool` install run
|
||||||
# docker-compose.mempalace.yml, which reuses this same devbox image. (Measured
|
# by the systemd unit `mempalace-serve.service` (`python -m mempalace.mcp_server
|
||||||
# 2026-09-06 over ssh: synlig's UV_TOOL_DIR mempalace entry last changed
|
# --transport http`), NOT via docker-compose.mempalace.yml — that compose file
|
||||||
# 2026-08-25 15:33 — this comment previously said 3.7.1, which was stale.)
|
# exists in this repo but is not what runs there. (Measured 2026-09-22 over
|
||||||
# Bumping this ARG changes only the CLIENT version baked into pi-devbox
|
# ssh: `uv tool list` -> mempalace v3.9.0, python 3.12.13, chromadb 1.5.9;
|
||||||
# images: it introduces client/server skew until synlig's compose stack is
|
# `docker ps` matched no palace container. This comment previously said the
|
||||||
# separately rebuilt/redeployed with the new pin. Not something to code around
|
# compose stack served 3.8.0, which was stale on both counts.) Bumping this ARG
|
||||||
# here — just sequence the redeploy.
|
# changes only the CLIENT version baked into pi-devbox images: it introduces
|
||||||
|
# client/server skew until synlig's tool is upgraded (`uv tool upgrade
|
||||||
|
# mempalace` + restart the unit). Not something to code around here — just
|
||||||
|
# sequence the upgrade. And note which side OWNS what: MCP tool semantics
|
||||||
|
# (event_list ordering, kg_timeline pagination, search result fields) come
|
||||||
|
# from the SERVER the extension talks to over MEMPALACE_REMOTE_URL, so they
|
||||||
|
# change when synlig upgrades; only the local CLI (`mempalace init` at first
|
||||||
|
# run, the mempalace-pi-session feeder) and the on-disk layout under
|
||||||
|
# ~/.mempalace change when THIS pin does.
|
||||||
#
|
#
|
||||||
# v1.8.13: 3.8.0 -> 3.9.0. Audited: no Breaking/Removed changelog headings.
|
# v1.8.13: 3.8.0 -> 3.9.0. Audited: no Breaking/Removed changelog headings.
|
||||||
# Adopted mainly for #2281 (`mempalace_mine` accepts a single conversation
|
# Adopted mainly for #2281 (`mempalace_mine` accepts a single conversation
|
||||||
@@ -551,7 +599,47 @@ ARG INSTALL_MEMPALACE=true
|
|||||||
# (release awareness, `task create`/`task launch` MCP tools) are SERVER-side,
|
# (release awareness, `task create`/`task launch` MCP tools) are SERVER-side,
|
||||||
# so they stay dark until synlig is redeployed — a client bump alone cannot
|
# so they stay dark until synlig is redeployed — a client bump alone cannot
|
||||||
# light them up.
|
# light them up.
|
||||||
ARG MEMPALACE_VERSION=3.9.0
|
#
|
||||||
|
# v1.9.4: 3.9.0 -> 3.10.0 (PyPI 2026-09-15). Deferred at v1.9.3 for two
|
||||||
|
# "Upgrade notes" items; both re-measured against the 3.10.0 wheel, one needed
|
||||||
|
# an adaptation:
|
||||||
|
# - "New installs keep config and palace under ~/.config/mempalace". The
|
||||||
|
# resolution order is $MEMPALACE_CONFIG_DIR, then ~/.mempalace IF it holds
|
||||||
|
# config.json / people_map.json / palace/chroma.sqlite3, then XDG. An
|
||||||
|
# EMPTY ~/.mempalace does not count — and an empty ~/.mempalace is exactly
|
||||||
|
# what a freshly mounted devbox-palace volume (or entrypoint.sh's mkdir on
|
||||||
|
# a volume-less container) looks like at first boot. Measured with a fresh
|
||||||
|
# $HOME: `mempalace init` wrote to ~/.config/mempalace, outside the
|
||||||
|
# persisted path, and entrypoint-user.sh's first-run test
|
||||||
|
# `[ ! -d ~/.mempalace/palace ]` would stay true on every start. With
|
||||||
|
# MEMPALACE_CONFIG_DIR set, everything landed in ~/.mempalace. Hence the
|
||||||
|
# ENV MEMPALACE_CONFIG_DIR below (in the non-root-user section, where
|
||||||
|
# ${USER_NAME} is in scope): first in the resolution order, so the
|
||||||
|
# heuristic never runs and the image's layout contract no longer depends
|
||||||
|
# on it. Existing volumes were safe either way (config.json is a legacy
|
||||||
|
# marker); the ENV is for first boots. palace_path still defaults to
|
||||||
|
# <config_dir>/palace and MEMPALACE_PALACE_PATH is still honoured (config.py
|
||||||
|
# :927), so scripts/smoke-test.sh's stage-path test keeps its meaning.
|
||||||
|
# - "MCP event listing returns the newest events first when no cursor is
|
||||||
|
# given". SERVER-side (see above), so it lands when synlig upgrades, not
|
||||||
|
# here. mempalace-toolkit 2167a1b made every cursor-less event_list call
|
||||||
|
# in the pi extension say `order: "desc"` explicitly, so the mailbox reads
|
||||||
|
# the same window against either server version.
|
||||||
|
# Also in the notes, neither reaching this image: `mempalace rules` dropped
|
||||||
|
# `--agent` (no caller in pi-devbox, mempalace-toolkit, skillset or myconfigs);
|
||||||
|
# `get_collection()` refuses unknown collection names (library callers only).
|
||||||
|
# MCP tool-schema review, as always: no tool removed or renamed; additive
|
||||||
|
# fields on search results (filed_at / content_date provenance), `limit` /
|
||||||
|
# `offset` on kg_timeline, `last_modified` on drawers. Skew while synlig stays
|
||||||
|
# on 3.9.0 is narrower than it looks: the pi extension speaks HTTP to the hub
|
||||||
|
# (no local mempalace-mcp is spawned), and the feeder in remote mode stages
|
||||||
|
# locally in python, rsyncs, and calls the hub's own `mempalace_mine` tool.
|
||||||
|
# Measured 2026-09-22 with a PATH shim in front of `mempalace`: a 47-session
|
||||||
|
# `mempalace-pi-session --dry-run` made ZERO local CLI calls (the shim's
|
||||||
|
# positive control logged one). So 3.10.0's new CLI write-routing policy never
|
||||||
|
# runs against the hub from this image; the client pin touches first-run
|
||||||
|
# `mempalace init` and the on-disk layout, nothing else in remote mode.
|
||||||
|
ARG MEMPALACE_VERSION=3.10.0
|
||||||
# Recorded as a label HERE, not in Dockerfile.variant, for three reasons: the
|
# Recorded as a label HERE, not in Dockerfile.variant, for three reasons: the
|
||||||
# value lives next to the ARG that defines it (a second copy in the variant
|
# value lives next to the ARG that defines it (a second copy in the variant
|
||||||
# would be one more pin able to drift, which is the class check-doc-drift.sh
|
# would be one more pin able to drift, which is the class check-doc-drift.sh
|
||||||
@@ -832,6 +920,18 @@ print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
|||||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||||
|
|
||||||
|
# ── MemPalace config/palace root: pin it, do not let a heuristic pick it ──
|
||||||
|
# mempalace >= 3.10.0 resolves its config dir as $MEMPALACE_CONFIG_DIR, then
|
||||||
|
# ~/.mempalace ONLY if it already holds a config/palace, else ~/.config/mempalace
|
||||||
|
# (XDG). An empty ~/.mempalace — a fresh devbox-palace volume, or entrypoint.sh's
|
||||||
|
# mkdir on a volume-less container — fails that test, so a first boot would put
|
||||||
|
# the palace outside the persisted path and re-run first-run init forever. This
|
||||||
|
# ENV is first in the order, so the layout is what entrypoint.sh (mkdir),
|
||||||
|
# entrypoint-user.sh (first-run test), the feeder's <palace-root>/pi-stage and
|
||||||
|
# scripts/recreate-sanity-check.sh all already assume. Rationale and the
|
||||||
|
# measurement live with ARG MEMPALACE_VERSION above; keep the two in step.
|
||||||
|
ENV MEMPALACE_CONFIG_DIR=/home/${USER_NAME}/.mempalace
|
||||||
|
|
||||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||||
RUN mkdir -p /etc/skel-devbox
|
RUN mkdir -p /etc/skel-devbox
|
||||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||||
|
|||||||
+267
-5
@@ -1,3 +1,37 @@
|
|||||||
|
# check=skip=InvalidDefaultArgInFrom
|
||||||
|
#
|
||||||
|
# ^ MUST stay the FIRST line of this file, and it is load-bearing for CI, not
|
||||||
|
# style. BuildKit parses `# check=` only before any other line, so moving it
|
||||||
|
# below the title comment silently disables it.
|
||||||
|
#
|
||||||
|
# Why it exists: `ARG BASE_IMAGE` (below) deliberately has NO default — this
|
||||||
|
# file is only ever built by the two-phase CI with --build-arg BASE_IMAGE=
|
||||||
|
# <image>:base-<hash>. BuildKit's InvalidDefaultArgInFrom check flags that as
|
||||||
|
# "default value for global ARG results in an empty or invalid base image
|
||||||
|
# name". There is no honest default to give it: `scratch` would satisfy the
|
||||||
|
# linter while being a lie (nothing here can build FROM scratch), and it would
|
||||||
|
# convert today's instant "invalid reference format" into a failure deep in the
|
||||||
|
# build. So the check is skipped by name, with the reason written down.
|
||||||
|
#
|
||||||
|
# What the warning actually COST, which is why this is not cosmetic: buildx
|
||||||
|
# attaches the warning's source context to the build metadata as
|
||||||
|
# buildx.build.warnings[].sourceInfo.data — the ENTIRE Dockerfile, base64, on
|
||||||
|
# ONE line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as
|
||||||
|
# a `name<<ghadelimiter_<uuid>` heredoc, and Gitea's act_runner truncates any
|
||||||
|
# single line at exactly 65536 chars. Once base64(this file) crossed 64 KiB the
|
||||||
|
# closing delimiter was cut off, and the runner failed the job with
|
||||||
|
# invalid format delimiter 'ghadelimiter_...' not found before end of file
|
||||||
|
# and NO failing step: every step green, smoke suite "0 failed", job red.
|
||||||
|
# Measured: 42251 B at v1.9.4 -> base64 56355 (fine); 50034 B at v1.10.1 ->
|
||||||
|
# base64 66712, truncated to exactly 65536. The cap corresponds to a 49152 B
|
||||||
|
# Dockerfile, so this release's comment growth crossed it by 882 B. It killed
|
||||||
|
# v1.10.0 (run 704, where a stale clipboard assertion hid it) and v1.10.1
|
||||||
|
# (run 707). With zero warnings there is no sourceInfo, so the longest metadata
|
||||||
|
# line drops from 65536 to ~1936 chars and the Dockerfile's SIZE stops being
|
||||||
|
# coupled to CI passing at all.
|
||||||
|
#
|
||||||
|
# If this ever recurs — job red, zero failing steps, suite reporting 0 failed —
|
||||||
|
# grep the job log for `ghadelimiter` first.
|
||||||
# pi-devbox — variant image
|
# pi-devbox — variant image
|
||||||
#
|
#
|
||||||
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
||||||
@@ -113,7 +147,149 @@ ARG USER_NAME=developer
|
|||||||
# signature is ~5s of sustained CPU. Two-sided check: the atelier sidebar
|
# signature is ~5s of sustained CPU. Two-sided check: the atelier sidebar
|
||||||
# painted ACTIVITY+WORKSPACE identically to the 0.84.4 control, so the test
|
# painted ACTIVITY+WORKSPACE identically to the 0.84.4 control, so the test
|
||||||
# could distinguish "loaded" from "silently absent".
|
# could distinguish "loaded" from "silently absent".
|
||||||
ARG PI_VERSION=0.85.1
|
#
|
||||||
|
# v1.9.4: HELD at 0.85.1 while 0.86.1 and 0.87.0 exist upstream. 0.87.0's
|
||||||
|
# changelog: "Removed the inherited `shouldStopAfterTurn` agent option. Use
|
||||||
|
# `finishTurn` and return `{ action: "end" }` instead" (no issue number on
|
||||||
|
# that line); 0.86.0 moved provider stream inputs to `TranscriptContext`, with
|
||||||
|
# system prompts read from `context.messages`. pi-observational-memory 3.1.4
|
||||||
|
# (the `master` ref baked below) still uses both the old option and
|
||||||
|
# `AgentContext.systemPrompt` in its observer, reflector and dropper workers —
|
||||||
|
# measured 2026-09-22 in src/agents/*/agent.ts, both the baked 3.1.3 tree and
|
||||||
|
# upstream master 3.1.4: `shouldStopAfterTurn` 1 per worker (3), `finishTurn`
|
||||||
|
# 0, `systemPrompt` 1 per worker. Its peerDependencies are `*`,
|
||||||
|
# so nothing at install time would refuse; it would break at runtime (turn
|
||||||
|
# caps ignored, workers losing their specialised prompts). Upstream tracks it
|
||||||
|
# as pi-observational-memory #82 with fix PR #83 (opened 2026-09-21, mergeable,
|
||||||
|
# not merged at this writing). Bump pi and pi-obsmem TOGETHER once #83 has
|
||||||
|
# shipped in a release. The other extensions were checked against the 0.86.0
|
||||||
|
# and 0.87.0 breaking lists and are clean: ssh-controlmaster's `user_bash`
|
||||||
|
# handler already returns `undefined | { operations }` (0.86.0 fail-closed
|
||||||
|
# contract) and its registerTool calls spread the built-in tools so they
|
||||||
|
# carry parameter schemas (#9300). 0.86.1 as an intermediate is untested and
|
||||||
|
# not worth the pty matrix for a stop that #83 will make moot.
|
||||||
|
#
|
||||||
|
# v1.9.5: 0.85.1 -> 0.87.1, and pi-obsmem moves off `master` to a pinned SHA in
|
||||||
|
# the SAME commit, because neither is safe alone. 0.87.0 REMOVED
|
||||||
|
# `shouldStopAfterTurn`, which 3.1.4 still used; the pinned tip uses
|
||||||
|
# `finishTurn`, which does not exist before 0.87.0. So 3.1.4 + 0.87.1 silently
|
||||||
|
# ignores turn caps, and the new tip + 0.85.1 breaks the workers outright — the
|
||||||
|
# pair only works together, exactly as the v1.9.4 note predicted.
|
||||||
|
# MEASURED 2026-10-01 across src/agents/{observer,reflector,dropper}/agent.ts,
|
||||||
|
# deliberately the same counting method as the v1.9.4 audit above so the two
|
||||||
|
# numbers are comparable:
|
||||||
|
# 3.1.4 (e7d77dc): shouldStopAfterTurn 3, finishTurn 0, systemPrompt 3
|
||||||
|
# 731c3d4 (pinned) : shouldStopAfterTurn 0, finishTurn 3, systemPrompt 0
|
||||||
|
# i.e. all three workers migrated, and the 0.86.0 `AgentContext.systemPrompt`
|
||||||
|
# reads are gone too. #82/#83 merged 2026-09-23.
|
||||||
|
# WHY A SHA AND NOT A TAG, departing from the v1.9.4 instruction to wait for a
|
||||||
|
# release: #83 is merged, but the newest obsmem tag is STILL 3.1.4, cut
|
||||||
|
# 2026-09-20 — before the merge. Upstream tags slowly while moving master often
|
||||||
|
# (e7d77dc -> 1529e14 -> 731c3d4 in the nine days to 2026-10-01), so waiting for
|
||||||
|
# a tag means holding pi indefinitely. A full SHA keeps the one property
|
||||||
|
# `master` does not have: rebuilding this tag later produces the SAME image.
|
||||||
|
# v1.9.5 SKIPPED 0.99.x and 1.0.0 for lack of evidence. v1.10.2 went and got the
|
||||||
|
# evidence instead of waiting for obsmem to mention a version, because "no issue
|
||||||
|
# names 1.0" is an absence of a statement, not a measurement.
|
||||||
|
#
|
||||||
|
# v1.10.2: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm
|
||||||
|
# tarballs for 0.87.1 and 1.0.0, unpacked side by side:
|
||||||
|
# 1. NO `### Breaking Changes` SECTION IN 1.0.0 AT ALL. The changelog's
|
||||||
|
# breaking sections belong to 0.87.0, 0.86.0, 0.84.3, 0.84.0, 0.83.0,
|
||||||
|
# 0.80.8, 0.80.7 and 0.75.0 — none to 0.88+..1.0.0. The major is a
|
||||||
|
# milestone (fullscreen default, leaner codemode), not an API break. The
|
||||||
|
# last break that touched us was 0.87.0's `shouldStopAfterTurn` removal,
|
||||||
|
# which v1.9.5 already absorbed.
|
||||||
|
# 2. `finishTurn` IS STILL IN 1.0.0's dist, so the obsmem SHA pinned below
|
||||||
|
# keeps the API it migrated to. (`shouldStopAfterTurn`: absent from both
|
||||||
|
# 0.87.1 and 1.0.0, as expected after its 0.87.0 removal.)
|
||||||
|
# 3. EVERY pi.* API our extensions call exists in 1.0.0's dist — all 8 of
|
||||||
|
# registerTool, registerCommand, registerFlag, getFlag, on, exec,
|
||||||
|
# sendMessage, sendUserMessage, extracted from mempalace.ts, the
|
||||||
|
# pi-extensions tree and obsmem's src/.
|
||||||
|
# 4. engines.node is `>=22.19.0` on both; the image ships 24.x.
|
||||||
|
# THE 0.99.2 CHANGE THAT LOOKED FATAL AND IS NOT: from 0.99.2 the DEFAULT MCP
|
||||||
|
# `exposure` is `codemode`, i.e. such tools are "neither declared to the model
|
||||||
|
# nor listed" and must be found with `searchTools()`. That would gut the
|
||||||
|
# MemPalace protocol if MemPalace were a builtin-MCP server. It is not: both
|
||||||
|
# mempalace.ts runs its OWN MCP client and surfaces tools via
|
||||||
|
# `pi.registerTool()`, which is why they are named `mempalace_search` and not
|
||||||
|
# `mcp__mempalace__search`. (So did pi-extensions' mcp-loader.ts until it was
|
||||||
|
# retired after v1.11.0 in favour of builtin:mcp. Servers in ~/.pi/agent/mcp.json
|
||||||
|
# ARE exposure-governed: they need `"exposure": "direct"` to be declared.) Confirmed on a second route — settings.json has
|
||||||
|
# neither an `mcpServers` block (builtin, exposure-governed) nor an `mcp` block.
|
||||||
|
# Extension-registered tools are declared like built-ins, so `exposure` cannot
|
||||||
|
# reach them.
|
||||||
|
# WHAT IS NOT PROVEN, stated so acceptance does not mistake this for cleared:
|
||||||
|
# grepping dist shows the SYMBOLS survive, not that their SIGNATURES are
|
||||||
|
# unchanged — necessary, not sufficient. 1.0.0 is also four days of upstream old
|
||||||
|
# (published 2026-10-01T19:15Z) and neither obsmem nor atelier has a commit
|
||||||
|
# naming it. Acceptance must prove the obsmem workers CAP TURNS (peerDeps are
|
||||||
|
# `*`, so a mismatch is silent) and that the atelier sidebar PAINTS.
|
||||||
|
# USER-VISIBLE BEHAVIOUR CHANGE, decided rather than inherited: 1.0.0 makes the
|
||||||
|
# TUI fullscreen by default, which replaces the terminal's normal scrollback.
|
||||||
|
# Upstream's default is ADOPTED on purpose and no `tuiMode` is baked here, so
|
||||||
|
# the image follows pi instead of pinning the fleet to either mode. The revert
|
||||||
|
# is documented (README -> "Terminal UI mode"): `"tuiMode": "regular"` in
|
||||||
|
# ~/.pi/agent/settings.json, `pi --tui-mode regular` for one session, or the
|
||||||
|
# same key in a project's .pi/settings.json.
|
||||||
|
#
|
||||||
|
# v1.11.0: 1.0.0 -> 1.1.0. MEASURED 2026-10-12 against the published npm
|
||||||
|
# tarballs for 1.0.0 and 1.1.0, unpacked side by side, by the same method as
|
||||||
|
# the v1.10.2 audit above:
|
||||||
|
# 1. ONE `### Breaking Changes` section in 1.0.1..1.1.0: 1.0.3 renamed the
|
||||||
|
# provider `azure-openai-responses` -> `azure`. 0 uses here: grepped
|
||||||
|
# rootfs/, ~/.pi/agent/settings.json, pi-toolkit's settings.example.json
|
||||||
|
# and the fleet's shared models.json. Everything else is additive or a fix
|
||||||
|
# (OSC 7501 program status, `+name`/`-name` in --tools, `durationMs` and
|
||||||
|
# `outputPad` in the tool render context, `aborted` on agent_settled).
|
||||||
|
# Two user-visible changes: Home/End always move the editor cursor
|
||||||
|
# (fullscreen transcript top/bottom moved to Ctrl+Home/Ctrl+End, 1.0.3),
|
||||||
|
# and OSC 7501 status reporting (PI_PROGRAM_STATUS=0 turns it off, 1.1.0).
|
||||||
|
# 2. pi-agent-core 1.0.0 -> 1.1.0: the finishTurn context in dist is
|
||||||
|
# BYTE-IDENTICAL; the only agent-loop diff adds `durationMs`. The obsmem
|
||||||
|
# turn-cap probe drove the real 1.1.0 agentLoop: uncapped ran its chosen
|
||||||
|
# 11 turns, maxTurns=3 stopped at exactly 3 + agent_end.
|
||||||
|
# 3. The 6 named pi.* APIs (registerTool, registerCommand, registerFlag,
|
||||||
|
# getFlag, sendMessage, sendUserMessage) appear in the same number of dist
|
||||||
|
# files in 1.0.0 and 1.1.0. atelier's internals TuiMainScreen and
|
||||||
|
# renderLayoutFrame are both still in 1.1.0's dist.
|
||||||
|
# 4. engines.node is still `>=22.19.0`; the image ships 24.x.
|
||||||
|
# Exercised before tagging, from a temp prefix with pi 1.1.0 and a throwaway
|
||||||
|
# PI_CODING_AGENT_DIR, in a real pty, with -ne controls: atelier v0.15.0 paints
|
||||||
|
# ACTIVITY/READY/TOOLS in BOTH renderers (controls 0/3, every arm render-proofed);
|
||||||
|
# obsmem 022da5e registers /om:status and /om:view (control: none); and all 11
|
||||||
|
# pi-extensions a88fa99 extensions load with no error (control: no /aws-sso).
|
||||||
|
#
|
||||||
|
# v1.11.2: 1.1.0 -> 1.2.0 (npm latest, 2026-10-12). MEASURED 2026-10-14 on
|
||||||
|
# tor-ms22 from an isolated npm prefix (nothing in /usr or ~/.pi touched):
|
||||||
|
# 1. FOUR `### Breaking Changes` in 1.2.0, none of which reaches us:
|
||||||
|
# - before_agent_start also fires for sendMessage(triggerTurn) runs and is
|
||||||
|
# a union on `messageType`; `systemPrompt` is on the shared base. Our 3
|
||||||
|
# handlers: ssh-controlmaster reads only systemPrompt, aws-sso and
|
||||||
|
# mempalace read no event field. Nothing in the stack uses triggerTurn.
|
||||||
|
# - the session counts as busy while a run starts (isIdle() false,
|
||||||
|
# sendUserMessage needs deliverAs). aws-sso already passes deliverAs;
|
||||||
|
# mempalace's /mempalace-diary is a command and runs while idle.
|
||||||
|
# - themes are schema-validated at startup and the schema moved to
|
||||||
|
# pi.dev. pi-studio's themes still name the old pi-mono $schema URL, but
|
||||||
|
# `--use-theme pi-studio-dark` applies (palette differs from the
|
||||||
|
# default) and prints no warning.
|
||||||
|
# - `pi mcp list --json` reports `resources: true`. Nothing parses it.
|
||||||
|
# 2. pi-agent-core 1.1.0 -> 1.2.0: dist is BYTE-IDENTICAL (24 files, same
|
||||||
|
# tree hash). The turn-cap probe drove the real 1.2.0 agentLoop: uncapped
|
||||||
|
# ran its chosen 19 turns, maxTurns=3 stopped at exactly 3 + agent_end.
|
||||||
|
# 3. 1.2.0's `-p` now exits 1 on a reply cut off at the output limit, in TEXT
|
||||||
|
# mode only (print-mode.js). pi-task and pi-fork run `--mode json`.
|
||||||
|
# 4. engines.node is still `>=22.19.0`; the image ships 24.x. Peer ranges:
|
||||||
|
# atelier >=0.84.0, fork >=0.75.0, obsmem and studio `*`.
|
||||||
|
# Exercised before tagging, in a real pty with -ne controls: atelier v0.15.0
|
||||||
|
# paints ACTIVITY/READY/TOOLS in BOTH renderers (controls 0/3, every arm
|
||||||
|
# render-proofed); obsmem 022da5e registers /om:status and /om:view (control:
|
||||||
|
# none); a copy of tor-ms22's real ~/.pi/agent (all packages, pi-extensions
|
||||||
|
# 750b2a8) starts with the same [Extensions] list as on 1.1.0 and no
|
||||||
|
# [Extension issues]; `pi -p` answers PONG via ERDC in 1.0 s, Bedrock in 5.2 s.
|
||||||
|
ARG PI_VERSION=1.2.0
|
||||||
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
|
||||||
@@ -128,7 +304,29 @@ ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
|||||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
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
|
# PINNED to a full 40-char SHA as of v1.9.5, not `master` — see the PI_VERSION
|
||||||
|
# note above for the measurement and the reasoning. Moves together with
|
||||||
|
# PI_VERSION by necessity, not by convention. The width matters: 731c3d4 is the
|
||||||
|
# same commit, but check-doc-drift.sh recognises a literal SHA only via a
|
||||||
|
# 40-char match (SHA40), so a short pin would fall through to its
|
||||||
|
# branch-or-tag lookup, fail, and downgrade that component's drift check to a
|
||||||
|
# silent SKIP — a pin that reads fine and is no longer verified.
|
||||||
|
# v1.11.0: 731c3d4 -> 022da5e (master, 2026-10-05; still no newer tag than
|
||||||
|
# 3.1.4). Two commits: #81 never drops unobserved context at compaction
|
||||||
|
# (new settings compactionMaxRetainedTokens, consolidateWhenIdle,
|
||||||
|
# piAutoCompactionEnabled, all defaulted), and #93 makes pi load a prebuilt
|
||||||
|
# dist/index.ts instead of 32 TS sources. The bundle is committed, so
|
||||||
|
# `npm install --omit=dev` below needs no build step. src/agents/ is
|
||||||
|
# UNCHANGED between the two SHAs, so the finishTurn migration is intact.
|
||||||
|
# v1.11.2: unchanged (master is still 022da5e, 6 commits past the 3.1.4 tag).
|
||||||
|
# CI NOW BUILDS THIS PIN. Until v1.11.2 docker-publish.yml's resolve-versions
|
||||||
|
# resolved obsmem from `master` and passed it as --build-arg, which overrides
|
||||||
|
# this default (verified two-sided on buildx 2026-10-12), so CI got the pin only
|
||||||
|
# while master == pin. resolve-versions now reads this line, refuses anything
|
||||||
|
# but a 40-char SHA, and WARNS when master has moved past it. obsmem tags
|
||||||
|
# rarely and lands what pi needs on master (PR #83 never reached a tag), so
|
||||||
|
# adopting master is still the norm: bump this SHA, deliberately, after audit.
|
||||||
|
ARG PI_OBSMEM_REF=022da5e5554d04dfdc876ed7dac4a42c6b83dfd4
|
||||||
# pi-atelier (TUI sidebar: ordered panels, split-pane, themes) is PINNED TO A
|
# 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
|
# TAG, which CI resolves to that tag's commit SHA — same treatment as
|
||||||
# pi-studio, for reproducibility plus cache-busting.
|
# pi-studio, for reproducibility plus cache-busting.
|
||||||
@@ -170,9 +368,57 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
|||||||
# old and new pin. Included because it was already exercised: the pty matrix
|
# old and new pin. Included because it was already exercised: the pty matrix
|
||||||
# for PI_VERSION above ran atelier v0.10.1 against pi 0.85.1 and painted the
|
# for PI_VERSION above ran atelier v0.10.1 against pi 0.85.1 and painted the
|
||||||
# sidebar identically to v0.10.0.
|
# sidebar identically to v0.10.0.
|
||||||
ARG PI_ATELIER_REF=v0.10.1
|
#
|
||||||
|
# v1.9.4: v0.10.1 -> v0.10.3 (both v0.10.2 and v0.10.3 released 2026-09-22).
|
||||||
|
# Fixes only per the release notes: sidebar text/borders preserved beside
|
||||||
|
# inline images (#53), transcript images hidden while capturing overlays are
|
||||||
|
# open, Workspace Pulse skips redundant HEAD/diff when nothing tracked changed
|
||||||
|
# (#61), sidebar height from row counts (#59), git/usage scans suspended while
|
||||||
|
# disabled, Display Revert + Undo ordering. Checked before bumping: package.json
|
||||||
|
# at v0.10.3 still declares zero runtime dependencies and no build script (so
|
||||||
|
# the no-`npm install` reasoning above holds) and peerDependencies are still
|
||||||
|
# pi >=0.84.0, so it still spans the pinned 0.85.1. 13 commits v0.10.1..v0.10.3,
|
||||||
|
# all under src/ tests/ docs/ scripts/ plus metadata; no entry-point move.
|
||||||
|
#
|
||||||
|
# v1.10.2: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the
|
||||||
|
# residual risk of the pi bump. peerDependencies are UNCHANGED at pi >=0.84.0
|
||||||
|
# across v0.10.3, v0.12.1 and v0.13.0 — still a FLOOR, so still not evidence of
|
||||||
|
# anything; the floor is satisfied by 1.0.0 either way. What was actually
|
||||||
|
# checked, 2026-10-02:
|
||||||
|
# - v0.13.0 CARRIES ITS OWN BREAKING CHANGE, unrelated to pi: "The package
|
||||||
|
# entry point now exports only the sidebar contribution protocol
|
||||||
|
# (`registerSidebarPanel`, guards, size limits, event types). The internal
|
||||||
|
# registry and layout helpers are no longer exported." Harmless HERE only
|
||||||
|
# because nothing of ours imports them: a grep for `pi-atelier` and
|
||||||
|
# `registerSidebarPanel` across mempalace-toolkit, pi-devbox,
|
||||||
|
# pi-extensions, pi-fork, pi-observational-memory and pi-studio returns
|
||||||
|
# ZERO matches in all six. atelier is a leaf here — it registers its own
|
||||||
|
# sidebar and no one consumes its API.
|
||||||
|
# - Settings churn in the gap, checked against our own tree: v0.12.0 REMOVED
|
||||||
|
# the `showSessionActions` setting (0 references here) and migrated the
|
||||||
|
# Control Center shortcut Alt+A -> F6 (0 references here, so no doc goes
|
||||||
|
# stale). `showSidebarAgent` / `showSidebarTodos`, the only atelier keys
|
||||||
|
# README names, are both still documented in v0.13.0's README.
|
||||||
|
# - The TUI internals atelier patches (`TuiMainScreen`, `renderLayoutFrame`)
|
||||||
|
# are both still present in pi 1.0.0's dist, and atelier's changelog shows
|
||||||
|
# it has handled fullscreen vs regular renderers explicitly since 0.84.
|
||||||
|
# - Residual: v0.13.0 is SAME-DAY upstream (2026-10-02). Acceptance proves
|
||||||
|
# the sidebar PAINTS with the two-sided check from 0.84.4/0.85.1 that can
|
||||||
|
# tell "loaded" from "silently absent" — "no crash" is not the test.
|
||||||
|
#
|
||||||
|
# v1.11.0: v0.13.0 -> v0.15.0 (v0.14.0 and v0.15.0 both 2026-10-03). The
|
||||||
|
# peerDependencies floor is unchanged (pi >=0.84.0), atelier still has zero
|
||||||
|
# runtime dependencies, and it still imports only the public package exports.
|
||||||
|
# Behaviour changes: v0.14.0 auto-collapses the Sidebar when the terminal is
|
||||||
|
# narrow (Auto/Manual modes). v0.15.0 starts in Manual mode, recolours the
|
||||||
|
# panels and simplifies the Activity rows. The startup preference is still
|
||||||
|
# `showSidebarOnStartup` (default true, src/config.ts:38). Painted in both
|
||||||
|
# renderers on pi 1.1.0 before tagging (see the PI_VERSION note).
|
||||||
|
# v1.11.2: unchanged (still the newest tag); painted in both renderers on
|
||||||
|
# pi 1.2.0 before tagging.
|
||||||
|
ARG PI_ATELIER_REF=v0.15.0
|
||||||
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||||
ARG PI_ATELIER_VERSION=v0.10.1
|
ARG PI_ATELIER_VERSION=v0.15.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
|
||||||
@@ -282,6 +528,22 @@ RUN set -e && \
|
|||||||
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})"
|
echo "pi-atelier at $(cd /opt/pi-atelier && git rev-parse --short HEAD) (${PI_ATELIER_VERSION})"
|
||||||
|
|
||||||
|
# ── git: let the unprivileged user read the root-owned /opt clones ──────────
|
||||||
|
# The clones above (and /opt/mempalace-toolkit from the base, /opt/pi-studio in
|
||||||
|
# the studio variant) are root-owned; the container runs as `developer`. Since
|
||||||
|
# git 2.35.2 (CVE-2022-24765) any git command in a repo owned by another user
|
||||||
|
# fails with "dubious ownership" — so `git -C /opt/pi-extensions rev-parse`
|
||||||
|
# returns nothing, `install.sh` used to abort on it, and acceptance checks that
|
||||||
|
# read a baked ref via git silently measured "" (v1.9.4 first-boot run: one
|
||||||
|
# false FAIL from exactly this). Listing the paths (not `*`) keeps the check
|
||||||
|
# meaningful for everything else, e.g. the virtiofs-mounted /workspace.
|
||||||
|
# Entries for paths absent in a variant (pi-studio) are inert.
|
||||||
|
RUN for d in pi-toolkit pi-extensions pi-fork pi-observational-memory pi-atelier \
|
||||||
|
mempalace-toolkit pi-studio; do \
|
||||||
|
git config --system --add safe.directory "/opt/${d}"; \
|
||||||
|
done && \
|
||||||
|
git config --system --get-all safe.directory
|
||||||
|
|
||||||
# ── 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
|
||||||
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
||||||
@@ -481,7 +743,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
|
|||||||
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
||||||
# Dockerfile.base, so no folding into the base hash is required — nor would
|
# Dockerfile.base, so no folding into the base hash is required — nor would
|
||||||
# it be correct, since this ARG changes nothing about the base's contents.)
|
# it be correct, since this ARG changes nothing about the base's contents.)
|
||||||
ARG SKILLSET_SNAPSHOT_REF=e9e45f7acdde490c3b5d24ce5f508bff8785c2c7
|
ARG SKILLSET_SNAPSHOT_REF=96b840ee43beca65a190772021a8ef370145cde1
|
||||||
|
|
||||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||||
# and every variant INHERITS it, so both published images used to advertise
|
# and every variant INHERITS it, so both published images used to advertise
|
||||||
|
|||||||
@@ -358,6 +358,61 @@ 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.
|
||||||
|
|
||||||
|
## Terminal UI mode: fullscreen is the default (since v1.10.2)
|
||||||
|
|
||||||
|
pi **1.0.0** changed the default terminal UI mode to **fullscreen**, and this
|
||||||
|
image adopts upstream's default rather than overriding it. Fullscreen draws into
|
||||||
|
the terminal's alternate screen, so pi's transcript no longer accumulates in your
|
||||||
|
terminal's native scrollback — you scroll inside pi instead, and on exit pi
|
||||||
|
prints the transcript (`fullscreenExitOutput`).
|
||||||
|
|
||||||
|
That is a real behaviour change if you were used to the old mode, so here is the
|
||||||
|
way back. Nothing in the image is pinned, so all three routes below work:
|
||||||
|
|
||||||
|
| Scope | How |
|
||||||
|
|---|---|
|
||||||
|
| **Permanently, for every session** | add `"tuiMode": "regular"` to `~/.pi/agent/settings.json` |
|
||||||
|
| **One session** | `pi --tui-mode regular` |
|
||||||
|
| **One project only** | add `"tuiMode": "regular"` to that project's `.pi/settings.json` — project settings override the agent directory |
|
||||||
|
|
||||||
|
The settings file is plain JSON and `tuiMode` is top-level, so the minimal
|
||||||
|
permanent change is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# merge the key without disturbing the rest of the file (python3 is always present)
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, pathlib
|
||||||
|
p = pathlib.Path.home() / ".pi/agent/settings.json"
|
||||||
|
d = json.loads(p.read_text()) if p.exists() else {}
|
||||||
|
d["tuiMode"] = "regular" # "fullscreen" is pi's default
|
||||||
|
p.write_text(json.dumps(d, indent=2) + "\n")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
If you stay on fullscreen, four related settings are worth knowing — all
|
||||||
|
documented in pi's own `docs/settings.md` under *Terminal and display*:
|
||||||
|
|
||||||
|
- `fullscreenExitOutput` — `"transcript"` (default) or `"resume-hint"`: what pi
|
||||||
|
leaves behind in the terminal when fullscreen exits.
|
||||||
|
- `fullscreenScrollbar` — `"auto"` (default), `"always"`, `"hidden"`.
|
||||||
|
- `fullscreenCopyOnSelect` — `true` by default; selecting text copies it.
|
||||||
|
- `fullscreenWheelScrollLines` — `"auto"` by default. Relevant **here** in
|
||||||
|
particular: this container is normally driven over SSH, and over SSH `"auto"`
|
||||||
|
accelerates fast wheel spins to at most 6 lines per event (local macOS
|
||||||
|
terminals accelerate on their own, so there it moves one line). `Alt`+wheel
|
||||||
|
moves five times as far.
|
||||||
|
|
||||||
|
Two notes specific to this image:
|
||||||
|
|
||||||
|
- **tmux.** Fullscreen uses the alternate screen, so `tmux` copy-mode scrollback
|
||||||
|
shows the pane's history *around* pi, not pi's transcript. Scroll within pi,
|
||||||
|
or use `"tuiMode": "regular"` if you rely on tmux copy-mode to search the
|
||||||
|
conversation.
|
||||||
|
- **pi-atelier.** The bundled sidebar works in both modes — upstream has handled
|
||||||
|
regular and fullscreen renderers separately since pi 0.84, including
|
||||||
|
fullscreen divider dragging and keeping sidebar text out of fullscreen
|
||||||
|
selection — so switching back to `regular` does not cost you the sidebar.
|
||||||
|
|
||||||
## Using pi-atelier (TUI sidebar)
|
## Using pi-atelier (TUI sidebar)
|
||||||
|
|
||||||
`pi-atelier` is bundled in **both** variants (vendored at `/opt/pi-atelier`,
|
`pi-atelier` is bundled in **both** variants (vendored at `/opt/pi-atelier`,
|
||||||
@@ -409,7 +464,10 @@ pin the order yourself.
|
|||||||
|
|
||||||
The sidebar auto-hides below 92 terminal columns and keeps the main pane at
|
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
|
least 64 columns wide, so a narrow terminal degrades to the plain TUI rather
|
||||||
than a squeezed one.
|
than a squeezed one. That is **Manual** mode, the default since atelier v0.15.0.
|
||||||
|
`/atelier sidebar auto` switches to **Auto** mode, which collapses the sidebar
|
||||||
|
when space is tight (below 124 columns at the default width) and reopens it at
|
||||||
|
132. Manual width adjustment is disabled in Auto mode.
|
||||||
|
|
||||||
## docker-compose.yml — basic shape
|
## docker-compose.yml — basic shape
|
||||||
|
|
||||||
@@ -572,7 +630,13 @@ ChromaDB ONNX embedding model so first-time semantic search is
|
|||||||
instant.
|
instant.
|
||||||
|
|
||||||
The palace data lives at `~/.mempalace/palace` on the host
|
The palace data lives at `~/.mempalace/palace` on the host
|
||||||
(bind-mounted into the container). This means:
|
(bind-mounted into the container). The image pins that layout with
|
||||||
|
`ENV MEMPALACE_CONFIG_DIR=/home/developer/.mempalace` (`Dockerfile.base`):
|
||||||
|
mempalace ≥ 3.10.0 would otherwise treat an *empty* `~/.mempalace` — a freshly
|
||||||
|
mounted volume at first boot — as "no install here" and put a new palace under
|
||||||
|
`~/.config/mempalace`, outside anything the compose files persist. With the
|
||||||
|
variable set, first in mempalace's resolution order, the location is a contract
|
||||||
|
rather than a heuristic. This means:
|
||||||
|
|
||||||
- A pi running on the host and a pi running inside this container see
|
- A pi running on the host and a pi running inside this container see
|
||||||
the same palace.
|
the same palace.
|
||||||
@@ -818,6 +882,40 @@ If you use Bedrock as pi's LLM provider:
|
|||||||
The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock
|
The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock
|
||||||
inference-profile model IDs (with `eu.` / `us.` prefixes) automatically.
|
inference-profile model IDs (with `eu.` / `us.` prefixes) automatically.
|
||||||
|
|
||||||
|
## Custom model providers (`models.json`)
|
||||||
|
|
||||||
|
pi reads extra providers from `~/.pi/agent/models.json`. That can be any
|
||||||
|
OpenAI-, Anthropic- or Google-compatible endpoint: a company gateway, vLLM,
|
||||||
|
Ollama, LM Studio and so on (pi's
|
||||||
|
[models.md](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/models.md)).
|
||||||
|
`~/.pi` is the `devbox-pi-config` volume. A file you put there by hand survives
|
||||||
|
recreates, but every new volume starts without one: another device, or after
|
||||||
|
`down -v`.
|
||||||
|
|
||||||
|
To share one file across devices, keep it in a host directory and mount that
|
||||||
|
**directory** read-only at `/opt/fleet-pi`. At every start the entrypoint links
|
||||||
|
`~/.pi/agent/models.json` to `/opt/fleet-pi/models.json`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./pi-shared:/opt/fleet-pi:ro # a directory containing models.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Mount the directory, not the file.** `git pull` and most editors replace a
|
||||||
|
file by rename. A single-file bind mount then keeps showing the old one, or
|
||||||
|
"No such file", until the next recreate. A directory mount shows the new file
|
||||||
|
at once. pi reads `models.json` when it starts, so a pulled change reaches the
|
||||||
|
next pi session without a recreate.
|
||||||
|
- **Keep keys out of the file.** Write `"apiKey": "$MY_GATEWAY_KEY"` in
|
||||||
|
`models.json` and put the value in `.env`, which `env_file:` already passes
|
||||||
|
in. The file then holds no secret and can live in git.
|
||||||
|
- **Nothing of yours is replaced.** A real `~/.pi/agent/models.json`, or a link
|
||||||
|
of your own, is left alone; `docker logs` shows one `NOTE` line saying so. With
|
||||||
|
nothing mounted, nothing happens.
|
||||||
|
- `PI_MODELS_JSON` points at another path, and `PI_MODELS_JSON=off` disables the
|
||||||
|
link. Check with `pi --list-models <provider>`. `recreate-sanity-check.sh`
|
||||||
|
verifies the link whenever a shared file is mounted.
|
||||||
|
|
||||||
## Build pipeline
|
## Build pipeline
|
||||||
|
|
||||||
pi-devbox is built from this repo's CI in two phases:
|
pi-devbox is built from this repo's CI in two phases:
|
||||||
@@ -1100,7 +1198,7 @@ persisted volumes survived, and pi runtime wiring is intact:
|
|||||||
```bash
|
```bash
|
||||||
./scripts/recreate-sanity-check.sh # auto-detects variant
|
./scripts/recreate-sanity-check.sh # auto-detects variant
|
||||||
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
|
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
|
||||||
./scripts/recreate-sanity-check.sh --expected-version 0.85.1 # assert the pi coding agent version
|
./scripts/recreate-sanity-check.sh --expected-version 1.2.0 # assert the pi coding agent version
|
||||||
```
|
```
|
||||||
|
|
||||||
Those are **two different versions**, and the flags are not interchangeable:
|
Those are **two different versions**, and the flags are not interchangeable:
|
||||||
@@ -1139,9 +1237,10 @@ resolved to `latest` at build time:
|
|||||||
|
|
||||||
| Component | Pin | Where |
|
| Component | Pin | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| pi | `0.85.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
| pi | `1.2.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||||
| pi-atelier | `v0.10.1` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
| pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
|
||||||
| mempalace | `3.9.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
| pi-atelier | `v0.15.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||||
|
| mempalace | `3.10.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||||
|
|
||||||
The objective is **not** to freeze versions. Bumping is routine — usually one
|
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
|
line plus a changelog note. The objective is that adopting a new upstream
|
||||||
|
|||||||
+54
-2
@@ -100,8 +100,21 @@ fi
|
|||||||
# existing data. `--yes` auto-accepts detected entities so the init is
|
# existing data. `--yes` auto-accepts detected entities so the init is
|
||||||
# non-interactive.
|
# non-interactive.
|
||||||
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||||
PALACE_DIR="${HOME}/.mempalace"
|
# Read the root from the same variable mempalace itself reads (set as an
|
||||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
# image ENV in Dockerfile.base since mempalace 3.10.0 started resolving
|
||||||
|
# ~/.config/mempalace for an EMPTY ~/.mempalace). The fallback keeps the
|
||||||
|
# historical location for anyone running this script with the ENV unset;
|
||||||
|
# the point of naming the variable here is that this test and mempalace's
|
||||||
|
# own resolution can no longer disagree about where the palace lives — a
|
||||||
|
# disagreement that would make this branch fire on every start.
|
||||||
|
PALACE_DIR="${MEMPALACE_CONFIG_DIR:-${HOME}/.mempalace}"
|
||||||
|
# Sentinel = config.json, because that is what `mempalace init` writes.
|
||||||
|
# It does NOT create palace/ — mining does — so the earlier test on palace/
|
||||||
|
# re-fired on every start of a container that had never mined locally
|
||||||
|
# (v1.9.4 acceptance: 1 "Initializing" line after one boot, 2 after a
|
||||||
|
# restart). Harmless (init is idempotent) but the log lied. Populated
|
||||||
|
# volumes (config.json present) skip either way.
|
||||||
|
if [ ! -f "$PALACE_DIR/config.json" ]; then
|
||||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||||
@@ -306,6 +319,35 @@ fi
|
|||||||
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
||||||
git config --global core.excludesFile "$HOME/.gitignore_global"
|
git config --global core.excludesFile "$HOME/.gitignore_global"
|
||||||
fi
|
fi
|
||||||
|
# Route git-over-ssh through the WRITABLE ssh sidecar. ~/.ssh is commonly
|
||||||
|
# bind-mounted read-only from the host, and a per-host
|
||||||
|
# ControlPath ~/.ssh/cm/%r@%h:%p
|
||||||
|
# inherited from that config (the standard CGNAT multiplexing recipe) kills every
|
||||||
|
# push with
|
||||||
|
# unix_listener: cannot bind to path ~/.ssh/cm/...: Read-only file system
|
||||||
|
# hidden behind git's misleading "Please make sure you have the correct access
|
||||||
|
# rights", which sends the reader hunting for a key problem that does not exist.
|
||||||
|
# setup-lan-access.sh (run near the top of this script) already wrote
|
||||||
|
# ~/.ssh-local/config, whose leading `Host *` block overrides ControlPath into
|
||||||
|
# the writable ~/.ssh-local/cm and only THEN `Include`s the user's own config —
|
||||||
|
# so -F repairs the socket path while keeping every per-host User/Port/
|
||||||
|
# IdentityFile. Wiring it here means no caller has to know any of that.
|
||||||
|
#
|
||||||
|
# WHY THIS IS NOT LEFT TO DOCUMENTATION: measured 2026-09-22 on tor-ms22, an
|
||||||
|
# agent with the remedy in its system prompt, in a loaded skill, in 24 palace
|
||||||
|
# drawers, AND printed verbatim by recreate-sanity-check.sh two hours earlier
|
||||||
|
# still hit this and reinvented a /tmp/sshcm workaround. The knowledge was
|
||||||
|
# available four times over, so a fifth copy is not the fix — removing the need
|
||||||
|
# to know is.
|
||||||
|
#
|
||||||
|
# The [ -r ] guard is load-bearing, not decoration: setup-lan-access.sh only
|
||||||
|
# writes the sidecar on VM-backed hosts (OrbStack / Docker Desktop). On native
|
||||||
|
# Linux Docker there is none, and pointing -F at a missing file would break EVERY
|
||||||
|
# git-over-ssh operation instead of fixing one. Respect a value the user already
|
||||||
|
# set — same first-wins convention as the three settings above.
|
||||||
|
if [ -r "$HOME/.ssh-local/config" ] && ! git config --global core.sshCommand &>/dev/null; then
|
||||||
|
git config --global core.sshCommand "ssh -F $HOME/.ssh-local/config"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||||
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
||||||
@@ -329,6 +371,16 @@ if command -v pi &>/dev/null; then
|
|||||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Shared custom providers: link ~/.pi/agent/models.json to a models.json in a
|
||||||
|
# read-only mounted DIRECTORY (default /opt/fleet-pi/models.json; PI_MODELS_JSON
|
||||||
|
# overrides, =off disables). Only ever creates a link where nothing exists —
|
||||||
|
# a real file or a link of the user's own is left alone. Without this, every
|
||||||
|
# fresh devbox-pi-config volume needed a manual `ln -s` (2026-10-12: one per
|
||||||
|
# device for the same file). Rationale and cases: the script's header.
|
||||||
|
if [ -r /usr/local/lib/pi-devbox/link-pi-models.sh ]; then
|
||||||
|
bash /usr/local/lib/pi-devbox/link-pi-models.sh || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||||
_pi_settings="$HOME/.pi/agent/settings.json"
|
_pi_settings="$HOME/.pi/agent/settings.json"
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# link-pi-models.sh — point ~/.pi/agent/models.json at a shared, mounted file.
|
||||||
|
#
|
||||||
|
# pi reads custom providers (any OpenAI-, Anthropic- or Google-compatible
|
||||||
|
# endpoint: a company gateway, vLLM, Ollama, LM Studio …) from
|
||||||
|
# ~/.pi/agent/models.json. In pi-devbox that directory is the devbox-pi-config
|
||||||
|
# named volume, which makes both obvious ways of providing the file wrong:
|
||||||
|
# - baked into the image, it would only seed a FRESH volume, once;
|
||||||
|
# - copied by hand into each volume, it is one more per-device step, and the
|
||||||
|
# copies drift the first time a price or model id changes.
|
||||||
|
# Instead, keep ONE models.json in a host directory, mount that directory
|
||||||
|
# read-only, and let this script link it in at every container start:
|
||||||
|
#
|
||||||
|
# volumes:
|
||||||
|
# - ./pi-shared:/opt/fleet-pi:ro # a directory containing models.json
|
||||||
|
#
|
||||||
|
# Mount the DIRECTORY, not the file. `git pull` (and most editors) replace a
|
||||||
|
# file by rename, and a single-file bind mount keeps the old inode and shows
|
||||||
|
# "No such file" until the next recreate (measured on Docker Desktop,
|
||||||
|
# 2026-10-12). A directory mount shows the new file at once, and pi re-reads
|
||||||
|
# models.json when it starts, so a pulled change reaches the next pi session.
|
||||||
|
#
|
||||||
|
# PI_MODELS_JSON overrides the path (default /opt/fleet-pi/models.json).
|
||||||
|
# PI_MODELS_JSON=off (or empty) disables this script.
|
||||||
|
#
|
||||||
|
# Never destructive. It only ever creates a link where nothing exists:
|
||||||
|
# - no file at the path → nothing (the case for most users)
|
||||||
|
# - models.json is already our link → nothing
|
||||||
|
# - models.json is a real file → left alone; one NOTE line says why
|
||||||
|
# - models.json links somewhere else → left alone; a deliberate choice
|
||||||
|
# A link this script made whose target later disappears (mount removed) is
|
||||||
|
# harmless: pi treats a dangling models.json exactly like a missing one —
|
||||||
|
# no error, no custom models (measured with pi 1.0.0, 2026-10-12).
|
||||||
|
#
|
||||||
|
# Called by entrypoint-user.sh. Safe to run by hand at any time.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
src="${PI_MODELS_JSON-/opt/fleet-pi/models.json}"
|
||||||
|
case "$src" in
|
||||||
|
"" | off | OFF | Off) exit 0 ;;
|
||||||
|
esac
|
||||||
|
[ -f "$src" ] && [ -r "$src" ] || exit 0
|
||||||
|
|
||||||
|
agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
||||||
|
dest="$agent_dir/models.json"
|
||||||
|
|
||||||
|
if [ -L "$dest" ]; then
|
||||||
|
# Ours already, or someone else's deliberate link: either way, nothing to do.
|
||||||
|
[ "$(readlink "$dest")" = "$src" ] || \
|
||||||
|
echo "NOTE: $dest links to $(readlink "$dest"), not $src; left alone"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -e "$dest" ]; then
|
||||||
|
echo "NOTE: $dest is a real file; not linking $src (move it away to use the shared one)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$agent_dir" 2>/dev/null || true
|
||||||
|
if ln -s "$src" "$dest" 2>/dev/null; then
|
||||||
|
echo "pi models.json: linked to $src"
|
||||||
|
else
|
||||||
|
echo "WARN: could not link $dest -> $src (continuing)"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -61,7 +61,7 @@ its skill file needed baking.
|
|||||||
`docker build` records the same thing CI does.
|
`docker build` records the same thing CI does.
|
||||||
|
|
||||||
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
|
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
|
||||||
not made one — see the Unreleased CHANGELOG entry for why (private repo;
|
not made one — see the v1.8.8 CHANGELOG entry for why (private repo;
|
||||||
another repo's branch must not be able to fail this build; and the artefact it
|
another repo's branch must not be able to fail this build; and the artefact it
|
||||||
would guard is read by no host on this fleet). The check belongs where the
|
would guard is read by no host on this fleet). The check belongs where the
|
||||||
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
|
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ 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`, `ext-toggle`, `task`, `fork-gate`, `secret-guard`, `aws-sso`, `confirm-destructive` |
|
||||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions (git-installed)** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
| `~/.pi/agent/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) |
|
| `~/.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` |
|
| `/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` |
|
||||||
|
|
||||||
|
**MCP servers are not an extension here.** pi's built-in MCP support (`builtin:mcp`) reads `~/.pi/agent/mcp.json`; manage it with `/mcp` or `pi mcp add|list`. Its tools are named `mcp__<server>__<tool>` and default to `codemode` exposure (not declared to the model) unless the server sets `"exposure": "direct"`. The `mempalace_*` tools are different: `mempalace.ts` registers them itself, so exposure never applies. The old `mcp-loader.ts` (an `mcp` block in `settings.json`) was retired on 2026-10-12, because an extension that registers `/mcp` replaces the built-in.
|
||||||
|
|
||||||
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.
|
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.
|
**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.
|
||||||
|
|||||||
@@ -181,8 +181,14 @@ check_pin() {
|
|||||||
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
|
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
|
||||||
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
|
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
|
||||||
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
|
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
|
||||||
|
# pi-obsmem became a PIN in v1.9.5 (was the floating `master`), so it joins the
|
||||||
|
# reviewable table. It is also covered by the ref-move check below, but that one
|
||||||
|
# can only ever report "unchanged" for a pinned SHA -- it answers "did upstream
|
||||||
|
# move?", never "does the table still say what we bake?", which is this check.
|
||||||
|
OBSMEM_PIN_ACTUAL="$(read_arg "$DF_VARIANT" PI_OBSMEM_REF)"
|
||||||
|
|
||||||
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
|
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
|
||||||
|
check_pin pi-obsmem "$(read_pin_row pi-obsmem)" "$OBSMEM_PIN_ACTUAL" "ARG PI_OBSMEM_REF in $DF_VARIANT"
|
||||||
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
|
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
|
||||||
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
|
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
|
||||||
|
|
||||||
|
|||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Guard: Dockerfile.variant must OPEN with its BuildKit `# check=` directive.
|
||||||
|
#
|
||||||
|
# Why this is a gate and not a comment. BuildKit parses `# check=` ONLY before
|
||||||
|
# any other line in the file, so moving it below the title comment — or dropping
|
||||||
|
# it during an unrelated header edit — silently re-enables the
|
||||||
|
# InvalidDefaultArgInFrom warning on `ARG BASE_IMAGE` / `FROM ${BASE_IMAGE}`.
|
||||||
|
#
|
||||||
|
# A re-enabled warning is not cosmetic. buildx attaches the warning's source
|
||||||
|
# context to the build metadata as buildx.build.warnings[].sourceInfo.data: the
|
||||||
|
# entire Dockerfile, base64, on ONE line. docker/build-push-action writes that
|
||||||
|
# metadata to $GITHUB_OUTPUT as a `name<<ghadelimiter_<uuid>` heredoc, and
|
||||||
|
# Gitea's act_runner truncates any single line at exactly 65536 chars. Since
|
||||||
|
# base64(Dockerfile.variant) passed 64 KiB (50034 B source -> 66712 chars, cut
|
||||||
|
# to 65536), the closing delimiter is lost and act_runner fails the job with
|
||||||
|
# invalid format delimiter 'ghadelimiter_...' not found before end of file
|
||||||
|
# while attributing it to NO step: every step reports Success, the smoke suite
|
||||||
|
# prints "0 failed", and the job is red regardless. That cost two tags —
|
||||||
|
# v1.10.0 (run 704, where a stale clipboard assertion masked it) and v1.10.1
|
||||||
|
# (run 707) — and most of a session to localise.
|
||||||
|
#
|
||||||
|
# So: one deterministic grep, run both on push AND in the publish workflow's
|
||||||
|
# lint-gate. A check that gates only `push` lets a tag regress — the same
|
||||||
|
# adoption slip that let doc-drift land 27 h after the v1.9.3 tag.
|
||||||
|
#
|
||||||
|
# Exit codes: 0 OK, 1 violation, 2 cannot-run (missing file) — matching
|
||||||
|
# lint-shell.sh / check-skill-floor.sh / check-doc-drift.sh, because a gate that
|
||||||
|
# cannot run must not pass.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DF="${1:-Dockerfile.variant}"
|
||||||
|
EXPECTED='# check=skip=InvalidDefaultArgInFrom'
|
||||||
|
|
||||||
|
if [ ! -f "$DF" ]; then
|
||||||
|
echo "::error::check-dockerfile-directives: '$DF' not found (cannot-run)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
first="$(head -n 1 "$DF")"
|
||||||
|
if [ "$first" != "$EXPECTED" ]; then
|
||||||
|
{
|
||||||
|
echo "::error::$DF line 1 must be exactly: $EXPECTED"
|
||||||
|
echo "::error::found instead: ${first:-<empty>}"
|
||||||
|
echo "::error::"
|
||||||
|
echo "::error::BuildKit only honours '# check=' before any other line. Without it the"
|
||||||
|
echo "::error::InvalidDefaultArgInFrom warning returns, buildx embeds this whole file as"
|
||||||
|
echo "::error::base64 in buildx.build.warnings[].sourceInfo.data, that single line exceeds"
|
||||||
|
echo "::error::act_runner's 65536-char cap, and the smoke jobs fail with"
|
||||||
|
echo "::error:: invalid format delimiter 'ghadelimiter_...' not found before end of file"
|
||||||
|
echo "::error::and NO failing step. See the header of $DF for the full measurement."
|
||||||
|
} >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Second, independent assertion: the condition the directive exists FOR. If a
|
||||||
|
# later edit gives ARG BASE_IMAGE a default, the directive becomes dead weight
|
||||||
|
# and should be removed deliberately rather than left to rot — and if the ARG is
|
||||||
|
# renamed, this guard would otherwise keep passing while guarding nothing.
|
||||||
|
if ! grep -qE '^ARG BASE_IMAGE$' "$DF"; then
|
||||||
|
{
|
||||||
|
echo "::error::$DF no longer contains a bare 'ARG BASE_IMAGE' (no default)."
|
||||||
|
echo "::error::That is the only thing '$EXPECTED' suppresses. Either restore the bare ARG"
|
||||||
|
echo "::error::or drop the directive and this guard together — do not leave a skip"
|
||||||
|
echo "::error::directive pointing at a check that can no longer fire."
|
||||||
|
} >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Dockerfile directive guard OK — $DF opens with the check-skip directive and still declares a bare ARG BASE_IMAGE."
|
||||||
Regular → Executable
@@ -277,6 +277,24 @@ else
|
|||||||
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# shared models.json (link-pi-models.sh) — checked only when a shared file is
|
||||||
|
# actually mounted, so a device without one is not judged by it.
|
||||||
|
_mj_src="${PI_MODELS_JSON-/opt/fleet-pi/models.json}"
|
||||||
|
case "$_mj_src" in "" | off | OFF | Off) _mj_src="" ;; esac
|
||||||
|
if [ -n "$_mj_src" ] && [ -f "$_mj_src" ]; then
|
||||||
|
_mj="$HOME/.pi/agent/models.json"
|
||||||
|
if [ -L "$_mj" ] && [ "$(readlink "$_mj")" = "$_mj_src" ]; then
|
||||||
|
pass "~/.pi/agent/models.json -> $_mj_src (shared custom providers)"
|
||||||
|
elif [ -L "$_mj" ]; then
|
||||||
|
warn "~/.pi/agent/models.json links to $(readlink "$_mj"), not the mounted $_mj_src"
|
||||||
|
elif [ -e "$_mj" ]; then
|
||||||
|
warn "~/.pi/agent/models.json is a real file, so the mounted $_mj_src is NOT in use"
|
||||||
|
else
|
||||||
|
fail "$_mj_src is mounted but ~/.pi/agent/models.json is missing (image without link-pi-models.sh? run: ln -s $_mj_src ~/.pi/agent/models.json)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
unset _mj _mj_src
|
||||||
|
|
||||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||||
@@ -285,6 +303,24 @@ else
|
|||||||
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# no dangling extension links. The count above includes them (ls lists a link
|
||||||
|
# whether or not its target exists) but they load nothing, and pi skips them
|
||||||
|
# without a word. That is exactly how a retired extension outlives its image on
|
||||||
|
# a preserved volume: mcp-loader.ts (retired after v1.11.0) left
|
||||||
|
# ~/.pi/agent/extensions/mcp-loader.ts -> /opt/pi-extensions/... behind. The
|
||||||
|
# pi-extensions install.sh run by the entrypoint now prunes links into ITS repo;
|
||||||
|
# anything this still finds points somewhere else, hence WARN, not FAIL.
|
||||||
|
_dangling=""
|
||||||
|
for _e in "$HOME"/.pi/agent/extensions/*.ts "$HOME"/.pi/agent/extensions/*.ts.off; do
|
||||||
|
if [ -L "$_e" ] && [ ! -e "$_e" ]; then _dangling="$_dangling $(basename "$_e")"; fi
|
||||||
|
done
|
||||||
|
if [ -z "$_dangling" ]; then
|
||||||
|
pass "no dangling extension links in ~/.pi/agent/extensions"
|
||||||
|
else
|
||||||
|
warn "dangling extension links (target gone, pi ignores them):$_dangling"
|
||||||
|
fi
|
||||||
|
unset _dangling _e
|
||||||
|
|
||||||
# mempalace.ts bridge symlink
|
# mempalace.ts bridge symlink
|
||||||
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||||
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
||||||
@@ -502,13 +538,33 @@ _ssh_cm_probe() {
|
|||||||
elif [ "${#bad[@]}" -eq 0 ]; then
|
elif [ "${#bad[@]}" -eq 0 ]; then
|
||||||
pass "$label: $n ControlMaster host(s), every ControlPath dir exists and is writable"
|
pass "$label: $n ControlMaster host(s), every ControlPath dir exists and is writable"
|
||||||
elif [ "$sev" = "warn" ]; then
|
elif [ "$sev" = "warn" ]; then
|
||||||
warn "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — expected when ~/.ssh/config pins ControlPath inside the read-only ~/.ssh; use 'ssh -F ~/.ssh-local/config' (see Dockerfile.base CAVEAT)"
|
warn "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — STRUCTURAL and permanent while ~/.ssh/config pins ControlPath inside the read-only ~/.ssh, so this line can never reach zero and is not a to-do; $_git_ssh_note (see Dockerfile.base CAVEAT)"
|
||||||
else
|
else
|
||||||
fail "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — ssh dies rc=255 'unix_listener: cannot bind to path' and the remote command never runs"
|
fail "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — ssh dies rc=255 'unix_listener: cannot bind to path' and the remote command never runs"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
if command -v ssh >/dev/null 2>&1; then
|
if command -v ssh >/dev/null 2>&1; then
|
||||||
|
# Whether git-over-ssh already routes through the sidecar decides how much the
|
||||||
|
# permanent default-route warning below actually matters, so state it IN that
|
||||||
|
# message rather than leaving each reader to work it out. Asserted properly as
|
||||||
|
# its own pass/fail in the next section.
|
||||||
|
#
|
||||||
|
# THREE states, not two, and the [ -r ] test is why: core.sshCommand NAMING the
|
||||||
|
# sidecar does not mean the sidecar EXISTS. Without that test, the arm where the
|
||||||
|
# path is wired but the file is gone printed "git IS wired ... unaffected" about
|
||||||
|
# a state in which every single git-over-ssh call fails. Found by exercising all
|
||||||
|
# five arms of this check rather than only the healthy one.
|
||||||
|
if [ ! -r "$HOME/.ssh-local/config" ]; then
|
||||||
|
_git_ssh_note="there is no ssh sidecar on this host, so nothing for -F to point at and these hosts cannot multiplex at all — see the git-over-ssh check below"
|
||||||
|
elif command -v git >/dev/null 2>&1 &&
|
||||||
|
git config --global --get core.sshCommand 2>/dev/null |
|
||||||
|
grep -qF -- "-F $HOME/.ssh-local/config"; then
|
||||||
|
_git_ssh_note="git IS wired to the sidecar (core.sshCommand), so git push/fetch is unaffected; bare 'ssh' to these hosts still needs 'ssh -F ~/.ssh-local/config'"
|
||||||
|
else
|
||||||
|
_git_ssh_note="git is NOT wired to the sidecar (see the git-over-ssh check below), so both git and bare 'ssh' need 'ssh -F ~/.ssh-local/config'"
|
||||||
|
fi
|
||||||
|
|
||||||
# Concrete Host aliases only: patterns (*, ?) and negations (!) are not
|
# Concrete Host aliases only: patterns (*, ?) and negations (!) are not
|
||||||
# connectable targets, so `ssh -G` on them proves nothing.
|
# connectable targets, so `ssh -G` on them proves nothing.
|
||||||
_cm_cfgs=()
|
_cm_cfgs=()
|
||||||
@@ -535,6 +591,46 @@ else
|
|||||||
warn "ssh not on PATH — effective ControlPath not verified"
|
warn "ssh not on PATH — effective ControlPath not verified"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- git-over-ssh routed through the writable ssh sidecar --"
|
||||||
|
# The one question in this area that is BINARY, FIXABLE, and therefore worth a
|
||||||
|
# check that can reach zero and stay there.
|
||||||
|
#
|
||||||
|
# The ControlPath warning above cannot: while a bind-mounted ~/.ssh/config pins
|
||||||
|
# ControlPath inside the read-only ~/.ssh, the default route will ALWAYS resolve
|
||||||
|
# to an unwritable dir, so that line fires benignly on every run of every device
|
||||||
|
# forever — and the script's own comment above says why that is dangerous: it is
|
||||||
|
# a warning you learn to skip. MEASURED 2026-09-22, exactly that: an agent ran
|
||||||
|
# this script, recorded "two by-design warnings", then two hours later hit
|
||||||
|
# `unix_listener: cannot bind ... Read-only file system` on `git push`, failed to
|
||||||
|
# connect it to the warning it had already read, and reinvented a /tmp/sshcm
|
||||||
|
# workaround — while the remedy string sat inside the dismissed warning.
|
||||||
|
# entrypoint-user.sh now wires core.sshCommand to the sidecar so nobody has to
|
||||||
|
# know; this section asserts the wiring actually happened, which is the part a
|
||||||
|
# reader can act on.
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
warn "git not on PATH — sidecar wiring not verified"
|
||||||
|
elif [ ! -r "$HOME/.ssh-local/config" ]; then
|
||||||
|
# No sidecar (native Linux Docker, where setup-lan-access.sh writes none). The
|
||||||
|
# correct state is UNSET: -F pointing at a missing file breaks every
|
||||||
|
# git-over-ssh call, which is worse than the problem being solved.
|
||||||
|
if [ -z "$(git config --global --get core.sshCommand 2>/dev/null || true)" ]; then
|
||||||
|
pass "no ssh sidecar on this host and core.sshCommand correctly unset (guard holds)"
|
||||||
|
else
|
||||||
|
fail "core.sshCommand is set but ~/.ssh-local/config does not exist — every git-over-ssh call dies on a missing -F file; entrypoint-user.sh's [ -r ] guard did not hold"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
_git_ssh_cmd=$(git config --global --get core.sshCommand 2>/dev/null || true)
|
||||||
|
case "$_git_ssh_cmd" in
|
||||||
|
*"-F $HOME/.ssh-local/config"*)
|
||||||
|
pass "git core.sshCommand routes through the sidecar ($_git_ssh_cmd)" ;;
|
||||||
|
'')
|
||||||
|
fail "a sidecar exists but git core.sshCommand is unset — 'git push' to any host whose config pins ControlPath inside the read-only ~/.ssh dies rc=255 behind git's misleading 'correct access rights'. entrypoint-user.sh should set it; expected on images built before that wiring landed, where the fix is: git config --global core.sshCommand \"ssh -F \$HOME/.ssh-local/config\"" ;;
|
||||||
|
*)
|
||||||
|
warn "git core.sshCommand set to something else and left alone (first-wins, deliberate): $_git_ssh_cmd" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
||||||
if [ -f "$HOME/.bash_aliases" ]; then
|
if [ -f "$HOME/.bash_aliases" ]; then
|
||||||
|
|||||||
+92
-8
@@ -30,7 +30,8 @@
|
|||||||
# client bundle present + registered via `pi install`
|
# client bundle present + registered via `pi install`
|
||||||
# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host
|
# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host
|
||||||
# - no build-time npm cache (/root/.npm) shipped in the image
|
# - no build-time npm cache (/root/.npm) shipped in the image
|
||||||
# - esbuild compiles + clipboard native loads at every install site
|
# - esbuild present iff something declares it, and compiles everywhere;
|
||||||
|
# clipboard present iff pi declares it
|
||||||
# - image size within threshold
|
# - image size within threshold
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -250,6 +251,22 @@ run_expect "remote-palace-without-inbox skip is announced, not silent" \
|
|||||||
"MemPalace catch-up skipped"
|
"MemPalace catch-up skipped"
|
||||||
run "...and the skip notice names the variable that fixes it" \
|
run "...and the skip notice names the variable that fixes it" \
|
||||||
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
|
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
|
||||||
|
# git-over-ssh must be wired to the writable ssh sidecar, because ~/.ssh is
|
||||||
|
# commonly bind-mounted READ-ONLY and a per-host `ControlPath ~/.ssh/cm/...`
|
||||||
|
# inherited from it kills every push with `unix_listener: cannot bind ...:
|
||||||
|
# Read-only file system` behind git's misleading "correct access rights".
|
||||||
|
#
|
||||||
|
# STATIC assertion, deliberately. `run` executes `docker run --entrypoint=""`, so
|
||||||
|
# entrypoint-user.sh never runs here and `git config --global core.sshCommand` is
|
||||||
|
# necessarily unset — asserting the VALUE in this harness would repeat the v1.8.0
|
||||||
|
# mistake (an assertion that cannot pass, unvalidated until the next tag). What
|
||||||
|
# IS checkable at build time is that the wiring code shipped in the image. The
|
||||||
|
# runtime value is asserted below in the Runtime deployment section, where the
|
||||||
|
# real entrypoint chain has run.
|
||||||
|
run "entrypoint wires git core.sshCommand to the ssh sidecar" \
|
||||||
|
"grep -q 'core.sshCommand \"ssh -F' /usr/local/bin/entrypoint-user.sh"
|
||||||
|
run "...and guards it on the sidecar existing (native Linux has none)" \
|
||||||
|
"grep -B2 'core.sshCommand \"ssh -F' /usr/local/bin/entrypoint-user.sh | grep -q '\\[ -r \"\$HOME/.ssh-local/config\" \\]'"
|
||||||
# A remote mine that FAILS must not report success. MCP answers a hard tool
|
# A remote mine that FAILS must not report success. MCP answers a hard tool
|
||||||
# failure with HTTP 200 and the tool's own JSON escaped inside
|
# failure with HTTP 200 and the tool's own JSON escaped inside
|
||||||
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
|
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
|
||||||
@@ -656,9 +673,28 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
|
|||||||
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
||||||
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
||||||
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
|
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||||
|
exec_test "no shared models.json mounted → none linked" 'test ! -e $HOME/.pi/agent/models.json && test ! -L $HOME/.pi/agent/models.json'
|
||||||
|
exec_test "link-pi-models.sh links a mounted models.json" 'mkdir -p /tmp/lpm && echo "{}" > /tmp/lpm/models.json && PI_MODELS_JSON=/tmp/lpm/models.json PI_CODING_AGENT_DIR=/tmp/lpm/agent bash /usr/local/lib/pi-devbox/link-pi-models.sh >/dev/null && test "$(readlink /tmp/lpm/agent/models.json)" = /tmp/lpm/models.json && rm -rf /tmp/lpm'
|
||||||
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
||||||
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
||||||
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||||
|
# git-over-ssh sidecar wiring, asserted as a BICONDITIONAL rather than "is set".
|
||||||
|
# setup-lan-access.sh writes ~/.ssh-local/config only on VM-backed hosts, and a
|
||||||
|
# CI runner is native Linux Docker — so "core.sshCommand is set" would fail here
|
||||||
|
# for a correct image, which is precisely the v1.8.0 trap (an assertion whose
|
||||||
|
# environment was never checked, unvalidated until the next tag). Both arms are
|
||||||
|
# real: sidecar present => must route through it; sidecar absent => must be UNSET,
|
||||||
|
# because -F pointing at a missing file breaks every git-over-ssh call and is
|
||||||
|
# worse than the problem being fixed. This arm is the one CI actually exercises,
|
||||||
|
# so CI validates the guard; the other is covered by the static greps above and
|
||||||
|
# by scripts/recreate-sanity-check.sh on a real device.
|
||||||
|
exec_test "git core.sshCommand matches sidecar presence" '
|
||||||
|
cmd=$(git config --global --get core.sshCommand 2>/dev/null || true)
|
||||||
|
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||||
|
case "$cmd" in *"-F $HOME/.ssh-local/config"*) echo "wired: $cmd" ;; *) exit 1 ;; esac
|
||||||
|
else
|
||||||
|
[ -z "$cmd" ] || exit 1; echo "no sidecar, correctly unset"
|
||||||
|
fi'
|
||||||
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
||||||
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
|
||||||
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
||||||
@@ -950,14 +986,62 @@ run "no build-time npm cache shipped (/root/.npm)" \
|
|||||||
# runbook as "if this fails, revert the release", and it duly failed for the
|
# runbook as "if this fails, revert the release", and it duly failed for the
|
||||||
# wrong reason on the first machine that ran it. A check must fail only for the
|
# wrong reason on the first machine that ran it. A check must fail only for the
|
||||||
# thing it is checking.
|
# thing it is checking.
|
||||||
run "esbuild works at every install site (prune removed weight, not function)" \
|
#
|
||||||
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/esbuild" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no esbuild install found at all" >&2; exit 1; fi; for d in $sites; do node -e "require(\"$d\").transformSync(\"const x:number=1\",{loader:\"ts\"})" || { echo "esbuild broken at $d" >&2; exit 1; }; done; echo ok'
|
# PRESENT IFF DECLARED, like clipboard below. esbuild was never a direct pi
|
||||||
|
# dependency: it arrived through @earendil-works/chord (pi 1.1.0 > chord >
|
||||||
|
# esbuild 0.28.2, at both the pi and pi-fork sites). pi 1.2.0 dropped chord, so
|
||||||
|
# a correct 1.2.0 image has NO esbuild, and the old "must exist" form failed the
|
||||||
|
# v1.11.1 tag build (102/103 core, 105/106 studio, nothing published) against a
|
||||||
|
# good image — the same mistake the clipboard check made at v1.10.0. Because it
|
||||||
|
# is transitive, "declared" cannot be read from pi's package.json: it is
|
||||||
|
# derived from every INSTALLED package root (node_modules/<pkg>/package.json,
|
||||||
|
# node_modules/@scope/<pkg>/package.json) and every /opt repo root, counting
|
||||||
|
# dependencies, optionalDependencies and peerDependencies (devDependencies are
|
||||||
|
# not installed: --omit=dev). Anchoring on package roots keeps a test fixture's
|
||||||
|
# package.json from counting. Both directions fail: declared but missing (the
|
||||||
|
# prune removed function), and installed with nothing declaring it (residue).
|
||||||
|
run "esbuild: present iff something declares it, and works at every install site (prune removed weight, not function)" \
|
||||||
|
'declarers=$(find /usr/lib/node_modules /opt -regextype posix-extended -type f \( -regex ".*/node_modules/(@[^/]+/)?[^/]+/package\.json" -o -regex "/opt/[^/]+/package\.json" \) 2>/dev/null | node -e "
|
||||||
|
const fs = require(\"fs\"); const hit = [];
|
||||||
|
for (const f of fs.readFileSync(0, \"utf8\").split(String.fromCharCode(10)).filter(Boolean)) {
|
||||||
|
let j; try { j = JSON.parse(fs.readFileSync(f, \"utf8\")); } catch (e) { continue; }
|
||||||
|
if (!j || j.name === \"esbuild\") continue;
|
||||||
|
for (const k of [\"dependencies\", \"optionalDependencies\", \"peerDependencies\"]) {
|
||||||
|
if (j[k] && Object.prototype.hasOwnProperty.call(j[k], \"esbuild\")) { hit.push(f); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(hit.join(\" \"));") || { echo "declarer scan itself failed" >&2; exit 1; };
|
||||||
|
sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/esbuild" -prune 2>/dev/null);
|
||||||
|
if [ -n "$declarers" ]; then
|
||||||
|
[ -n "$sites" ] || { echo "declared by $declarers but NO install found - prune removed function" >&2; exit 1; };
|
||||||
|
for d in $sites; do node -e "require(\"$d\").transformSync(\"const x:number=1\",{loader:\"ts\"})" || { echo "esbuild broken at $d" >&2; exit 1; }; done;
|
||||||
|
echo "ok: declared ($(echo $declarers | wc -w) package(s)) and compiles at $(echo $sites | wc -w) site(s)";
|
||||||
|
else
|
||||||
|
[ -z "$sites" ] || { echo "nothing declares esbuild yet installs remain: $sites" >&2; exit 1; };
|
||||||
|
echo "ok: nothing declares esbuild (pi >= 1.2.0 dropped chord); no install expected";
|
||||||
|
fi'
|
||||||
|
|
||||||
# Clipboard is the family pruned second, and its napi-rs loader picks its native
|
# Clipboard is the family pruned second. Upstream pi 0.86.0 (#9163) REPLACED the
|
||||||
# binding at require() time — so a successful load IS the proof that the kept
|
# external `@mariozechner/clipboard` dependency with bundled macOS/Windows/X11
|
||||||
# platform package is the one this image needs.
|
# helpers plus the OSC 52 fallback, so from 0.86.0 on there is nothing external to
|
||||||
run "clipboard native loads at every install site" \
|
# prune and nothing to require(). pi 0.85.1 declared it; 0.87.1 and 1.0.0 do not.
|
||||||
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/@mariozechner/clipboard" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no @mariozechner/clipboard install found at all" >&2; exit 1; fi; for d in $sites; do node -e "var c=require(\"$d\"); if (typeof c.setText !== \"function\") { throw new Error(\"native binding missing\"); }" || { echo "clipboard native broken at $d" >&2; exit 1; }; done; echo ok'
|
# This assertion therefore derives its expectation from what the image's pi
|
||||||
|
# actually DECLARES rather than hardcoding either state: it re-arms by itself if a
|
||||||
|
# future pi re-adds the dependency, and it still fails if an install lingers that
|
||||||
|
# nothing depends on. Hardcoding "must exist" is what failed the v1.10.0 tag build
|
||||||
|
# (100 passed, 1 failed, nothing published) against a perfectly correct image.
|
||||||
|
run "clipboard native: present iff pi declares it (prune removed weight, not function)" \
|
||||||
|
'PKG=/usr/lib/node_modules/@earendil-works/pi-coding-agent/package.json;
|
||||||
|
if grep -q "\"@mariozechner/clipboard\"" "$PKG" 2>/dev/null; then declared=yes; else declared=no; fi;
|
||||||
|
sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/@mariozechner/clipboard" -prune 2>/dev/null);
|
||||||
|
if [ "$declared" = yes ]; then
|
||||||
|
[ -n "$sites" ] || { echo "pi declares @mariozechner/clipboard but NO install found - prune removed function" >&2; exit 1; };
|
||||||
|
for d in $sites; do node -e "var c=require(\"$d\"); if (typeof c.setText !== \"function\") { throw new Error(\"native binding missing\"); }" || { echo "clipboard native broken at $d" >&2; exit 1; }; done;
|
||||||
|
echo "ok: pi declares it and the native binding loads at every site";
|
||||||
|
else
|
||||||
|
[ -z "$sites" ] || { echo "pi no longer declares @mariozechner/clipboard yet installs remain: $sites" >&2; exit 1; };
|
||||||
|
echo "ok: pi bundles clipboard since upstream 0.86.0 (#9163); no external install expected";
|
||||||
|
fi'
|
||||||
|
|
||||||
# ── Image size ────────────────────────────────────────────────────────
|
# ── Image size ────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-link-pi-models.sh — two-sided tests for rootfs/usr/local/lib/pi-devbox/link-pi-models.sh.
|
||||||
|
#
|
||||||
|
# Runs the real script against a throwaway HOME / PI_CODING_AGENT_DIR, so it never
|
||||||
|
# touches a live ~/.pi/agent. Every "must link" case has a "must NOT touch" twin:
|
||||||
|
# the script's whole contract is that it only ever creates a link where nothing
|
||||||
|
# exists, so the negative cases are the important half.
|
||||||
|
#
|
||||||
|
# bash scripts/test-link-pi-models.sh # CI (lint.yml) and locally
|
||||||
|
# LINK_PI_MODELS=/path/to/variant.sh bash scripts/test-link-pi-models.sh
|
||||||
|
set -u
|
||||||
|
here="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SCRIPT="${LINK_PI_MODELS:-$here/rootfs/usr/local/lib/pi-devbox/link-pi-models.sh}"
|
||||||
|
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||||
|
bad() { printf ' ❌ %s — %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
# One fresh world per case: shared dir with models.json, empty HOME, agent dir unset.
|
||||||
|
world() {
|
||||||
|
rm -rf "$T/w"; mkdir -p "$T/w/shared" "$T/w/home"
|
||||||
|
printf '{"providers":{}}\n' > "$T/w/shared/models.json"
|
||||||
|
SRC="$T/w/shared/models.json"; AG="$T/w/home/.pi/agent"; DEST="$AG/models.json"
|
||||||
|
}
|
||||||
|
# run [env assignments...] — run the script with HOME and PI_CODING_AGENT_DIR pointed into the world.
|
||||||
|
# "$@" goes FIRST so an option like `-u PI_MODELS_JSON` is still parsed as one by env.
|
||||||
|
run() { OUT="$(env "$@" HOME="$T/w/home" PI_CODING_AGENT_DIR="$AG" bash "$SCRIPT" 2>&1)"; RC=$?; }
|
||||||
|
|
||||||
|
echo "── link-pi-models.sh ($SCRIPT)"
|
||||||
|
|
||||||
|
world; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ -L "$DEST" ] && [ "$(readlink "$DEST")" = "$SRC" ] && [[ "$OUT" == *linked* ]]; then
|
||||||
|
ok "links models.json when the shared file exists and nothing is there (creates the agent dir)"
|
||||||
|
else bad "link when absent" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
|
||||||
|
|
||||||
|
run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ -z "$OUT" ] && [ "$(readlink "$DEST")" = "$SRC" ]; then
|
||||||
|
ok "second run is a silent no-op (every container start runs it)"
|
||||||
|
else bad "idempotent" "rc=$RC out=[$OUT]"; fi
|
||||||
|
|
||||||
|
world; mkdir -p "$AG"; printf 'my own\n' > "$DEST"; before="$(sha256sum "$DEST")"; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ ! -L "$DEST" ] && [ "$(sha256sum "$DEST")" = "$before" ] && [[ "$OUT" == NOTE*"real file"* ]]; then
|
||||||
|
ok "never replaces a real models.json (content unchanged, one NOTE line)"
|
||||||
|
else bad "real file kept" "rc=$RC islink=$([ -L "$DEST" ] && echo y) out=[$OUT]"; fi
|
||||||
|
|
||||||
|
world; mkdir -p "$AG"; ln -s /somewhere/else.json "$DEST"; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST")" = /somewhere/else.json ] && [[ "$OUT" == NOTE*"left alone"* ]]; then
|
||||||
|
ok "never re-points a link the user made to another file"
|
||||||
|
else bad "foreign link kept" "rc=$RC link=$(readlink "$DEST") out=[$OUT]"; fi
|
||||||
|
|
||||||
|
world; rm -f "$SRC"; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||||
|
ok "no shared file → creates nothing and says nothing (the default for most users)"
|
||||||
|
else bad "absent source" "rc=$RC out=[$OUT]"; fi
|
||||||
|
|
||||||
|
world; rm -f "$SRC"; mkdir -p "$SRC"; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ]; then
|
||||||
|
ok "a directory at the path is not a models.json → nothing"
|
||||||
|
else bad "directory source" "rc=$RC out=[$OUT]"; fi
|
||||||
|
|
||||||
|
for v in off OFF ""; do
|
||||||
|
world; run PI_MODELS_JSON="$v"
|
||||||
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||||
|
ok "PI_MODELS_JSON='$v' disables it"
|
||||||
|
else bad "disable '$v'" "rc=$RC out=[$OUT]"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
world; run PI_MODELS_JSON="$SRC"; rm -f "$SRC"; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ -L "$DEST" ] && [ "$(readlink "$DEST")" = "$SRC" ] && [ -z "$OUT" ]; then
|
||||||
|
ok "our link whose target vanished (mount removed) is left as is, silently (pi treats it as no file)"
|
||||||
|
else bad "dangling own link" "rc=$RC out=[$OUT]"; fi
|
||||||
|
|
||||||
|
# pi honours PI_CODING_AGENT_DIR, so the link must land THERE, not in $HOME/.pi/agent.
|
||||||
|
world; AG="$T/w/elsewhere/agent"; DEST="$AG/models.json"; run PI_MODELS_JSON="$SRC"
|
||||||
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST" 2>/dev/null)" = "$SRC" ] && [ ! -e "$T/w/home/.pi/agent/models.json" ]; then
|
||||||
|
ok "links into PI_CODING_AGENT_DIR when set (and leaves \$HOME/.pi/agent alone)"
|
||||||
|
else bad "PI_CODING_AGENT_DIR" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
|
||||||
|
|
||||||
|
# 'off' must be a switch, not merely a path that happens not to exist: with a file
|
||||||
|
# literally named "off" in the working directory it must still disable.
|
||||||
|
world; printf '{}\n' > "$T/w/off"; OUT="$(cd "$T/w" && env PI_MODELS_JSON=off HOME="$T/w/home" PI_CODING_AGENT_DIR="$AG" bash "$SCRIPT" 2>&1)"; RC=$?
|
||||||
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ]; then
|
||||||
|
ok "PI_MODELS_JSON=off disables it even when a file named 'off' exists in the cwd"
|
||||||
|
else bad "off is a switch" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
|
||||||
|
|
||||||
|
# The default path is /opt/fleet-pi/models.json. Whether it exists depends on where
|
||||||
|
# this runs (a fleet devbox: yes; CI: no), so derive the expectation, don't assume it.
|
||||||
|
world; run -u PI_MODELS_JSON
|
||||||
|
if [ -f /opt/fleet-pi/models.json ]; then
|
||||||
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST" 2>/dev/null)" = /opt/fleet-pi/models.json ]; then
|
||||||
|
ok "PI_MODELS_JSON unset → default /opt/fleet-pi/models.json (present here) is linked"
|
||||||
|
else bad "default path (present)" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
|
||||||
|
else
|
||||||
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||||
|
ok "PI_MODELS_JSON unset → default /opt/fleet-pi/models.json (absent here) → nothing"
|
||||||
|
else bad "default path (absent)" "rc=$RC out=[$OUT]"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── $PASS passed, $FAIL failed"
|
||||||
|
[ "$FAIL" = 0 ]
|
||||||
Reference in New Issue
Block a user