Compare commits

...

23 Commits

Author SHA1 Message Date
joakimp ea6605bfae release(v1.12.0): pi 1.3.0, mempalace 3.11.0, pi-atelier v0.16.0
Lint / hadolint (push) Successful in 11s
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Successful in 14s
Lint / actionlint (push) Successful in 24s
Publish Docker Image / lint-gate (push) Successful in 27s
Publish Docker Image / resolve-versions (push) Successful in 11s
Publish Docker Image / base-decide (push) Successful in 15s
Publish Docker Image / build-base (push) Successful in 55m24s
Publish Docker Image / smoke-studio (push) Successful in 6m45s
Publish Docker Image / smoke (push) Successful in 9m1s
Publish Docker Image / build-variant-studio (push) Successful in 18m17s
Publish Docker Image / build-variant (push) Successful in 16m56s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Every component at its newest upstream release; each pinned one audited
against this image before tagging, none breaking.

- ARG PI_VERSION 1.2.0 -> 1.3.0. No breaking-changes section. pi-agent-core,
  pi-tui and dist/core/extensions are byte-identical to 1.2.0. The only
  removed export (parseModelPattern, not index-exported) and pi-ai's dropped
  assistant-message-frame re-export have 0 consumers; all 54 runtime-imported
  names across the stack and /opt/fleet-pi resolve (negative control
  reported). With a copy of tor-ms22's ~/.pi/agent: pi -p exits 0 in 10 s,
  all extensions load, bridge 45 tools, /om:* and /studio offered, atelier
  paints in both renderers (-ne controls: none).
- ARG MEMPALACE_VERSION 3.10.0 -> 3.11.0. init --yes still writes
  config.json; a 3.10.0-built palace opens, searches and serves MCP under
  3.11.0, re-mines its convo file once, then skips it; stdio MCP 45 -> 47
  tools, none removed. research() removal: 0 callers. synlig hub stays
  3.10.0 (newer-client direction); upgrade notes in CHANGELOG.
- ARG PI_ATELIER_REF/VERSION v0.15.0 -> v0.16.0 (3b42825). Additive
  (keyValues rows, progress bars, two roles, meter background). Protocol
  and pi floor unchanged; fleet:todos panel renders a real todo on both
  versions; the meter-background escape tells the two arms apart.
- Base rebuilds anyway, so BASE_REBUILD_DATE marker refreshed (uv 0.13.1,
  gitea-mcp 1.9.0, Node 24.22.0 re-resolve).
- CHANGELOG: Unreleased -> v1.12.0 — 2026-10-18. README pin table (and
  "Three" -> "Four" components), sanity-check example; DOCKER_HUB.md's stale
  "29 mempalace_* tools".

Local gates: check-doc-drift rc 0 (check 9 names pi, mempalace, atelier,
pi-extensions), lint-shell, check-workflow-shell, test-link-pi-models
13/13, test-link-pi-extensions 16/16, directive guard, skill floor.
2026-10-18 17:19:26 +02:00
Joakim Persson a6abe17edc entrypoint: link fleet pi extensions from the mounted config dir
Lint / skill-floor (push) Successful in 7s
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 19s
Lint / doc-drift (push) Successful in 17s
pi auto-loads every .ts in ~/.pi/agent/extensions/, which lives in the
devbox-pi-config volume. A baked extension only seeds a fresh volume once, and a
hand-copied one is a per-device step that drifts — the same two problems
link-pi-models.sh was added for on 2026-10-12, one level down. This is also the
only path that reaches every host: four of them mount common/pi from
docker-compose-repo, and three cannot be reached from a devbox to create a
symlink by hand (measured: no ssh config entry, no name resolution, and this
instance's ~/.ssh-local/config carries no jump alias — only gitea and the
mempalace feed host are reachable).

link-pi-extensions.sh links each TOP-LEVEL *.ts from the mounted fleet directory
(default /opt/fleet-pi, the same directory that already carries models.json;
PI_EXTENSIONS_SRC overrides, =off disables). A symlink, not a single-file bind
mount, for the measured reason already documented for models.json: git pull
replaces a file by rename, and a file mount then serves stale content. Never
destructive — a real file of that name in ~/.pi/agent/extensions/ wins over the
fleet copy. Nested *.ts are deliberately not linked: the directory is fleet pi
config, not an extension tree.

- scripts/test-link-pi-extensions.sh: 16 two-sided cases (the negative half is
  the contract), all passing, wired into lint.yml beside the models one.
- smoke-test.sh and recreate-sanity-check.sh gained the matching in-image and
  on-device checks, each gated on the fleet directory actually holding *.ts so a
  device without a shared directory is not judged by them.
- README: the shared directory can carry extensions, and the rules that apply.

Also repairs a pre-existing release blocker, found while checking this change.
check-doc-drift.sh failed on pristine HEAD (verified in a clean worktree):
pi-extensions main moved 0d8e417 -> 4c1d28d without the SHA being named above
the v1.11.2 heading, and docker-publish.yml's resolve-versions has
needs: [lint-gate] — so the next tag could not have published. The CHANGELOG now
names 4c1d28d and describes the two confirm-destructive commits from their diff
(they stop the gate prompting on harmless redirects, and add tee/dd of= as write
forms); node --test test/confirm-destructive.test.mjs at 4c1d28d: 2 pass, 0 fail.

Local verification: lint-shell.sh (21 files, severity error), check-workflow-shell.sh,
check-skill-floor.sh, check-dockerfile-directives.sh, check-doc-drift.sh, and both
link-pi-*.sh contract suites all pass. actionlint and hadolint are not installed in
a devbox, so those CI steps are unvalidated here; the smoke-test addition runs only
inside the image at release time.
2026-10-16 19:01:04 +02:00
joakimp 5becbba6c8 docs: Studio's PDF export needs LaTeX, in AGENTS.md and Dockerfile.base too
Lint / skill-floor (push) Successful in 9s
Lint / hadolint (push) Successful in 12s
Lint / doc-drift (push) Successful in 14s
Lint / actionlint (push) Successful in 22s
AGENTS.md ("What we DON'T install") and two comments in Dockerfile.base's
typst block still said pi-studio's PDF export uses typst. Same correction
as 77929b7 for the README. AGENTS.md also warns maintainers not to ship
ENV PANDOC_PDF_ENGINE=typst, since pi-studio's LaTeX header is printed as
page text under typst while pandoc exits 0.

Corrects 77929b7: "texlive-xetex alone is not enough" holds only with
--no-install-recommends. With apt's defaults, texlive-xetex (and the old
AGENTS recipe, texlive-xetex texlive-latex-recommended: the same set) works,
but installs 202 packages / 1053 MB including systemd, D-Bus and a JRE.
The README recipe (4 packages, --no-install-recommends) is 37 / 491 MB.
All three recipes measured from the image's 401 packages on v1.11.2 +
pi-studio 0.10.1, checking the exported page, not only the exit code.

Dockerfile.base changes are comments only, but base_tag hashes the whole
file; the next release rebuilds the base anyway.
2026-10-15 01:04:40 +02:00
joakimp 77929b73df docs(readme): Studio's PDF export needs LaTeX; typst only covers plain pandoc
Lint / skill-floor (push) Successful in 12s
Lint / hadolint (push) Successful in 15s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 22s
The README said /studio-pdf and studio_export_pdf use typst (since v1.4.0)
and that "PDF export from Studio/pandoc works out of the box" (since
v1.9.1). Only the pandoc half is true: pi-studio uses $PANDOC_PDF_ENGINE,
falling back to xelatex, and the image has never set it, so Studio export
fails with "xelatex not found" (seen at v1.6.3 and v1.11.2).

New "PDF export from Studio needs LaTeX" section:
- do not set PANDOC_PDF_ENGINE=typst: pi-studio's LaTeX header is then
  printed as page-1 text while pandoc exits 0
- texlive-xetex alone is not enough (lmodern.sty, ulem.sty, pzdr font);
  texlive-xetex lmodern texlive-plain-generic texlive-fonts-recommended
  works, 37 packages / 491 MB, measured on v1.11.2 + pi-studio 0.10.1 by
  exporting and rendering the page

AGENTS.md and Dockerfile.base comments carry the same claim; not changed.
2026-10-15 00:38:25 +02:00
joakimp 10a8030620 changelog(unreleased): pi-extensions 0d8e417, AWS SSO keeper times in 24-hour format
Lint / hadolint (push) Successful in 9s
Lint / skill-floor (push) Successful in 11s
Lint / actionlint (push) Successful in 19s
Lint / doc-drift (push) Successful in 16s
The keeper's status line, /aws-sso status, "session ended at" notice and "last
login" line used toLocaleTimeString()/toLocaleString(); Dockerfile.base sets
LC_ALL=en_US.UTF-8, so the TUI read "AWS ✓ since 01:25 PM". 0d8e417 prints
HH:MM / YYYY-MM-DD HH:MM:SS whatever the locale. Devices overriding the locale
to sv_SE in .env already saw 24-hour times.

Measured in a v1.11.2 container: Node gives "01:25 PM" under en_US.UTF-8 and
C.UTF-8, "13:25" under sv_SE.UTF-8; upstream tests at 0d8e417 pass 18/18 under
en_US.UTF-8. Names the SHA check-doc-drift.sh check 9 wanted, so doc-drift is
green again (rc 0 locally; red on 1e4c238 and a8f5b35 for this alone).
2026-10-14 19:35:30 +02:00
joakimp a8f5b35d2a docs: correct the "pi-studio has no bind flag" claim everywhere it was baked
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 14s
Lint / doc-drift (push) Failing after 18s
Lint / actionlint (push) Successful in 31s
1e4c238 fixed README; the same claim lived in three more places, all wrong
since pi-studio 0.9.60 added /studio --listen-all:

- rootfs/usr/local/bin/studio-expose header (and so its --help; the sed range
  grows 2,31p -> 2,35p so --help still ends at "runs it backgrounded.")
- pi-devbox-environment skill section 7: --listen-all as a third path, plus the
  0.10 whole-URL / 409 / "link looks damaged" notes, the published-port rule,
  --stop before --port, and the on-host curl 403-vs-empty-reply diagnostic
- Dockerfile.variant STUDIO_PORT comment

Bridge and --listen-all on one port: whichever starts second fails, measured
both ways on tor-ms22 (v1.11.2): Studio "listen EADDRINUSE ... 0.0.0.0:8765"
with the bridge up; studio-expose rc 1 on socat "bind(...): Address already in
use" with a 0.0.0.0 listener up. README's warning now says both directions.

Cost: studio-expose and the skill are in rootfs/, which base_tag hashes, so the
next release rebuilds the base (recomputed with the CI recipe: base-6f3b20266de1
at 1e4c238 -> base-26d425cea6c2 now). Noted in the CHANGELOG Unreleased entry.
No pin changes. lint-shell, dockerfile-directives and base-hash guards pass;
check-doc-drift is red only for the unrelated pi-extensions 750b2a8 -> 0d8e417.
2026-10-14 19:27:42 +02:00
joakimp 1e4c238a83 docs(readme): pi-studio has --listen-all; the remote recipe describes the 0.10 URL
Lint / skill-floor (push) Successful in 9s
Lint / hadolint (push) Successful in 12s
Lint / doc-drift (push) Failing after 13s
Lint / actionlint (push) Successful in 22s
README "Using pi-studio" said pi-studio hard-binds 127.0.0.1 with no bind flag.
It has had /studio --listen-all (binds 0.0.0.0) since 0.9.60; listed now as path
C, with a warning not to combine it with the studio-expose bridge on one port:
with STUDIO_EXPOSE=1, `/studio --listen-all --port 8765` fails with
"listen EADDRINUSE: address already in use 0.0.0.0:8765" (measured, tor-ms22,
v1.11.2).

The remote recipe described a 0.9 URL. Since 0.10 the printed URL also carries
studioTabState + hostedWorkspace and a token-only URL is answered 409; the
recipe now says to open the whole URL, names the wrapped-URL copy hazard
("This link looks damaged..."), says Studio must run on the published port
(only the laptop's side of ssh -L is free) and that --port does not move a
running server, adds --watch <file>, and gives an on-host curl that tells
"chain up" (403) from "Studio down or on another port" (empty reply).

All of it measured on tor-ms22 2026-10-14 (pi-studio 0.10.1): tunnel through
the host sshd + headless browser load, both modes; --listen-all reached on the
container's eth0 (200 / 403 without token). Docs only; no pin changes.
2026-10-14 18:45:46 +02:00
joakimp bf265f6d54 release(v1.11.2): smoke esbuild check present-iff-declared; pi-studio 0.10.1
Lint / hadolint (push) Successful in 8s
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Successful in 17s
Lint / actionlint (push) Successful in 30s
Publish Docker Image / lint-gate (push) Successful in 26s
Publish Docker Image / resolve-versions (push) Successful in 16s
Publish Docker Image / base-decide (push) Successful in 15s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 5m25s
Publish Docker Image / smoke-studio (push) Successful in 8m40s
Publish Docker Image / build-variant (push) Successful in 16m48s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / build-variant-studio (push) Successful in 21m23s
v1.11.1 (run 724) was tagged and never published: the smoke assertion
"esbuild works at every install site" required esbuild to exist, and pi
1.2.0 dropped @earendil-works/chord, its only source. The image was right.

- smoke-test.sh: esbuild must be present iff an installed package root or
  /opt repo root declares it, and compile at every site. Body tested 8 ways
  (v1.11.0-studio ok; sites removed / natives pruned / residue / bare
  declarer fail; fixture decoy ignored; v1.11.2 candidates ok). Full smoke
  on local arm64 candidates with run 724's args: old script 102/1 (CI
  reproduced), new core 103/0, studio 106/0 (0.9.63 and 0.10.1).
- pi-studio v0.9.63 -> v0.10.1 (tagged 2026-10-14; CI takes the newest
  tag): devbox contract unchanged (manifest, client bundle, 11 tools, 6
  commands, --no-browser/--port/--listen-all, env vars); exercised under pi
  1.2.0 in headless Chromium (render, Run Prompt via ERDC, Files ->
  Document, port freed); in-image TUI starts with no extension issues.
- CHANGELOG v1.11.1 -> v1.11.2; Dockerfile.variant/workflow cycle notes.
  No base input changed: base-6f3b20266de1 (built by run 724) is reused.
2026-10-14 07:28:38 +02:00
joakimp 4efa84bb42 release(v1.11.1): pi 1.2.0; CI builds the pinned pi-obsmem SHA
Lint / hadolint (push) Successful in 9s
Lint / skill-floor (push) Successful in 10s
Lint / doc-drift (push) Successful in 13s
Lint / actionlint (push) Successful in 29s
Publish Docker Image / lint-gate (push) Successful in 25s
Publish Docker Image / resolve-versions (push) Successful in 20s
Publish Docker Image / base-decide (push) Successful in 14s
Publish Docker Image / build-base (push) Successful in 42m31s
Publish Docker Image / smoke (push) Failing after 5m30s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / smoke-studio (push) Failing after 8m38s
Publish Docker Image / build-variant-studio (push) Has been skipped
- pi 1.1.0 -> 1.2.0, audited on tor-ms22 from an isolated prefix: none of
  the four breaking changes reaches the image (3 before_agent_start
  handlers checked, no triggerTurn user, pi-studio themes load); agent-core
  dist byte-identical; turn cap N=19 honoured; atelier paints in both
  renderers; obsmem /om:* registered; real-config startup clean.
- resolve-versions reads ARG PI_OBSMEM_REF from Dockerfile.variant instead
  of resolving obsmem master (which overrode the pin as --build-arg),
  requires a 40-char SHA, and warns when master moves past the pin. Block
  tested in isolation: real pin, short/branch/missing pin, older pin,
  GitHub unreachable.
- CHANGELOG: Unreleased -> v1.11.1 — 2026-10-14. README pin table,
  BASE_REBUILD_DATE marker (base rebuilds anyway), stale VENDORED.md pointer.
2026-10-14 01:17:51 +02:00
joakimp fa38e3e102 CHANGELOG: pi -p skips the palace wake-up and mailbox poll (mempalace-toolkit d5a03f4)
Lint / skill-floor (push) Successful in 8s
Lint / hadolint (push) Successful in 10s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 28s
Measured on tor-ms22: 3 palace requests and 1.6 s, against 15 requests
(12 tools/call) with MEMPALACE_WAKEUP_PRINT=1. check-doc-drift.sh: DRIFT
before this entry, rc=0 after.
2026-10-13 15:32:40 +02:00
joakimp 1d53e008d4 CHANGELOG: pi -p skips the palace feed in print/JSON mode (mempalace-toolkit f590a3a)
Lint / skill-floor (push) Successful in 12s
Lint / hadolint (push) Successful in 16s
Lint / actionlint (push) Successful in 20s
Lint / doc-drift (push) Successful in 18s
Measured on tor-ms22: pi -p 2.9 s, --mode json 3.8 s (45 s with only the
timer fix 2f0edf5, 315 s before it). MEMPALACE_FEED_PRINT=1 restores the
feed. check-doc-drift.sh: DRIFT before this entry, rc=0 after.
2026-10-13 14:52:13 +02:00
joakimp 28e324d68f CHANGELOG: pi -p no longer idles 5 min after answering (mempalace-toolkit 2f0edf5)
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 13s
Lint / actionlint (push) Successful in 19s
Lint / doc-drift (push) Successful in 19s
The bridge's feed raced its mine against a setTimeout it never cleared, and
print mode waits for an empty event loop. Measured on tor-ms22: exit at 315 s
before the fix, 45 s after (the catch-up feed by design), 3.8 s with
MEMPALACE_FEED=0. check-doc-drift.sh: rc=0, and rc=1 with the SHA removed.
2026-10-13 13:56:50 +02:00
joakimp 3da7312166 MCP via pi's built-in support: mcp-loader retired upstream; skill floor, docs, sanity check
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 20s
Lint / doc-drift (push) Successful in 17s
pi-extensions 750b2a8 retired mcp-loader.ts in favour of builtin:mcp (an
extension registering /mcp replaced the built-in and printed a warning on
every start). This repo has no pin to move, since PI_EXTENSIONS_REF and
MEMPALACE_TOOLKIT_REF float on main, so this commit carries what the next
build changes:

- CHANGELOG Unreleased: component table (pi-extensions a88fa99 -> 750b2a8,
  mempalace-toolkit 975ab92 -> a30af61), the switch and its measurements, the
  stale-link pruning, the new sanity check, and the floor refresh.
  check-doc-drift.sh rc=0. A two-sided check: with 750b2a8 blanked out of the
  CHANGELOG it fails rc=1, naming pi-extensions a88fa99 -> 750b2a8.
- rootfs pi-extensions skill floor refreshed to 750b2a8. check-skill-floor.sh
  was rc=1 before and is rc=0 after. Base hash changes, so the next tag
  rebuilds the base.
- recreate-sanity-check.sh: "no dangling extension links" (WARN). The
  existing count includes dangling links because ls lists them. Tested
  two-sided in a temp HOME (PASS clean; WARN naming mcp-loader.ts and x.ts.off).
  On tor-ms22 v1.11.0: rc=0, 36 pass / 3 warn / 0 fail (was 35/3/0).
- DOCKER_HUB.md: pi-extensions list was three releases stale; it now lists the
  10 that ship, plus an MCP line. Dockerfile.variant comment updated.
- lint-shell.sh: 19 files clean at severity error.
2026-10-13 07:42:23 +02:00
joakimp 4ee94da523 release(v1.11.0): pi 1.1.0, pi-obsmem 022da5e, pi-atelier v0.15.0, all floating refs at newest
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 11s
Lint / actionlint (push) Successful in 19s
Lint / doc-drift (push) Successful in 22s
Publish Docker Image / lint-gate (push) Successful in 23s
Publish Docker Image / resolve-versions (push) Successful in 17s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 50m31s
Publish Docker Image / smoke (push) Successful in 5m26s
Publish Docker Image / smoke-studio (push) Successful in 11m35s
Publish Docker Image / build-variant (push) Successful in 17m18s
Publish Docker Image / update-description (push) Successful in 9s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Publish Docker Image / build-variant-studio (push) Successful in 20m28s
- ARG PI_VERSION 1.0.0 -> 1.1.0. Audited against the npm tarballs: the only
  breaking change in 1.0.1..1.1.0 is 1.0.3's azure-openai-responses -> azure
  rename, which nothing here uses. pi-agent-core's finishTurn handling is
  byte-identical between 1.0.0 and 1.1.0.
- ARG PI_OBSMEM_REF 731c3d4 -> 022da5e (= master): #81 compaction keeps
  unobserved context, #93 prebuilt dist/index.ts. src/agents/ unchanged.
- ARG PI_ATELIER_REF/VERSION v0.13.0 -> v0.15.0. Sidebar Auto/Manual modes,
  Manual by default. README's sidebar section updated.
- SKILLSET_SNAPSHOT_REF e9e45f7 -> 96b840e via vendor-mempalace-skill.sh.
  The vendored bytes are unchanged.
- Dockerfile.base BASE_REBUILD_DATE marker refreshed, at no cost, because the
  base rebuilds anyway (rootfs + entrypoint-user.sh changed in 11e512e).
- CHANGELOG: Unreleased -> v1.11.0 with the audit. The CI bypass of the obsmem
  pin is recorded under Still open.

Exercised before tagging, with pi 1.1.0 from a temp prefix in a real pty
against -ne controls:
- obsmem turn cap: 11 turns uncapped, exactly 3 capped.
- atelier v0.15.0 paints in both renderers (controls 0/3, render-proofed).
- obsmem 022da5e registers /om:status and /om:view.
- All 11 pi-extensions a88fa99 extensions load.

Local gates: check-doc-drift (rc 0, check 9 names every moved component),
lint-shell, check-workflow-shell, test-link-pi-models (13/13),
check-dockerfile-directives, check-skill-floor, check-base-hash.
2026-10-12 16:54:55 +02:00
Joakim Persson 3f46f5fd29 changelog(unreleased): pi-task fallback covers the task children the AWS SSO keeper leaves alone
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 21s
Lint / doc-drift (push) Successful in 20s
The keeper (pi-extensions aws-sso.ts, entry by pi@mbp-m1-2020 in eb70928) acts
only in interactive sessions: ctx.hasUI in the code, and the troubleshooting row
in docs/aws-sso.md. A pi-task child that hits an ended SSO session is the case
the new fallback handles, so the two entries now point at each other. The keeper
entry itself is unchanged.
2026-10-12 16:34:41 +02:00
Joakim Persson 09ce100c92 changelog(unreleased): secret-guard (pi-extensions a88fa99) and pi-task model fallback (pi-toolkit 3760d90)
Lint / hadolint (push) Successful in 12s
Lint / skill-floor (push) Successful in 10s
Lint / actionlint (push) Successful in 22s
Lint / doc-drift (push) Successful in 16s
Both arrive with the next build without a pin change, because CI resolves
pi-extensions and pi-toolkit main at build time. The components table now names
them: pi-extensions 143a214 -> a88fa99, and a new pi-toolkit row 9c87ee8 ->
3760d90. Both ids were checked against ls-remote, and the baked values against
/etc/pi-devbox/build-manifest.json on a v1.10.2 device.
2026-10-12 16:28:28 +02:00
Joakim Persson 11e512e497 feat(entrypoint): link ~/.pi/agent/models.json to a mounted shared file
pi reads custom providers from ~/.pi/agent/models.json, and ~/.pi is the
devbox-pi-config named volume. A file baked into the image would only seed a
fresh volume, once. A hand-made link is one more step on every new volume: on
2026-10-12 that was the same `ln -s /opt/fleet-pi/models.json …` once per
device, for one fleet-shared file.

New rootfs/usr/local/lib/pi-devbox/link-pi-models.sh, called from
entrypoint-user.sh after the pi-toolkit and pi-extensions installs. When
/opt/fleet-pi/models.json exists (PI_MODELS_JSON overrides the path, =off
disables it), it links ~/.pi/agent/models.json to it, honouring
PI_CODING_AGENT_DIR. It only ever creates a link where nothing exists: a real
file or a link of the user's own is left alone, with one NOTE line. With
nothing mounted, it does nothing and prints nothing. A link whose target later
vanishes is harmless; measured with pi 1.0.0, a dangling models.json behaves
like a missing one (rc 0, no error, no custom models).

Tests:
- scripts/test-link-pi-models.sh: 13 two-sided cases against a throwaway HOME.
  Wired into lint.yml, so it runs on every push and not only at release.
- Of 11 mutations, 10 are caught. The 11th (ln -s -> ln -sfn on the final
  link) is unreachable behind the guards.
- smoke-test.sh: with nothing mounted no link appears, and the script links a
  mounted file. Both commands were run under dash here.
- recreate-sanity-check.sh: pass, warn or fail on the link, but only when a
  shared file is mounted. Here it passes.

Docs:
- README "Custom model providers (models.json)": mount the DIRECTORY (git pull
  renames, and a single-file mount then shows "No such file" until recreate),
  keep keys in .env as "$NAME".
- .env.example: PI_MODELS_JSON.

Local gates green: lint-shell, check-workflow-shell, check-dockerfile-directives,
check-base-hash, check-doc-drift.
2026-10-12 16:28:28 +02:00
pi@mbp-m1-2020 eb70928b16 changelog(unreleased): AWS SSO keeper (pi-extensions f885199); pi-studio v0.9.63 named
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 12s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 19s
pi-extensions 143a214 -> f885199 adds aws-sso.ts: renews the Bedrock SSO
session just after IdC ends it (end read from AWS's token cap), local mode
via the host browser, remote mode via device code + ntfy push. Measured on
mbp-m1-2020 2026-10-11/12. pi-studio moved upstream 641aa32 -> fae3ad3
(v0.9.63) and CI's newest-tag resolution will bake it; named so
check-doc-drift passes, not yet reviewed.
2026-10-12 09:20:48 +02:00
pi@mbp-m1-2020 32448c7353 docs(.env.example): document the PI_AWS_SSO_* variables of the pi-extensions AWS SSO keeper
All optional and commented out. Guide: pi-extensions docs/aws-sso.md.
2026-10-11 23:00:17 +02:00
Joakim Persson 56e3742a2d fix(ci): skip the BuildKit check whose warning embedded this Dockerfile in CI output
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Failing after 13s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 33s
Publish Docker Image / lint-gate (push) Successful in 32s
Publish Docker Image / resolve-versions (push) Successful in 17s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m38s
Publish Docker Image / smoke (push) Successful in 8m20s
Publish Docker Image / build-variant-studio (push) Successful in 20m34s
Publish Docker Image / build-variant (push) Successful in 21m5s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
v1.10.1 (run 707) failed with ZERO failing steps: every step Success, `smoke`
printing "101 passed, 0 failed", `smoke-studio` "104 passed, 0 failed", both
jobs red. The clipboard fix from f3b3748 worked exactly as predicted; this is a
second, independent defect that run 704 had masked.

CHAIN, measured end to end rather than reasoned:

`ARG BASE_IMAGE` has no default on purpose (the two-phase build always supplies
it), which trips BuildKit's InvalidDefaultArgInFrom check. buildx attaches that
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. Gitea's act_runner truncates any single
line at exactly 65536 chars, so the closing delimiter was cut off:

  invalid format delimiter 'ghadelimiter_...' not found before end of file

and the runner failed the job while attributing it to no step at all.

  v1.9.4  run 695 SUCCESS: longest metadata line 56355, 0 delimiter errors
  v1.10.0 run 704 failed : longest metadata line 65536, 1 delimiter error
  v1.10.1 run 707 failed : longest metadata line 65536, 1 delimiter error

65536 = 2^16: cut AT the cap, not merely long. Independent route via file size
rather than log parsing: 42251 B at v1.9.4 (base64 56335, matching the log) vs
50034 B now (base64 66712, truncated). The cap corresponds to a 49152 B
Dockerfile, so this cycle's comment growth crossed it by 882 B.

Located by asking which top-level metadata key CONTAINS the base64, instead of
assuming: it is buildx.build.warnings -> sourceInfo -> data in BOTH runs.

THIS IS THE SECOND FIX FOR THIS BUG. The first, `provenance: false` on the two
smoke build steps, was committed as 784fad7 and is REVERTED here: a real buildx
on another host showed provenance metadata present in both modes with a longest
string of 71 chars, i.e. the base64 was never in provenance. Shipping it would
have left the release broken a third time while looking like a fix.

Also rejected, each measured: floating action tags moving (act action-bundle
hashes byte-identical between runs 695 and 707, all four) and the build-check
annotation text changing (byte-identical).

FIX: `# check=skip=InvalidDefaultArgInFrom` as the FIRST line of
Dockerfile.variant — BuildKit parses `# check=` only before any other line, so
placement is load-bearing. No honest default exists for BASE_IMAGE: `scratch`
would satisfy the linter while being a lie, and would convert today's instant
"invalid reference format" into a failure deep in the build. Verified against
the actual edited file with `docker buildx build --check`: "Check complete, no
warnings found." Zero warnings means no sourceInfo, so the longest metadata line
drops 65536 -> ~1936 and the file's SIZE stops gating CI.

GUARD: scripts/check-dockerfile-directives.sh, wired into BOTH lint.yml and
docker-publish.yml's lint-gate, because a check that gates only `push` lets a
tag regress — the adoption slip that let doc-drift land 27 h after the v1.9.3
tag. It also fails if ARG BASE_IMAGE gains a default, so the directive cannot
rot into guarding a check that can no longer fire. Truth table, exit codes:
present 0, removed 1, demoted to line 2 → 1, ARG defaulted 1, file missing 2
(a gate that cannot run must not pass).

Release renamed v1.10.1 -> v1.10.2 with both failed tags left standing as
tombstones. Docs swept again (README "since" marker, Dockerfile decision
comments) because CI reads them from the TAG.

Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor,
lint-shell (17 files now), dockerfile-directives all rc=0.
2026-10-02 10:58:03 +02:00
Joakim Persson 784fad78f3 fix(ci): provenance: false on the smoke builds — base64(Dockerfile) crossed 64 KiB
Lint / hadolint (push) Successful in 9s
Lint / skill-floor (push) Successful in 12s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 32s
v1.10.1 (run 707) failed with ZERO failing steps: every step reported Success,
`smoke` printed "Results: 101 passed, 0 failed", `smoke-studio` printed "104
passed, 0 failed", and both jobs went red anyway. The clipboard fix worked
exactly as predicted; this is a second, independent defect that run 704 hid.

MECHANISM, measured end to end:

buildx writes a provenance attestation into the metadata it returns, and that
metadata embeds the ENTIRE Dockerfile as a single base64 "data" field on ONE
line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as a
`name<<ghadelimiter_<uuid>` heredoc. Gitea's act_runner truncates any single
line at exactly 65536 chars, so once base64(Dockerfile.variant) crosses 64 KiB
the closing delimiter is cut off and the runner reports

  invalid format delimiter 'ghadelimiter_...' not found before end of file

then fails the job while attributing it to no step at all.

  v1.9.4  run 695 (SUCCESS): longest metadata line 56355 chars, 0 delimiter errors
  v1.10.0 run 704 (failed) : longest metadata line 65536 chars, 1 delimiter error
  v1.10.1 run 707 (failed) : longest metadata line 65536 chars, 1 delimiter error

65536 is exactly 2^16 — the line was TRUNCATED at the cap, not merely long.
Independent route via file size, not log parsing: Dockerfile.variant was 42251 B
at v1.9.4 (base64 56335, matching the log) and is 50034 B at HEAD (base64 66712,
cut to 65536). The cap corresponds to a 49152 B Dockerfile, so this cycle's
comment growth (+7783 B, mostly comments I wrote) crossed it by 882 B.

Hypotheses measured and REJECTED before landing this:
  - floating action tags moved: the act action-bundle hashes are IDENTICAL
    between run 695 and run 707 (all four), so no action changed version.
  - build-check annotation changed: the ::warning text is byte-identical.
  - stale clipboard assertion again: no, the suites print 0 failed.

WHY provenance: false and not shorter comments — trimming would restore the
margin and silently re-arm the trap for the next comment, with a failure mode of
"job red, no failing step, suite green", which cost most of this session to
diagnose once. Dropping the attestation removes the Dockerfile-size coupling
entirely.

BLAST RADIUS IS NIL for what ships: these two steps build with `load: true` and
the images are discarded after the suite runs, so the attestation has no
consumer. The PUBLISHED images are built by raw `docker buildx build --push` in
run: blocks (three call sites), which never write $GITHUB_OUTPUT metadata and
are therefore both unaffected by the bug and unchanged by this fix.

Verified: YAML parses; check-workflow-shell rc=0; doc-drift 23 OK / 0 DRIFT.
Next step is a `smoke_only=true` dispatch against main — the escape hatch this
workflow already documents — so the fix is proven before another tag is cut.
2026-10-02 10:50:16 +02:00
Joakim Persson c2c42c34a2 docs(v1.10.1): cut v1.10.1; v1.10.0 stays tagged-but-never-published
Lint / skill-floor (push) Successful in 11s
Lint / doc-drift (push) Successful in 12s
Lint / hadolint (push) Successful in 14s
Lint / actionlint (push) Successful in 19s
Publish Docker Image / resolve-versions (push) Successful in 12s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / lint-gate (push) Successful in 25s
Publish Docker Image / smoke-studio (push) Failing after 5m43s
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / smoke (push) Failing after 8m21s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
v1.10.0's build failed one assertion of 104 and published nothing. Rather than
re-point a tag CI had already consumed, the number moves: a version that failed
its build should stay failed and readable, not be quietly overwritten with
different bytes.

CHANGELOG gains a v1.10.1 section carrying the smoke fix (moved out of
v1.10.0's Fixed list), and states plainly that everything under v1.10.0 ships
here so the big section stays the content record. v1.10.0's heading is marked
"tagged, never published — superseded by v1.10.1" with a blockquote naming the
commit (12f99c4), the run (704), the four jobs that skipped, and the fact that
no image carries the tag.

Pre-tag doc sweep, because CI reads docs from the TAG and POSTs DOCKER_HUB.md
to the Hub as full_description:

  - README "Terminal UI mode: fullscreen is the default (since v1.10.0)"
    -> v1.10.1. A "since" marker pointing at an image nobody can pull is a
    worse lie than no marker.
  - Dockerfile.variant decision comments for the pi and pi-atelier bumps
    -> v1.10.1 (these describe which release carries the change).
  - scripts/smoke-test.sh deliberately KEEPS "the v1.10.0 tag build" — that
    one is a historical event and v1.10.0 is its correct name.
  - DOCKER_HUB.md needed no change: its fullscreen note is version-free.

No code changes. RELEASE_TAG is derived from github.ref_name, so nothing in the
build hardcodes the version.

Base layer does NOT rebuild: no base input (Dockerfile.base, rootfs/**,
entrypoint.sh, entrypoint-user.sh) has changed since 12f99c4, so base-decide
will cache-hit base-dad0f365ff24, which run 704's build-base already pushed at
07:52:38Z. This retry skips the expensive half.

Gates: doc-drift 23 OK / 0 DRIFT / 0 SKIP; base-hash, workflow-shell,
skill-floor, lint-shell all rc=0.
2026-10-02 10:36:21 +02:00
Joakim Persson f3b3748223 fix(smoke): derive the clipboard assertion from what pi declares
Lint / skill-floor (push) Successful in 7s
Lint / actionlint (push) Successful in 17s
Lint / hadolint (push) Successful in 14s
Lint / doc-drift (push) Successful in 14s
v1.10.0's first tag build (run 704) failed on ONE assertion out of 104, and
published nothing. The assertion was wrong, not the image.

scripts/smoke-test.sh required @mariozechner/clipboard to be installed and
require()-able at every install site, failing hard when the family was absent.
pi 0.86.0 (#9163) "Replaced the external native clipboard dependency with
bundled asynchronous macOS, Windows, and X11 helpers while preserving platform
command and OSC 52 fallbacks". Measured in the published tarballs rather than
inferred from the changelog:

  pi 0.85.1 -> dependencies include @mariozechner/clipboard@0.3.9
  pi 0.87.1 -> no @mariozechner/* at all
  pi 1.0.0  -> no @mariozechner/* at all

So on a correct v1.10.0 image the package is legitimately gone, `if [ -z
"$sites" ]; then exit 1` fired, and both smoke jobs went red on the same line:
smoke 100 passed/1 failed, smoke-studio 103 passed/1 failed with every
studio-specific assertion green. build-variant, build-variant-studio,
promote-base-latest and update-description all skipped -> nothing published.
The gate worked; it was enforcing pi 0.85.1's dependency graph.

The guard is NOT deleted, because its "fail when absent" shape is the thing
that stops prune_foreign_natives() from deleting the binding instead of the
surplus platform copies. It now derives the expectation from what the image's
pi actually DECLARES:

  declares + install loads     -> pass
  declares + no install        -> FAIL (prune removed function)
  does not declare + no install-> pass (pi bundles it since 0.86.0)
  does not declare + install   -> FAIL (orphan nothing depends on)

That re-arms automatically if a future pi re-adds the dependency, which a
skip-if-absent would not.

Verified as that four-quadrant truth table on EXIT CODES, not messages, since
run() keys on status: rc=0/1/0/1 as listed. The final check extracted the
command string verbatim from the committed file and ran it through `sh -c` the
way run() does, so the escaping was tested as shipped rather than as drafted.

Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor,
lint-shell all rc=0. smoke-test.sh is not a base-hash input, so the base layer
does not rebuild.
2026-10-02 10:27:25 +02:00
21 changed files with 1827 additions and 93 deletions
+24
View File
@@ -169,6 +169,30 @@ GIT_USER_EMAIL=
# AWS_ACCESS_KEY_ID=
# 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) ─────────────────────────
# If you have a skillset repo, the entrypoint auto-deploys skills and
# instructions on container start using relative symlinks.
+42 -9
View File
@@ -9,9 +9,13 @@ name: Publish Docker Image
# Pipeline shape:
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
# + entrypoints; probe Docker Hub for existing tag.
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
# to commit SHAs (defeats registry-buildcache
# cache-hit footgun on byte-identical build args).
# 2. resolve-versions pi and mempalace from their pins (checked on npm/
# PyPI); pi-obsmem and pi-atelier from their pins in
# 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>.
# 4. smoke amd64-only build of the variant FROMing the base
# tag; runs scripts/smoke-test.sh.
@@ -186,7 +190,7 @@ jobs:
# 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_OBSMEM_REF=master and friends, which resolve-versions below turns
# 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 ->
@@ -222,6 +226,13 @@ jobs:
- 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:
# Gated: a defective tree must not reach a 46-minute base build.
needs: [lint-gate]
@@ -379,15 +390,37 @@ jobs:
fi
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" \
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
require_sha PI_FORK_REF "$FORK_REF"
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
# 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"
# 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
# (which track a branch head) atelier wraps pi's private TUI
@@ -476,7 +509,7 @@ jobs:
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 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_STUDIO_REF=${STUDIO_REF} (pi-studio ${STUDIO_TAG})"
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
+18
View File
@@ -90,6 +90,24 @@ jobs:
# actionlint so the more precise diagnostic surfaces first.
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: link-pi-extensions.sh contract (two-sided, no docker needed)
# Same reasoning as the models step above: the entrypoint links top-level
# *.ts from the mounted fleet directory into ~/.pi/agent/extensions, and
# must never replace a user's own extension of the same name.
run: bash scripts/test-link-pi-extensions.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)
env:
ACTIONLINT_VERSION: 1.7.12
+18 -8
View File
@@ -354,14 +354,24 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
## What we DON'T install (and why)
- **No texlive** (~600 MB–1 GB). PDF export from pandoc / pi-studio works
out of the box via **`typst`** (~30 MB static binary), which the base ships
as the pandoc PDF engine (`pandoc --pdf-engine=typst`) — small enough to live
in base rather than a dedicated `:latest-studio-tex` variant. We don't bake in
a full TeX Live: it's heavy and typst covers the common Markdown→PDF case.
Users needing LaTeX-exact output can install the higher-fidelity fallback on
demand: `sudo apt-get install texlive-xetex texlive-latex-recommended` (then
`pandoc --pdf-engine=xelatex`).
- **No texlive** (~0.5–1 GB). The base ships **`typst`** (~30 MB static
binary) as a PDF engine for pandoc, so
`pandoc doc.md --pdf-engine=typst -o doc.pdf` works out of the box. That
covers the common Markdown→PDF case, small enough to live in base rather
than in a dedicated `:latest-studio-tex` variant. Callers must ask for
typst: pandoc's own default engine is `pdflatex`, which is not installed.
**pi-studio's PDF export (`/studio-pdf`, `studio_export_pdf`) does not use
typst.** It runs pandoc with `$PANDOC_PDF_ENGINE`, defaulting to `xelatex`,
so in this image it fails with `xelatex not found`. Do not "fix" that with
`ENV PANDOC_PDF_ENGINE=typst`. pi-studio adds its own LaTeX header to the
document, and typst prints that header as page text while pandoc exits 0.
That ENV only becomes a fix once pi-studio stops sending its LaTeX header
to non-LaTeX engines. Until then, users who need Studio's PDF export, or
LaTeX output, install TeX Live on demand with the recipe in README
"PDF export from Studio needs LaTeX" (37 packages, ~490 MB with
`--no-install-recommends`). A plain `sudo apt-get install texlive-xetex`
also works, but its recommends make it 202 packages and ~1 GB, including
systemd and a Java runtime.
- **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
vendored to `/opt/pi-studio` and registered at container start via
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
+743 -1
View File
@@ -11,7 +11,749 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## v1.10.0 — 2026-10-02
## v1.12.0 — 2026-10-18
Minor release. Headlines: **pi 1.3.0**, **mempalace 3.11.0**, **pi-atelier
v0.16.0**, and **fleet pi extensions linked in at every container start**
(*Added*, below). Every component is at its newest upstream release; each
pinned one was audited for breaking changes against this image before tagging
(*Changed*, below), and none was found. Unchanged upstream, re-checked
2026-10-18: pi-observational-memory `022da5e` (= `master`), pi-studio `v0.10.1`
(`76db01f`, newest tag), pi-fork `e69725c`, pi-toolkit `3760d90`,
mempalace-toolkit `d5a03f4`. The skillset is still at `1e02b44` and its
mempalace `SKILL.md` is byte-identical to the recorded `96b840e`, so the
snapshot is not refreshed (`vendor-mempalace-skill.sh --check`: OK). The base
image is rebuilt (rootfs, `Dockerfile.base` and the mempalace pin all changed),
so the base tools pinned to `latest` re-resolve too: uv `0.13.0` → `0.13.1`,
gitea-mcp `v1.8.1` → `v1.9.0` (additive: mTLS, extra headers,
`create_organization`), Node `v24.21.0` → `v24.22.0`; nvim, fzf, gitleaks,
bat, eza, zoxide, git-lfs, gosu, tealdeer, typst, yq, agent-browser and
playwright unchanged.
### Components that move with this release
| Component | Baked in v1.11.2 | v1.12.0 | Why |
|---|---|---|---|
| **pi** | `1.2.0` | **`1.3.0`** | audited, see *Changed* below |
| **mempalace** | `3.10.0` | **`3.11.0`** | audited, see *Changed* below |
| **pi-atelier** | `v0.15.0` (`3befe02`) | **`v0.16.0`** (`3b42825`) | audited, see *Changed* below |
| pi-extensions | `750b2a8` | **`4c1d28d`** (or later: CI resolves `main` at build time) | the `confirm-destructive` gate stops prompting on harmless redirects and catches `tee`/`dd of=` (below); picks up the AWS SSO keeper's 24-hour times too (below) |
### Upgrade notes
- **Local palaces re-mine their conversations once.** mempalace 3.11.0's
exchange chunker no longer discards text, so the first conversation mine
after upgrading re-chunks and re-embeds each conversation file it covers
(drawers of transcripts no longer on disk are kept). This only applies to a
devbox with its own palace: with `MEMPALACE_REMOTE_URL` set, the hub does the
mining, and nothing changes until the hub itself is upgraded.
- **The central palace (synlig) is still on 3.10.0.** That is the safe
direction (newer client), and the bridge's tools come from the hub, so
shared-palace devices see no change. When synlig is upgraded, its first
conversation mine re-mines every inbox transcript still on disk (upstream
estimates 40 minutes to 3 hours per million drawers): back it up first and
run it at a quiet time.
### Added
- **Fleet pi extensions are linked in at every container start**
(`rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh`, called from
`entrypoint-user.sh` next to `link-pi-models.sh`). pi auto-loads every `.ts` in
`~/.pi/agent/extensions/`, which lives in the `devbox-pi-config` volume: a baked
extension would only seed a fresh volume once, and a hand-copied one is a
per-device step that drifts — the two problems `link-pi-models.sh` was added
for on 2026-10-12, one level down. The script links each **top-level** `*.ts`
from the mounted fleet directory (default `/opt/fleet-pi`, the same directory
that already carries `models.json`; `PI_EXTENSIONS_SRC` overrides, `=off`
disables) and is never destructive: a real file of that name in
`~/.pi/agent/extensions/` is left alone, so a user's own copy of an extension
always wins over the fleet one. A symlink rather than a single-file bind mount
because `git pull` replaces a file by rename (measured on Docker Desktop,
2026-10-12), which is the documented reason `models.json` is shared this way.
Nested `*.ts` are deliberately NOT linked — the directory is fleet pi config,
not an extension tree. `scripts/test-link-pi-extensions.sh` covers the contract
with 16 two-sided cases and runs in `lint.yml`; `smoke-test.sh` and
`recreate-sanity-check.sh` gained the matching in-image and on-device checks.
Motivated by the ERDC System One classifier extension: four hosts mount
`common/pi` from `docker-compose-repo`, and three of them are not reachable
from a devbox to create a symlink by hand.
### Changed
- **pi `1.2.0` → `1.3.0`.** Audited 2026-10-18 on tor-ms22 from an isolated npm
prefix, against the 1.2.0 tarballs unpacked side by side:
- 1.3.0 has **no** breaking-changes section. Its behaviour changes don't
reach this image: `--model` and `enabledModels` now resolve a bare model
id across providers by which one has credentials (every model here is
written `amazon-bedrock/…`, and pi-fork's profiles name a provider);
resumed sessions keep their active tools; a model switch drops the old
model's thinking; `kimi-coding` base-URL overrides need `/v1` (not used).
- pi-agent-core and pi-tui are byte-identical between 1.2.0 and 1.3.0
(24 and 184 files, same tree hashes), and so is pi's
`dist/core/extensions`. The obsmem turn cap lives in the first and
atelier's painting in the second.
- The only export removed from any type declaration is `parseModelPattern`
(never exported from the package index); pi-ai dropped its
`assistant-message-frame` re-export. Neither has a consumer. All 54 names
that pi-toolkit, pi-extensions, pi-fork, obsmem, pi-studio, pi-atelier,
mempalace-toolkit and the fleet extensions in `/opt/fleet-pi` import at
runtime resolve in 1.3.0; an injected fake name is reported missing.
- With a copy of tor-ms22's real `~/.pi/agent`: `pi -p` exits 0 in 10 s
(1.2.0: 9 s) with nothing on stderr. The TUI loads every extension with no
extension issues; the mempalace bridge registers its 45 tools;
`/om:status`, `/om:view` and the `/studio` commands are offered; and the
atelier sidebar paints ACTIVITY/READY/TOOLS in fullscreen and regular mode
(with `-ne`: no sidebar). pi shows its 1.3.0 "What's new" once and records
`lastChangelogVersion`; nothing else in `settings.json` changes.
- **mempalace `3.10.0` → `3.11.0`.** Core requirements unchanged. Measured with
both wheels in throwaway venvs and a throwaway palace:
- `mempalace init --yes` (the entrypoint's first-run call) still writes
`config.json`, rc 0.
- A palace built by 3.10.0 opens under 3.11.0, searches, and serves MCP with
no embedder warning. Its one conversation file was re-mined once (see
*Upgrade notes*) and skipped on the next mine.
- MCP over stdio lists 47 tools instead of 45. None is removed or renamed;
`mempalace_get_drawers` and `mempalace_delete_drawers` are new, and the 14
read-only tools now carry `readOnlyHint`.
- The removed `EntityRegistry.research()` / `confirm_research()` have no
caller in pi-devbox, mempalace-toolkit, skillset or myconfigs.
- **pi-atelier `v0.15.0` → `v0.16.0`.** Additive: a contributed sidebar panel
can send label/value rows with an optional progress bar (`keyValues`; plain
`rows` remain the fallback), two new roles (`success`, `caution`), and the
context meter no longer draws a hard-coded dark background, so it works on
light terminals. The panel protocol is unchanged (channel
`pi-atelier:sidebar-panels`, version 1, 24 rows of 160 chars), as is the pi
floor (`>=0.84.0`). On pi 1.3.0 it paints in both renderers, and the fleet's
`fleet:todos` panel (`fleet-todos-panel.ts`, plain rows) showed a todo
written in a real turn, as on v0.15.0. The v0.15.0 run still drew the old
meter background and the v0.16.0 run did not, so each ran the version it
claims.
- **DOCKER_HUB.md no longer says "29 `mempalace_*` tools".** The palace server
decides the set; it now says so, with the measured counts.
### Fixed
- **`confirm-destructive` asked for approval on nearly every `2>/dev/null`**
(pi-extensions `4c1d28d`, two commits on top of the `0d8e417` named below).
Its "write to device" pattern was a bare `>\s*/dev/`, so redirecting a
standard stream into the null device looked like a write to a block device —
the extension's own comment cites 20 of 70 bash calls in one 2026-10-16
session, and a gate that prompts that often trains the user to approve without
reading. `/dev/null`, `stdout`, `stderr`, `tty`, `/dev/fd/*` and `/dev/shm` are
now exempt, matched as whole names so `/dev/nullfoo` still prompts and a
`/dev/shm` path containing `..` is not exempt. The same gate got harder to
slip past: `tee` and `dd of=` (which writes from stdin and has no `if=`) are
now write forms, and quoted device paths are recognised. Its suite covers the
new cases — `node --test test/confirm-destructive.test.mjs` in
pi-extensions `4c1d28d`: 2 tests, 2 pass, 0 fail (measured 2026-10-16).
- **The AWS SSO keeper showed 12-hour times under the image's default
locale** (pi-extensions `0d8e417`). The status line (`AWS ✓ since …` /
`until …`), `/aws-sso status`, the "session ended at" login notice and the
"last login" line used `toLocaleTimeString()` / `toLocaleString()`. Those
follow the locale, and `Dockerfile.base` sets `LC_ALL=en_US.UTF-8`, so the
TUI read `AWS ✓ since 01:25 PM`. Times are now always `HH:MM`, and "last
login" is `YYYY-MM-DD HH:MM:SS`, the forms `docs/aws-sso.md` already showed.
A device that overrides the locale in `.env` (the `sv_SE.UTF-8` example in
`.env.example`) already saw 24-hour times and sees no change. Measured in a
v1.11.2 container: Node formats 13:25 as `"01:25 PM"` under `en_US.UTF-8`
and `C.UTF-8` and as `"13:25"` under `sv_SE.UTF-8`. The upstream test suite
at `0d8e417` passes 18/18 under `en_US.UTF-8`, including the new
"hhmm/fmtStamp are 24-hour, locale-independent" case.
- **README "Using pi-studio" said pi-studio has no bind flag.** It has had
`/studio --listen-all` (binds `0.0.0.0`) since pi-studio 0.9.60, so the
section now lists it as path **C**, next to host networking (A) and the
`studio-expose` bridge (B). It also warns not to combine B and C on one
port, because whichever starts second fails. With `STUDIO_EXPOSE=1`,
`/studio --listen-all --port 8765` fails with
`listen EADDRINUSE: address already in use 0.0.0.0:8765`. In the other
order, `studio-expose` exits 1 on socat's `bind(…): Address already in
use` (both measured on tor-ms22, v1.11.2). The same stale "no bind
flag" claim is corrected in the three other places that carried it:
the `studio-expose` header (and so its `--help`, whose `sed` range
grows from `2,31p` to `2,35p`), §7 of the baked `pi-devbox-environment`
skill (which also gains the whole-URL, published-port and `curl` notes
below), and the `STUDIO_PORT` comment in `Dockerfile.variant`.
- **The remote recipe described a pi-studio 0.9 URL.** Since 0.10 the printed
URL carries `studioTabState` and `hostedWorkspace` as well as the token, and
a token-only URL is answered `409`. The recipe now says to open the whole
URL, and names the copy hazard: the URL wraps, a side panel's text can land
inside it, and Studio answers *"This link looks damaged…"*. It also says
Studio must run on the **published** port (only the laptop's side of
`ssh -L` is free to choose) and that `--port` does not move a running server
(`/studio --stop` first). Two more additions: `--watch <file>` for a rendered,
read-only preview, and an on-host `curl` that tells "chain up" (`403`) from
"Studio down or on another port" (empty reply). A remote session hit exactly
that: Studio was started on `18765` while only `8765` is published.
- **README said PDF export from Studio works out of the box. It does not.**
Since v1.4.0 (`67da05b`) the README's Studio section has said that
`/studio-pdf` and `studio_export_pdf` use `typst`. Since v1.9.1
(`6353d59`) the image-tags section has also said that "PDF export from
Studio/pandoc works out of the box". That holds for pandoc run directly
(`pandoc doc.md --pdf-engine=typst -o doc.pdf`), but not for Studio.
pi-studio runs pandoc with `$PANDOC_PDF_ENGINE`, falling back to `xelatex`,
and the image has never set that variable (`git log -S PANDOC_PDF_ENGINE`
finds no commit). So `/studio-pdf` and `studio_export_pdf` fail with
`xelatex not found`. That was seen at v1.6.3 and again at v1.11.2. The
README now says so in both places that made the claim, and adds a
"PDF export from Studio needs LaTeX" section with two warnings and a
working recipe:
- **Setting `PANDOC_PDF_ENGINE=typst` makes it worse.** The error message
suggests it, but pi-studio adds its own LaTeX header to the document
(unless the document sets `header-includes`). Under typst, pandoc exits
0 and prints that header as text above the first heading. Seen in the same PDF through two renderers, MuPDF and
Chromium.
- **`texlive-xetex`, the README's old fallback, works but is twice the
size it needs to be.** Installed with apt's defaults it pulls in its
recommends: 202 packages and 1053 MB, including systemd, D-Bus and a
Java runtime (`openjdk-21-jre-headless`, 194 MB). The same 202 come from
the old `AGENTS.md` recipe, `texlive-xetex texlive-latex-recommended`.
With `--no-install-recommends`, `texlive-xetex` alone is not enough:
the export then lacks `lmodern.sty`, `ulem.sty` and the `pzdr` font.
The README's recipe adds the three packages that provide them,
`lmodern texlive-plain-generic texlive-fonts-recommended`, and comes to
37 packages and 491 MB, after `apt-get update`. Each recipe was
measured from the image's own 401 packages on v1.11.2 with pi-studio
0.10.1, and the exported page was looked at, not only its exit code.
The same claim is corrected in `AGENTS.md` ("What we DON'T install"),
which now also warns against `ENV PANDOC_PDF_ENGINE=typst`, and in two
comments in `Dockerfile.base`'s typst block. `base_tag` hashes all of
`Dockerfile.base`, comments included, so this changes the base hash too.
The base is rebuilt in the next release anyway (above).
## v1.11.2 — 2026-10-14
**v1.11.1 was tagged and never published.** Run 724 failed one smoke assertion
in each variant (`smoke` 102 passed / **1 failed**, `smoke-studio` 105 / **1**):
*esbuild works at every install site* reported "no esbuild install found at
all". The image was right and the assertion was wrong. esbuild was never a
direct pi dependency. It came through `@earendil-works/chord` (pi 1.1.0 → chord
→ esbuild 0.28.2, at the pi and pi-fork sites), and pi 1.2.0 dropped chord, so
a correct 1.2.0 image has no esbuild. pi 1.2.0's tree has no esbuild import;
1.1.0's chord bundle had one. This is the mistake the clipboard assertion made
at v1.10.0. Every publish job skipped, so no `v1.11.1`, `latest` or Hub
description was written. The one artefact pushed is the base `base-6f3b20266de1`.
It is content-addressed and this release reuses it, which is why
`Dockerfile.base`'s `BASE_REBUILD_DATE` comment still names v1.11.1: editing it
would change the hash and rebuild an identical base. **Everything below ships
here, with the assertion fixed** (*Fixed*, first entry). The tag moves rather
than being re-pointed, as at v1.10.2. One component moved between the two tags:
pi-studio published **v0.10.0 and v0.10.1** the same morning, CI builds the
newest pi-studio tag, and this release adopts 0.10.1 after testing it (*Changed*
below) rather than pinning 0.9.63.
Patch release. Headlines: **pi 1.2.0**, **pi-studio 0.10.1** (two editors, a
Prompt and a Document, by default), **`pi -p` returns in seconds** on a
devbox instead of idling for five minutes (mempalace-toolkit), **MCP servers use
pi's built-in support** (pi-extensions' `mcp-loader.ts` is retired), and **CI
now builds the pinned pi-observational-memory commit** instead of whatever its
`master` is that day. Unchanged upstream, re-checked 2026-10-14:
pi-observational-memory `022da5e`, pi-atelier `v0.15.0`, pi-fork `e69725c`, pi-toolkit `3760d90`, mempalace `3.10.0`, Node 24 (v24.21.0
LTS). The skillset moved (`96b840e` → `1e02b44`), but the vendored mempalace
`SKILL.md` is byte-identical, so the snapshot is not refreshed. The base image is
rebuilt (the rootfs skill floor and the mempalace-toolkit ref both changed), so
the base tools pinned to `latest` re-resolve too.
### Components that move with this release
| Component | Baked in v1.11.0 | v1.11.2 | Why |
|---|---|---|---|
| **pi** | `1.1.0` | **`1.2.0`** | audited, see *Changed* below |
| **pi-studio** (studio variant) | `v0.9.63` (`fae3ad3`) | **`v0.10.1`** (`76db01f`) | tagged 2026-10-14, tested under pi 1.2.0, see *Changed* below |
| pi-extensions | `a88fa99` | **`750b2a8`** (or later: CI resolves `main` at build time) | `mcp-loader.ts` retired; `install.sh` prunes stale links (below) |
| mempalace-toolkit | `975ab92` | **`d5a03f4`** (or later) | `pi -p` returns in seconds: `2f0edf5` clears the feed's leftover 5-min timer, `f590a3a` skips the feed in print/JSON mode, and `d5a03f4` skips the wake-up and mailbox poll there too (below). Also `a30af61`: the bridge's `RemoteMcpClient` is now the only copy, and `scripts/check-mcp-client-sync.sh` is removed (comment/README only) |
### Fixed
- **The smoke test's esbuild assertion is now "present iff something declares
it"**, like clipboard's. Because esbuild arrives transitively, "declared" is
not read from pi's `package.json`. It is derived from every installed package
root and every `/opt` repo root, counting dependencies, optional dependencies
and peer dependencies. Anchoring on package roots means a test fixture's
`package.json` cannot count. Both directions fail: declared but missing (the
prune removed function), and installed with nothing declaring it (residue).
The new body was run before tagging. On published v1.11.0-studio it reports
"declared (2 package(s)) and compiles at 2 site(s)". With every esbuild dir
removed it fails, naming both chord `package.json` files. With
`@esbuild/<platform>` pruned it fails with "esbuild broken at …". On the new
images it reports that nothing declares esbuild. It also fails a fake esbuild
dir that nothing declares, and a package root that declares esbuild with no
install, but ignores a test fixture's `package.json`. The whole
`smoke-test.sh` then ran against arm64 candidates, built locally with run
724's resolved build args on the same base. The old script reproduced CI's
102/1 on core. The new one passed core 103/0 and studio 106/0, with pi-studio
0.10.1 and with 0.9.63.
- **The pi-observational-memory pin now binds CI builds** (v1.11.0's known
issue). `resolve-versions` resolved the repo's `master` and passed it as
`--build-arg PI_OBSMEM_REF`, which overrides the Dockerfile's pinned default,
so CI baked the pin only while `master` happened to equal it (v1.9.5, v1.10.2,
v1.11.0). It now reads the pin from `Dockerfile.variant`, the way pi-atelier's
tag is read. It refuses anything but a full 40-char SHA, and prints a
`::warning::` with the compare link when `master` has moved past the pin. The
pin stays an untagged `master` commit on purpose. Upstream tags rarely (the
newest tag is still 3.1.4) and lands what pi needs on `master`: #83, the
migration to pi 0.87's `finishTurn`, never reached a tag. Its
`peerDependencies` are `*`, so a mismatch with pi fails silently at runtime.
Adopting a newer `master` is now a deliberate one-line bump, after the
turn-cap probe. The new step was run on its own against six inputs: the real
pin gives `022da5e` and no warning; a short SHA, `master` or a missing ARG
fails; an older pin gives that SHA plus the warning; GitHub unreachable gives
the pin and no warning. The workflow's pipeline comment, which still said pi
resolves from npm `latest`, is corrected too.
- **`pi -p` on a devbox no longer sits idle for five minutes after
answering.** It printed its answer, then held the terminal until a timer
ran out: the feed's 300 s mine deadline (mempalace-toolkit `mempalace.ts`).
The bridge raced its transcript mine against a `setTimeout` it never cleared.
Interactive pi calls `process.exit()` and never showed it, but print mode
(`pi -p`, `--mode json`) waits for an empty event loop. Measured on tor-ms22
(pi 1.1.0): before the fix, `PONG` came at 4 s and exit at 315 s, and that
timer was the only live resource for the last 265 s. After `2f0edf5`, exit
is at 45 s, the moment the session's catch-up feed finishes (prepare 11 s,
mine 31 s). `f590a3a` then makes print and JSON mode skip that feed by
default: `pi -p` takes 2.9 s and `--mode json` 3.8 s. Nothing is lost. A
one-shot run's own transcript rarely passes the exporter's quality gate, and
the next interactive session files the device's other sessions the same
way. The mine skips an unchanged transcript and purges a grown one before
refiling, so nothing is filed twice. `MEMPALACE_FEED_PRINT=1` restores the
feed for a device that only runs `pi -p`. pi 1.0.0 behaved the same, so it
predates v1.11.0. `pi-task` and `fork` children run `--no-extensions` and
were never affected. `scripts/test-feed-exit.sh` in mempalace-toolkit pins
both changes, and each change's pre-fix source fails it. On images before
v1.11.2, scripts that call `pi -p` should keep their `timeout` or set
`MEMPALACE_FEED=0`.
- **`pi -p` no longer gets the palace wake-up.** In print and JSON mode the
mempalace bridge now skips the wake-up injection and the mid-session mailbox
poll (mempalace-toolkit `d5a03f4`). The injection is status, diary, and the
owed/closed mailbox lists. The `mempalace_*` tools are still registered.
Measured on tor-ms22: `pi -p` makes 3 palace requests (the MCP handshake)
and exits in 1.6 s. With the wake-up it made 15 requests, 12 of them
`tools/call`. The wake-up is for the person at an interactive session, who
sees it in the next one. In a script it is cost, and once it was harm: a
probe's flash model read the injected diary's open items as its task, ran
about 12 tool calls, and filed a wrong drawer in the shared palace.
`MEMPALACE_WAKEUP_PRINT=1` restores the wake-up for `pi -p`.
### Changed
- **pi `1.1.0` → `1.2.0`.** Audited 2026-10-14 on tor-ms22 from an isolated npm
prefix, by the same method as v1.11.0. 1.2.0 has four breaking changes, and
none reaches this image:
- `before_agent_start` also fires for runs started by
`pi.sendMessage(…, { triggerTurn: true })`, and its event is a union on
`messageType`. `systemPrompt` is on both members. Of the three handlers in
the image, ssh-controlmaster reads only `systemPrompt`, and aws-sso and
mempalace read no event field. Nothing in the image uses `triggerTurn`.
- The session counts as busy while a run starts, so `pi.sendUserMessage()`
then needs `deliverAs`. aws-sso already passes it. mempalace's
`/mempalace-diary` is a command, so it runs while pi is idle.
- Themes are schema-validated at startup, and the schema moved to `pi.dev`.
pi-studio's themes still name the old URL, but they load and apply on 1.2.0
with no warning.
- `pi mcp list --json` reports `resources: true` instead of counts. Nothing
here parses it.
Measured on 1.2.0. pi-agent-core's dist is byte-identical to 1.1.0's. The
turn-cap probe ran 19 turns uncapped and stopped at exactly 3 with
`maxTurns=3`. atelier paints in both renderers, with controls at 0/3.
pi-observational-memory registers `/om:status` and `/om:view`. A copy of
tor-ms22's real `~/.pi/agent` starts with the same extension list as on 1.1.0
and no extension issues. `pi -p` answers in 1.0 s through ERDC and 5.2 s
through Bedrock. One change reaches scripts: `pi -p` now exits 1 when the
reply was cut off at the model's output limit. That is text mode only;
`pi-task` and `fork` run `--mode json`. The first start after the upgrade
shows 1.2.0's What's New once. Read from the code, not reproduced: pi-studio
(0.9.63 and 0.10.1 alike) marks pi busy on `agent_start`, which 1.2.0 fires
after the session already counts as busy. A request that reaches pi in that
gap (the `before_agent_start` handlers) gets pi's "Agent is already
processing" error instead of Studio's own "pi is busy". Before 1.2.0 the same
request started a second run that failed, so retrying is the answer either
way. From the browser it is hard to reach: three **Run Prompt** clicks 30 ms
and 150 ms apart produced exactly one run in 0.10.1.
- **pi-studio `v0.9.63` → `v0.10.1`** (studio variant). 0.10.0 is the largest
Studio change in a while. By default there are two editors: a **Prompt** for
what you send to pi and a **Document** for the file you are working on. You
can switch with `/studio --single` / `--dual` or from the footer. Unsaved text
survives reloads, and `/studio` reopens your last workspace (`--blank`,
`--last`); its state lives under `~/.pi/agent/pi-studio`, on the preserved
volume. There is a new **Files** pane, a two-row toolbar, and **Add to
Prompt**. 0.10.1 fixes the Run button flickering while a run starts. Upstream
typechecks against pi 1.2.0; its peers stay `*`.
- **What pi-devbox relies on is unchanged:** the package manifest
(`./index.ts`, `./themes`), the committed `client/studio-client.js`, the 11
`studio_*` tools, the 6 commands, and the `--no-browser`, `--port` and
`--listen-all` flags that `studio-expose` and the README use. The set of
environment variables read is identical.
- **Tested under pi 1.2.0 before tagging**, in a copy of tor-ms22's real
`~/.pi/agent` with 0.10.1 swapped in:
- pi starts with no extension issues, and `/studio --no-browser --port
18765` prints its token URL.
- In headless Chromium the new UI renders with no console errors.
- A prompt typed into the Prompt and sent with **Run Prompt** came back
through ERDC in both Studio and the terminal.
- The Files pane lists the workspace, and **Open in Document** loads a file.
- Quitting pi frees the port.
- **In the image:** the 0.10.1 studio candidate passes the full smoke test,
106/0. It is 11 MB smaller than with 0.9.63, because the npm package shrank.
Inside it, pi's TUI starts with pi-studio among the extensions and no
extension issues.
- **To go back**, if 0.10 does not suit: v1.11.0-studio still has 0.9.63.
Inside a container, `/studio --single` restores the one-editor layout.
- **MCP servers now use pi's built-in MCP support; pi-extensions'
`mcp-loader.ts` is retired.** pi has shipped MCP as a built-in extension
(`builtin:mcp`) since 0.99.0: servers go in `~/.pi/agent/mcp.json` or are
added with `pi mcp add`, and `/mcp` manages them (tools, reconnect, OAuth
sign-in, enable/disable, exposure). Because an extension that registers
`/mcp` *replaces* the built-in, every start on v1.11.0 printed
`Extension … mcp-loader.ts registers command /mcp, so built-in extension mcp
was not loaded`, and `mcp.json` was never read. Measured on tor-ms22
(v1.11.0, pi 1.1.0) before the switch: time to ready, 7 interleaved pty runs
each, was 0.276 s with no MCP, 0.283 s with the loader and 0.272 s with the
built-in. A prompt cost the same tokens with either and with neither, and a
stdio server (`server-everything`) connected and answered a tool call
through the built-in. No device had servers in the loader's `mcp` block, so
nothing needs migrating. If one does, pi-extensions' README ("MCP servers")
shows how to move it to `mcp.json`. Tools from `mcp.json` default to
`codemode` exposure; set `"exposure": "direct"` to declare them to the model.
MemPalace is unaffected: `mempalace.ts` registers `mempalace_*` itself.
- **A preserved volume drops the stale link by itself.** `~/.pi/agent/` survives
a recreate, so the old `extensions/mcp-loader.ts ->
/opt/pi-extensions/extensions/mcp-loader.ts` link would outlive the file. The
entrypoint's pi-extensions `install.sh --yes` now removes links into its own
repo whose target is gone (`.ts` and `/ext`-disabled `.ts.off`). It warns once
if `settings.json` still has an `mcp` block.
- **`recreate-sanity-check.sh`: one new check, "no dangling extension links".**
The existing "N extensions deployed" line counts a dangling link as deployed
(`ls` lists it), so it cannot see this case. The new check WARNs instead of
FAILing, because whatever it still finds points outside pi-extensions, which
`install.sh` leaves alone. Expect one more ✓ than v1.11.0 printed.
- **Vendored `pi-extensions` skill floor refreshed** to pi-extensions `750b2a8`.
`check-skill-floor.sh` went red on the retirement commit and is green again.
The skill now says MCP servers are the built-in's, not an extension. Because
`rootfs/` is in the base hash, this release rebuilds the base.
- DOCKER_HUB.md's pi-extensions list was three releases stale (`mcp-loader`
listed; `task`, `fork-gate`, `secret-guard`, `aws-sso` missing). It now lists
the 10 that ship and has an MCP line. The vendored skills' `VENDORED.md`
pointed at "the Unreleased CHANGELOG entry", which had been stale since
v1.8.8; it now names v1.8.8.
## v1.11.0 — 2026-10-12
Minor release. Headlines: **pi 1.1.0**, **secret env values are kept out of
model context by default** (pi-extensions `secret-guard`), an opt-in **AWS SSO
keeper** for long Bedrock runs, and **`~/.pi/agent/models.json` linked to a
mounted shared file** at every start. Everything else moves to its newest
upstream: pi-observational-memory `022da5e`, pi-atelier `v0.15.0`, pi-studio
`v0.9.63`. The base image is rebuilt (rootfs and `entrypoint-user.sh` changed),
so the base tools pinned to `latest` re-resolve too.
### Components that move with this release
| Component | Baked in v1.10.2 | v1.11.0 | Why |
|---|---|---|---|
| **pi** | `1.0.0` | **`1.1.0`** | audited, see *Changed* below |
| pi-obsmem | `731c3d4` | **`022da5e`** (pinned SHA, = `master`) | #81: compaction never drops unobserved context; #93: pi loads a prebuilt `dist/index.ts` (see *Changed*) |
| pi-atelier | `v0.13.0` (`34d26f1`) | **`v0.15.0`** (`3befe02`) | v0.14.0 Auto/Manual sidebar modes with auto-collapse; v0.15.0 starts in Manual, recolours panels, simpler Activity rows |
| skillset snapshot | `e9e45f7` | **`96b840e`** | provenance only: the vendored `SKILL.md` is byte-identical (sha256 `a1db86da…` at both refs) |
| pi-extensions | `143a214` | **`a88fa99`** (or later: CI resolves `main` at build time) | new `aws-sso.ts` (keeps the Bedrock SSO session alive) and `secret-guard.ts` (keeps secret env values out of model context); both below |
| pi-toolkit | `9c87ee8` | **`3760d90`** (or later: CI resolves `main` at build time) | `pi-task`: optional model fallback when the primary model fails auth before acting (below) |
| pi-studio | `v0.9.61` (`641aa32`) | **`v0.9.63`** (`fae3ad3`) | upstream releases (v0.9.62, v0.9.63), picked up by CI's newest-tag resolution; `-studio` variant only. Reviewed: browser-editor fixes only (WebKit cursor drift after re-wrap, re-layout after macOS autocorrect) plus `Ctrl+Shift+H` to toggle highlighting; peerDependencies unchanged: [compare](https://github.com/omaclaren/pi-studio/compare/641aa3288a6bfd4a7eb37a3a4847836de85e0593...fae3ad38d23c9666981bfc29f9a4e06d56f8de84) |
### Added
- **Shared custom model providers: `~/.pi/agent/models.json` is linked to a
mounted file at every start.** Mount a host *directory* read-only at
`/opt/fleet-pi`, and new `rootfs/usr/local/lib/pi-devbox/link-pi-models.sh`
links `~/.pi/agent/models.json` to `/opt/fleet-pi/models.json`. It runs from
`entrypoint-user.sh`. `PI_MODELS_JSON` points at another path, and `=off`
disables it. It only ever creates a link where nothing exists: a real file or a
link of your own is left alone, with one `NOTE` line in `docker logs`.
Why this is needed: pi reads custom providers (company gateways, vLLM, Ollama,
…) from `~/.pi/agent/models.json`, and `~/.pi` is the `devbox-pi-config`
volume. A file baked into the image would only seed a fresh volume once, and a
hand-made link was one more step on every new volume. In October 2026 that was
the same `ln -s` once per device for one fleet-shared file. Mount the
directory, not the file. `git pull` replaces the file by rename, and a
single-file bind mount then shows "No such file" until the next recreate
(measured on Docker Desktop).
Tests:
- `scripts/test-link-pi-models.sh` has 13 two-sided cases and now runs in
`lint.yml` on every push. Every mutation of a rule was caught, except one
that the guards make unreachable.
- Two smoke-test checks: with nothing mounted no link appears, and the script
links a mounted file.
- `recreate-sanity-check.sh` checks the link whenever a shared file is mounted.
pi treats a dangling `models.json` exactly like a missing one, with no error,
so removing the mount later is harmless (measured with pi 1.0.0). README:
"Custom model providers (`models.json`)".
- **secret-guard (pi-extensions `secret-guard.ts`): secret env values never reach
the model, the session file or the palace feed.** It replaces the exact value
of every env var whose name says secret (`…TOKEN`, `…API_KEY`, `…PASSWORD`, …)
with `<redacted:NAME>`, the same rule and placeholder as mempalace-toolkit's
feed redactor. It applies to every tool result (MCP tools included), to the
context of each request (covering user `!` commands and older sessions), and it
blocks a `write`/`edit` that would write such a placeholder back over the real
value. `$NAME` still expands in commands; only the output coming back is
rewritten.
Measured motivation, 2026-10-12: an agent's own `sed` failed to redact
`MEMPALACE_REMOTE_TOKEN=…` from `env | grep` output. The raw value had been in
local session files for two months, so each of those sessions had sent it to
its model provider. The palace stayed clean only because the feed redactor
works.
Not covered:
- live streaming output while a tool runs;
- the stored entry of a user `!` command;
- secrets not in the environment;
- `pi-task` children, which run `--no-extensions`.
`PI_SECRET_GUARD=off` disables it.
- **`pi-task` model fallback (pi-toolkit `bin/pi-task`).** A
`pi-task.fallbackProfiles` block in `settings.json` (per effort tier:
provider/id/thinking) lets a task retry once on another model. The retry
happens only when the primary fails with an auth-class error (an expired SSO
token, an invalid key) **before** it has called a tool or changed a watched
file, and with at least 30 s of wall budget left. Both attempts are recorded in
`result.json` and the audit dir, and the result line is tagged `[FALLBACK]`.
Without the block, behaviour is unchanged. It was needed because pi reports
such a failure only inside its JSON stream: `stopReason=error`, rc 0. Before
this, an expired Bedrock session failed every task as "no fenced json
envelope". The `fork` tool does not read this key. Documented in pi-toolkit's
README. It complements `/aws-sso` (below), which by design acts only in
interactive sessions (`ctx.hasUI`; pi-extensions `docs/aws-sso.md`,
troubleshooting). A `task` child that meets an ended SSO session gets no help
from the keeper.
- **Long Bedrock runs no longer die at "Token is expired" when the AWS SSO
session ends: `/aws-sso` (pi-extensions `aws-sso.ts`) runs `aws sso login` for
you just after the session ends.** Off until `/aws-sso on` (or
`PI_AWS_SSO_REFRESH=1`), so other devices stay as they are.
Why this is needed: pi's AWS SDK already renews the 1-hour access token by itself.
The limit is the IAM Identity Center session behind it, the browser's AWS sign-in,
which the IdC admin caps (8 h here, measured three times). After it, AWS refuses
every renewal, and a long or overnight run stops. Renewing on a clock does not
work: a login while the browser sign-in is still valid completes silently but
*inherits* its end. Only a login after the end starts a new session.
How it finds the end: in the session's last hour AWS still issues tokens, but caps
them at the end. The keeper makes the SDK's own renewal attempt as soon as the
SDK's 5-minute window opens and reads the result from the token cache: a token
issued for under 55 min gives the end, and the login runs 30 s after it. A refused
renewal triggers the login at once. If a request fails anyway (pi closed, laptop
asleep), it logs in and sends `continue`, so the run resumes. It never handles
passwords or MFA, and tokens appear only as `sha256[:8]` fingerprints.
Two modes, set per device with `/aws-sso local|remote` or `PI_AWS_SSO_MODE`:
`local` (default) runs the login on the Docker host over `~/.ssh-local/config`,
so the host's signed-in browser normally completes it silently. `remote`
(headless server, ssh + tmux) uses the device-code flow in the container: link
and code shown in pi and pushed to the phone via [ntfy](https://ntfy.sh)
(`PI_AWS_SSO_NTFY`; the topic name is a secret, shown only as a fingerprint).
Pushes also report a `local` login waiting for you, a failed login and a renewal.
Measured on mbp-m1-2020 (macOS host, `local`), 2026-10-11/12: overnight, two
unattended silent logins with nobody at the Mac. Then, with the final rule: the
end was read from the cap at 08:08:57 (session end 08:47:55), and the login ran
at 08:48:40 and completed in 7 s with a new full-hour token, before any request
failed.
`.env.example` documents the `PI_AWS_SSO_*` variables. The setup guide (modes
per device type, ntfy, log reference, troubleshooting) is pi-extensions
[`docs/aws-sso.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/docs/aws-sso.md).
`.env` changes need a container recreate. To try a variable without one, run a
second pi as the right user: `docker exec` defaults to **root** here (the image
sets no `USER`), and root has no `~/.aws` ("Could not load credentials from any
providers"). Use
`docker exec -it -u developer -e PI_AWS_SSO_NTFY=… pi-devbox bash -lc pi`.
### Changed
- **pi `1.0.0` → `1.1.0`.** Audited 2026-10-12 against the published npm
tarballs, by the same method as the v1.10.2 audit (the full note is above
`ARG PI_VERSION` in `Dockerfile.variant`):
- The only `### Breaking Changes` section in 1.0.1..1.1.0 is 1.0.3's rename
of the `azure-openai-responses` provider to `azure`. Nothing here uses it.
- `finishTurn` handling in pi-agent-core is byte-identical between 1.0.0 and
1.1.0. The six named `pi.*` APIs our extensions call (`registerTool`,
`registerCommand`, `registerFlag`, `getFlag`, `sendMessage`,
`sendUserMessage`) appear in the same dist files in both versions, and so do
the TUI internals atelier hooks. `engines.node` is unchanged.
- User-visible: `Home`/`End` now always move the editor cursor (fullscreen
transcript top/bottom moved to `Ctrl+Home`/`Ctrl+End`). Terminals that
support OSC 7501 now see pi's working/blocked/done status; set
`PI_PROGRAM_STATUS=0` to turn that off.
- Exercised before tagging with pi 1.1.0 from a temp prefix, in a real pty,
with `--no-extensions` controls:
- The obsmem turn cap held on the real 1.1.0 agent loop: uncapped ran 11
turns, capped stopped at exactly 3.
- atelier v0.15.0 painted in both renderers (controls 0/3, every arm
render-proofed).
- obsmem `022da5e` registered `/om:status` and `/om:view`.
- All 11 pi-extensions `a88fa99` extensions loaded without error.
- **pi-observational-memory `731c3d4` → `022da5e`**, still a full-SHA pin; no
tag newer than 3.1.4 exists.
- #81 changes compaction. Entries the observer has not reached yet are no
longer cut out of context, and memory workers yield to the session. New
settings (`compactionMaxRetainedTokens`, `consolidateWhenIdle`,
`piAutoCompactionEnabled`) all have defaults, so nothing needs setting.
- #93 makes pi load a committed `dist/index.ts` bundle instead of 32 TS
sources, which is faster to start.
- `src/agents/` is unchanged between the two SHAs, so the workers'
`finishTurn` migration is intact.
- **pi-atelier `v0.13.0` → `v0.15.0`.** The sidebar now starts in **Manual**
mode. `/atelier sidebar auto` collapses it when the terminal is narrow and
reopens it when there is room. `showSidebarOnStartup` keeps its key and its
default. README's sidebar section describes both modes.
- **Base rebuilt** (`base-<hash>` changes because `rootfs/` and
`entrypoint-user.sh` changed). The base tools pinned to `latest` re-resolve.
Upstream on 2026-10-12: uv 0.13.0 (its breaking change makes Python 3.15 the
default *download*; mempalace still installs with `uv tool install` on
Debian's python3.13, which uv prefers because it is already installed), nvim
0.12.6, fzf 0.74.5. `# BASE_REBUILD_DATE:` is updated while it is free.
### Still open
- `remote` mode end to end (code → push → approve on the phone) is unit-tested and
its push path is verified against ntfy.sh, but no live device-code renewal has
run yet.
- `local` is verified on a macOS host only. On a Linux or Windows host, the login
started over SSH may not reach the desktop browser; `PI_AWS_SSO_LOCAL_CMD`
overrides the host command.
- AWS refused renewals twice with time left in the session (a probe 5 min before
the end, a failed request about 2 h before it). The cause is unknown; both times
a login then worked at once.
- The real fix is administrative: the IdC admin can raise the session length (up to
90 days), after which the keeper has almost nothing to do.
- **The pi-obsmem pin does not bind CI builds.** `resolve-versions` in
`docker-publish.yml` still resolves `pi-observational-memory` from `master`
and passes it as `--build-arg PI_OBSMEM_REF`, which overrides the Dockerfile
default. This was verified on both sides with buildx: without the build-arg
the pin is baked, with it `master` is. The pin reached v1.9.5, v1.10.2 and
this release only because `master` equalled the pin at build time.
`check-doc-drift.sh` reads the ARG, so it would not notice a divergence. Fix
it by reading the pin from `Dockerfile.variant` in `resolve-versions`, the way
pi-atelier's tag already is.
---
## v1.10.2 — 2026-10-02
**v1.10.0 and v1.10.1 were both tagged and never published.** Neither failure
was in the image — both were in the test and CI harness, and each hid the other:
| tag | run | symptom | cause |
|---|---|---|---|
| v1.10.0 | 704 | `smoke` 100 passed / **1 failed**; `smoke-studio` 103 / **1** | a smoke assertion required a pi dependency upstream had deleted |
| v1.10.1 | 707 | `smoke` **101 passed / 0 failed**, `smoke-studio` **104 / 0**, and both jobs red anyway with **no failing step** | base64(`Dockerfile.variant`) in buildx's warning metadata crossed act_runner's 64 KiB line cap |
In both runs every publish job skipped, so no image, no `latest`, and no Hub
description was ever written — the gates did their job twice. **Everything
described under v1.10.0 below ships here**; that section remains the content
record and nothing in it changed.
The tag number moves each time rather than being re-pointed, because CI had
already consumed the previous one. A version that failed its build should stay
failed and readable, not be quietly overwritten with different bytes.
### Fixed
- **A job can fail with every step green, a smoke suite reporting `0 failed`,
and no failing step anywhere — and it cost two tags** — `Dockerfile.variant`
now opens with `# check=skip=InvalidDefaultArgInFrom`, and
`scripts/check-dockerfile-directives.sh` keeps it there.
The chain, measured rather than reasoned: `ARG BASE_IMAGE` deliberately has no
default (the two-phase build always supplies it), which trips BuildKit's
`InvalidDefaultArgInFrom` check. buildx attaches that 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 of the file passed 64 KiB the closing delimiter was cut off and the
runner reported `invalid format delimiter 'ghadelimiter_...' not found before
end of file`, then failed the job while attributing it to nothing.
Numbers: the file was 42251 B at v1.9.4 → base64 56355 chars, fine; it is
50034 B now → base64 66712, truncated to exactly 65536 (2^16, i.e. cut *at*
the cap, not merely long). The cap corresponds to a 49152 B Dockerfile, so
this release's comment growth — much of it comments added while documenting
the v1.10.0 round — crossed it by **882 bytes**. Confirmed two ways: by
parsing the metadata out of both job logs, and independently by arithmetic on
the file size at each tag. Verified fixed by running `docker buildx build
--check` against the actual edited file: *"Check complete, no warnings
found."* 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 whether CI passes**.
Hypotheses measured and rejected on the way, each of which would have produced
a wrong fix: floating action tags moving (the act action-bundle hashes are
byte-identical between the green v1.9.4 run and the red one — all four); the
build-check annotation text changing (byte-identical); and `provenance: false`,
which was written, tested against a real buildx, and **reverted** when the
metadata turned out to carry the base64 under `buildx.build.warnings`, not
under provenance. The published images are unaffected either way: they are
built by raw `docker buildx build --push`, which never writes
`$GITHUB_OUTPUT` metadata.
The new guard runs in **both** `lint.yml` and the publish workflow's
`lint-gate`, because 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. It
also fails if `ARG BASE_IMAGE` ever gains a default, so the skip directive
cannot rot into pointing at a check that can no longer fire. Truth table:
directive present → rc=0; removed → rc=1; demoted to line 2 → rc=1; ARG given
a default → rc=1; file missing → rc=2 (cannot-run must not pass).
- **The smoke suite asserted a pi dependency that upstream deleted, and it cost
this release its first tag build** — `scripts/smoke-test.sh` required
`@mariozechner/clipboard` to be installed and `require()`-able at every install
site. pi **0.86.0** (`#9163`) *"Replaced the external native clipboard
dependency with bundled asynchronous macOS, Windows, and X11 helpers while
preserving platform command and OSC 52 fallbacks"*. Measured in the published
tarballs: pi `0.85.1` declares `@mariozechner/clipboard@0.3.9`; `0.87.1` and
`1.0.0` declare no `@mariozechner/*` at all. So on a correct v1.10.0 image the
package is legitimately absent, the assertion's `if [ -z "$sites" ]; then exit
1` fired, and run 704 ended **100 passed / 1 failed** on `smoke` and **103
passed / 1 failed** on `smoke-studio` — same single assertion, every
studio-specific check green. `build-variant`, `build-variant-studio`,
`promote-base-latest` and `update-description` were all skipped, so **nothing
was published**: the gate behaved exactly as designed, against a stale
expectation rather than a real defect.
The fix does not delete the guard, because the guard was right: "fail when the
family is absent" is what stops `prune_foreign_natives()` from silently
deleting the binding instead of the surplus platform copies. It now **derives
its expectation from what the image's pi actually declares** — if pi declares
the dependency an install must exist and load; if pi does not, no install may
linger. That re-arms by itself should a future pi re-add it, and it still
catches an orphaned install. Verified as a four-quadrant truth table with the
command string extracted verbatim from the file and run through `sh -c` the way
`run()` invokes it: declared+present → rc=0, declared+absent → rc=1,
undeclared+absent → rc=0, undeclared+present → rc=1.
Worth stating plainly, since it is the whole value of the round: pi 1.0.0,
pi-atelier v0.13.0 and pi-studio v0.9.61 passed **103 of 104** assertions on a
pi MAJOR bump. The one red was the suite describing pi 0.85.1's dependency
graph, not the image.
---
## v1.10.0 — 2026-10-02 (tagged, never published — content shipped as v1.10.2)
> Tagged 2026-10-02 at commit `12f99c4`. Its build (run 704) went red on the
> stale clipboard assertion described under v1.10.1, so `build-variant`,
> `build-variant-studio`, `promote-base-latest` and `update-description` all
> skipped and nothing reached the Hub. No image carries this tag. The content
> below is accurate and shipped as **v1.10.2**. v1.10.1 (commit `c2c42c3`,
> run 707) was the first attempt at republishing it and died on the separate
> act_runner line-cap bug described under v1.10.2.
Three small fixes found by the v1.9.4 first-boot acceptance and the CI base
hash prediction, plus one boot-time wiring fix that stops a recurring `git push`
+3 -2
View File
@@ -49,7 +49,8 @@ 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-atelier** — TUI sidebar (ordered panels, split-pane, themes), vendored at `/opt/pi-atelier` and pinned to an audited tag; the exact tag is in the image labels (`se.jordbo.pi-devbox.pi-atelier-version`) and `/etc/pi-devbox/build-manifest.json`
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
- **[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
- **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
@@ -60,7 +61,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
### 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 `mempalace_*` tools inside pi (the palace server decides the set: the baked 3.11.0 lists 47, a 3.10.0 central server 45)
- ChromaDB ONNX embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
- Bind-mount your host's `~/.mempalace` and the host-pi and container-pi share one brain
+37 -9
View File
@@ -14,7 +14,7 @@
# content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates.
#
# BASE_REBUILD_DATE: 2026-09-22 (v1.9.4 — mempalace 3.9.0 -> 3.10.0 with ENV MEMPALACE_CONFIG_DIR pinning the layout, mempalace-toolkit 2167a1b explicit event_list order; previous marker 2026-09-19 / v1.9.3)
# BASE_REBUILD_DATE: 2026-10-18 (v1.12.0 — mempalace 3.11.0, link-pi-extensions.sh, studio-expose and skill doc fixes; apt and base tools re-resolve (upstream on 2026-10-18: uv 0.13.1, gitea-mcp 1.9.0, Node v24.22.0; nvim 0.12.6, fzf 0.74.5 unchanged); previous marker 2026-10-14 / v1.11.1)
#
# ── Lineage note ─────────────────────────────────────────────────────
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
@@ -639,7 +639,30 @@ ARG INSTALL_MEMPALACE=true
# 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
#
# v1.12.0: 3.10.0 -> 3.11.0 (PyPI 2026-10-14). Core requirements unchanged.
# Three "Upgrade notes", checked against our callers and measured with both
# wheels in throwaway uv venvs and a throwaway palace:
# - `EntityRegistry.research()` / `confirm_research()` removed: 0 callers in
# pi-devbox, mempalace-toolkit, skillset or myconfigs.
# - The first conversation mine re-mines each covered conversation file once
# (the exchange chunker stopped discarding text). LOCAL palaces only from
# this image: in remote mode the hub does the mining. Measured: a palace
# built by 3.10.0 (84 drawers) re-mined its one convo file under 3.11.0
# (83 -> 84 drawers, old ones replaced, 2 s); the next mine skipped it.
# - A palace refuses to open with a different embedder than the one that
# built it. The default (MiniLM) is unchanged and nothing here sets
# `embedding_model`; the 3.10.0-built palace opened, searched and served
# MCP under 3.11.0 with no warning.
# `mempalace init --yes` (entrypoint) still writes config.json, rc 0. MCP over
# stdio: 45 -> 47 tools, none removed or renamed (+delete_drawers,
# +get_drawers); 14 read-only tools now carry `readOnlyHint`. Skew: synlig's
# hub is still 3.10.0 (measured 2026-10-18), the newer-client direction, and
# the bridge's tool list comes from the hub, so nothing changes for shared-
# palace devices until synlig upgrades. When it does, ITS first convo mine
# re-mines every inbox transcript still on disk (upstream: 40 min to 3 h per
# million drawers); back it up and run it at a quiet time.
ARG MEMPALACE_VERSION=3.11.0
# 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
# would be one more pin able to drift, which is the class check-doc-drift.sh
@@ -822,12 +845,16 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
# a single ~30 MB static Rust binary with no LaTeX dependency. pi-studio's
# PDF export (studio_export_pdf) and pandoc invocations use it via
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-
# fidelity fallback for anyone who needs LaTeX-exact output (not shipped
# here — install on demand or in a future variant).
# back-end engine. Rather than a ~0.5–1 GB TeX Live install, we ship typst:
# a single ~30 MB static Rust binary with no LaTeX dependency. Callers must
# ask for it with `pandoc --pdf-engine=typst`, because pandoc's own default
# engine is pdflatex, which is not installed. pi-studio's PDF export
# (studio_export_pdf, /studio-pdf) does NOT use typst. It runs pandoc with
# $PANDOC_PDF_ENGINE, defaulting to xelatex, so it fails with "xelatex not
# found". Setting PANDOC_PDF_ENGINE=typst is not a fix: pi-studio adds its
# own LaTeX header, and typst prints that header as page text while pandoc
# exits 0. TeX Live is not shipped here; README "PDF export from Studio needs
# LaTeX" has the on-demand recipe (~490 MB).
#
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
@@ -839,7 +866,8 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
# document font to an empty tuple (`font: ()`), so a naked
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
# (typst's own bundled default font) so PDF export works out-of-the-box.
# (typst's own bundled default font) so a naked `pandoc --pdf-engine=typst`
# works out of the box.
ARG TYPST_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${TYPST_VERSION}" && \
+172 -14
View File
@@ -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
#
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
@@ -154,11 +188,11 @@ ARG USER_NAME=developer
# (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.0 went and got the
# 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.0: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm
# 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,
@@ -178,9 +212,11 @@ ARG USER_NAME=developer
# `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 and mcp-loader.ts run their OWN MCP client and surface tools via
# 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`. Confirmed on a second route — settings.json has
# `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.
@@ -197,7 +233,91 @@ ARG USER_NAME=developer
# 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.
ARG PI_VERSION=1.0.0
#
# 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.
#
# v1.12.0: 1.2.0 -> 1.3.0 (npm latest, 2026-10-15). MEASURED 2026-10-18 on
# tor-ms22 from an isolated npm prefix, against the 1.2.0 tarballs unpacked
# side by side:
# 1. NO `### Breaking Changes` section in 1.3.0. Behaviour changes, none of
# which reaches us: `--model`/`enabledModels` resolve a bare id across
# providers by credentials (ours are all `amazon-bedrock/...`, and
# pi-fork's effortProfiles name a provider); resumed sessions restore
# their active tools; thinking from another model is dropped on a model
# switch; `kimi-coding` base-URL overrides need `/v1` (not used).
# 2. pi-agent-core and pi-tui 1.2.0 -> 1.3.0: dist BYTE-IDENTICAL (tree
# hashes 6a2819f6 / 92eecf92, 24 / 184 files), and so is pi-coding-agent's
# dist/core/extensions. So obsmem's turn cap (agent-core) and atelier's
# painting (pi-tui) cannot have moved there; atelier's internals
# TuiMainScreen / renderLayoutFrame are in the same number of dist files.
# 3. Public API: the only removed export in any .d.ts is
# `parseModelPattern` (model-resolver, never re-exported from the package
# index), and pi-ai dropped its `assistant-message-frame` re-export. 0
# consumers of either. All 54 runtime-imported names across pi-toolkit,
# pi-extensions, pi-fork, obsmem, pi-studio, pi-atelier, mempalace-toolkit
# and /opt/fleet-pi resolve in 1.3.0 (control: an injected fake name is
# reported missing).
# 4. engines.node is still `>=22.19.0`; internal deps are now exact pins.
# Exercised before tagging, with a copy of tor-ms22's real ~/.pi/agent:
# `pi -p` exits 0 in 10 s (1.2.0: 9 s), nothing on stderr; the TUI loads every
# extension with no [Extension issues], the bridge registers its 45 tools,
# /om:status, /om:view and the /studio commands are offered, and atelier
# paints ACTIVITY/READY/TOOLS in both renderers (controls with -ne: none).
ARG PI_VERSION=1.3.0
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
@@ -219,7 +339,22 @@ ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
# 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.
ARG PI_OBSMEM_REF=731c3d49288580f4d79cabdbfbc0d16b34db0f41
# 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
# TAG, which CI resolves to that tag's commit SHA — same treatment as
# pi-studio, for reproducibility plus cache-busting.
@@ -273,7 +408,7 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
# 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.0: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the
# 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
@@ -298,9 +433,31 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
# - 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.
ARG PI_ATELIER_REF=v0.13.0
#
# 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.
# v1.12.0: v0.15.0 -> v0.16.0 (3b42825, 2026-10-15). Additive: contributed
# panels may send label/value rows with an optional progress bar (`keyValues`;
# plain `rows` stay the fallback) and two new roles, and the context meter
# loses its hard-coded dark background. Protocol unchanged (channel
# `pi-atelier:sidebar-panels`, version 1, 24 rows, 160 chars), peer floor
# unchanged (pi >=0.84.0), still zero runtime dependencies. On pi 1.3.0 it
# paints in both renderers, and the fleet's contributed `fleet:todos` panel
# (/opt/fleet-pi/fleet-todos-panel.ts, plain rows) shows a todo written in a
# real turn, as on v0.15.0. The v0.15.0 arm still draws the old meter
# background (48;2;48;53;56) and the v0.16.0 arm does not, so the arms ran the
# code they claim.
ARG PI_ATELIER_REF=v0.16.0
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
ARG PI_ATELIER_VERSION=v0.13.0
ARG PI_ATELIER_VERSION=v0.16.0
RUN set -e && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
@@ -558,10 +715,11 @@ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
# so reaching it from a browser needs a loopback bridge or host networking —
# see the "Using pi-studio" section in README.md.
# Harmless in the non-studio variant. NOTE: by default pi-studio binds the
# server to 127.0.0.1 inside the container, so reaching it from a browser
# needs host networking, the studio-expose loopback bridge, or the opt-in
# `/studio --listen-all` (pi-studio >= 0.9.60; never together with the
# bridge on one port) — see the "Using pi-studio" section in README.md.
ENV STUDIO_PORT=8765
# ── Optional: Go toolchain ───────────────────────────────────────────
@@ -625,7 +783,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
# 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
# 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)"
# and every variant INHERITS it, so both published images used to advertise
+157 -29
View File
@@ -175,10 +175,12 @@ Currently published:
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
Both variants ship **`typst`** as the pandoc PDF engine
(`pandoc --pdf-engine=typst`), a single ~30 MB static binary, so PDF export from
Studio/pandoc works out of the box — no separate `-tex` variant needed.
`texlive-xetex` stays the higher-fidelity fallback (install on demand).
Both variants ship **`typst`**, a single ~30 MB static binary, as a PDF engine
for pandoc, so `pandoc doc.md --pdf-engine=typst -o doc.pdf` works out of the
box (see [Generating a PDF with pandoc + typst](#generating-a-pdf-with-pandoc--typst)).
**pi-studio's PDF export does not use typst.** `/studio-pdf` and
`studio_export_pdf` need a LaTeX engine, which neither variant ships (see
[PDF export from Studio needs LaTeX](#pdf-export-from-studio-needs-latex)).
## Using pi-studio (`-studio` variant)
@@ -198,12 +200,11 @@ Inside a pi session in the container:
### Reaching the UI from your browser (the container caveat)
pi-studio **hard-binds its server to `127.0.0.1` inside the container**
(`index.ts`: `.listen(port, "127.0.0.1")`) and serves a tokenized URL.
There is no `--host`/bind flag. This matters for a container: a plain
By default pi-studio **binds its server to `127.0.0.1` inside the
container** and serves a tokenized URL. This matters for a container: a plain
`docker run -p 8765:8765` publish forwards to the container's *external*
interface, **not** its loopback, so it will not reach Studio. Two paths
work:
interface, **not** its loopback, so it will not reach a default Studio.
Three paths work (A and B keep Studio itself on loopback; C does not):
**A. Host networking (simplest — OrbStack / single-host, no bridge).**
Run the container with host networking so the container's loopback is the
@@ -215,8 +216,9 @@ services:
network_mode: host # container 127.0.0.1 == host 127.0.0.1
```
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
host. This is the most secure option (Studio never leaves loopback). Note:
Then the URL Studio prints (`http://127.0.0.1:8765/?token=…`) works in a
browser on the Docker host. This is the most secure option (Studio never
leaves loopback). Note:
host networking changes `host.docker.internal` semantics, so weigh it
against the LAN-jump SSH feature if you use that.
@@ -253,6 +255,20 @@ studio-expose & # bridges $STUDIO_PORT (default 8765); --help for detai
> its tokenized URL is the only auth. Keep the host-side publish on
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
**C. `/studio --listen-all` (pi-studio ≥ 0.9.60, no bridge).** pi-studio has
its own opt-in flag: `/studio --no-browser --listen-all --port 8765` binds
`0.0.0.0`, so the published port reaches it without `socat`. The URL it
prints still says `127.0.0.1`, so it works verbatim through a same-port
publish. Keep the publish on `127.0.0.1:` as in B: anyone who can reach the
port and has the full URL controls Studio, including running prompts and
reading or writing files, and there is no TLS.
> **Use B or C on a port, not both.** Whichever starts second fails with
> `EADDRINUSE`: `0.0.0.0` includes the egress IP the bridge binds. With
> `STUDIO_EXPOSE=1` the bridge holds `$STUDIO_PORT` from boot, so
> `/studio --listen-all --port 8765` fails. If the bridge is running, start
> Studio *without* `--listen-all`.
### Remote host (SSH / mosh)
When the Docker host is remote, keep Studio on localhost and forward the
@@ -298,25 +314,79 @@ Assuming the compose file publishes `127.0.0.1:8765:8765` (see method B):
TUI, not a shell command:
```
/studio --no-browser --port 8765
/studio --no-browser --port 8765 --watch notes.md # or: a rendered, read-only preview of a file
/studio --status # reprint the URL anytime
```
Copy the `http://…:8765/?token=<token>` it prints. **This** is where
the real token comes from — not `studio-expose`.
**This** is where the real token comes from — not `studio-expose`.
The port must be the **published** one (`8765` here): the tunnel, the
publish and the bridge all lead to it, and Studio on any other port is
unreachable from outside. `--port` does not move a running server (Studio
only warns), so run `/studio --stop` first to change it.
3. **On your laptop** — open the ssh port-forward alongside mosh:
```bash
ssh -L 8765:127.0.0.1:8765 user@docker-host
ssh -N -o ExitOnForwardFailure=yes -L 8765:127.0.0.1:8765 user@docker-host
```
4. **In your laptop browser** — open `http://127.0.0.1:8765/?token=<token>`
(keep the port and token verbatim; only the host part is `127.0.0.1`).
Only the **left** port is yours to choose. If `8765` is already taken on
the laptop (for example by the laptop's own devbox publish), use
`-L 18765:127.0.0.1:8765` and change only the port in the URL.
4. **In your laptop browser** — open the **whole** URL Studio printed. It
already says `127.0.0.1`, and it carries more than the token
(`token`, `studioTabState`, `hostedWorkspace` since pi-studio 0.10, or
`docPath`/`watchFile`/… for `--watch`). A URL cut down to just the token
is answered `409`.
The URL is long and wraps across TUI lines; a side panel next to it (such
as the pi-atelier sidebar) can end up inside the copied text, and Studio
then answers *"This link looks damaged…"*. Rejoin the lines without the
panel text, or widen the terminal and copy it again.
> **Order check:** nothing listens on the container's `127.0.0.1:8765`
> until step 2 runs. If the browser can't connect, verify Studio is up
> (`/studio --status`) and the bridge is running (`ps aux | grep socat`).
> **on the published port** (`/studio --status`) and the bridge is running
> (`ps aux | grep socat`). On the Docker host,
> `curl -s http://127.0.0.1:8765/` answers `403` when the whole chain is up;
> an empty reply means the bridge has nothing behind it, i.e. Studio is down
> or on another port.
> PDF export (`/studio-pdf`, `studio_export_pdf`) uses **`typst`**, shipped in
> the base image as the pandoc PDF engine (`pandoc --pdf-engine=typst`). For
> LaTeX-exact output you can install `texlive-xetex` on demand as a heavier
> fallback. HTML export, KaTeX, Mermaid, and all REPL features work regardless.
### PDF export from Studio needs LaTeX
Studio's PDF export (`/studio-pdf` and the `studio_export_pdf` tool) fails out
of the box:
```text
pandoc PDF export failed with exit code 47: xelatex not found
```
pi-studio runs pandoc with the engine named in `PANDOC_PDF_ENGINE`, and uses
`xelatex` when that is unset. The image ships no LaTeX engine and does not set
`PANDOC_PDF_ENGINE`. HTML export, KaTeX, Mermaid and the REPLs are not
affected.
**Do not set `PANDOC_PDF_ENGINE=typst`**, even though the error message
suggests setting it. pi-studio adds its own LaTeX header
(`\usepackage{titlesec}`, `\titleformat…`) to the document, and typst does
not understand LaTeX. pandoc exits 0, and the header is printed as text above
your first heading. There is no error, only a wrong PDF.
There are two ways out:
- **Run pandoc yourself** (nothing to install):
`pandoc doc.md --pdf-engine=typst -o doc.pdf`. See the next section.
- **Install the LaTeX packages Studio needs.** This adds 37 packages and
about 490 MB, and they are gone when the container is recreated:
```bash
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
texlive-xetex lmodern texlive-plain-generic texlive-fonts-recommended
```
The image starts with an empty package index, so run `apt-get update`
first. With `--no-install-recommends`, `texlive-xetex` on its own is not
enough: Studio's export then lacks `lmodern.sty`, `ulem.sty` and the `pzdr`
font, which the other three packages provide. A plain
`sudo apt-get install texlive-xetex` also works, but its recommended
packages make it 202 packages and about 1 GB, including systemd, D-Bus and
a Java runtime. (Measured on v1.11.2 with pi-studio 0.10.1.)
### Generating a PDF with pandoc + typst
@@ -358,7 +428,7 @@ DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a
`.md` that embeds it) and hit **refresh-from-disk** after each edit.
Note: SVG is **not** in Studio's local-image-link allowlist — use PNG.
## Terminal UI mode: fullscreen is the default (since v1.10.0)
## 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
@@ -464,7 +534,10 @@ pin the order yourself.
The sidebar auto-hides below 92 terminal columns and keeps the main pane at
least 64 columns wide, so a narrow terminal degrades to the plain TUI rather
than a squeezed one.
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
@@ -879,6 +952,61 @@ If you use Bedrock as pi's LLM provider:
The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock
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.
### The same directory can carry pi extensions
pi auto-loads every `.ts` in `~/.pi/agent/extensions/`, which lives in the same
volume and so has the same problem. The entrypoint therefore links top-level
`*.ts` files from the mounted directory in as well, one line each:
pi extension: linked erdc-classifiers.ts -> /opt/fleet-pi/erdc-classifiers.ts
- Only **top-level** `*.ts` files are linked: `models.json`, a README or a
nested checkout in that directory is not an extension, and one is never loaded
by accident.
- Same never-destructive rule, with the same `NOTE` lines, and the same
reasoning that makes a link right where a single-file mount is not (a pulled
change is picked up at once).
- **Your own copy wins.** A real file of the same name in
`~/.pi/agent/extensions/` is left alone, so a local edit of a shared extension
is never overwritten.
- `PI_EXTENSIONS_SRC` points at another directory, and `PI_EXTENSIONS_SRC=off`
disables it. `recreate-sanity-check.sh` verifies each link whenever the shared
directory holds `*.ts`.
## Build pipeline
pi-devbox is built from this repo's CI in two phases:
@@ -1161,7 +1289,7 @@ persisted volumes survived, and pi runtime wiring is intact:
```bash
./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-version 1.0.0 # assert the pi coding agent version
./scripts/recreate-sanity-check.sh --expected-version 1.3.0 # assert the pi coding agent version
```
Those are **two different versions**, and the flags are not interchangeable:
@@ -1195,15 +1323,15 @@ user's pull.
### Version pins: pi, pi-atelier, mempalace
Three components are pinned to an exact version **in the repo** instead of being
Four components are pinned to an exact version **in the repo** instead of being
resolved to `latest` at build time:
| Component | Pin | Where |
|---|---|---|
| pi | `1.0.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-obsmem | `731c3d49288580f4d79cabdbfbc0d16b34db0f41` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
| pi-atelier | `v0.13.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.10.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
| pi | `1.3.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
| pi-atelier | `v0.16.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.11.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
The objective is **not** to freeze versions. Bumping is routine — usually one
line plus a changelog note. The objective is that adopting a new upstream
+20
View File
@@ -371,6 +371,26 @@ if command -v pi &>/dev/null; then
echo "WARN: pi-extensions install.sh failed (continuing)"
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
# Shared pi extensions: link each TOP-LEVEL *.ts in that same mounted directory
# into ~/.pi/agent/extensions (default /opt/fleet-pi; PI_EXTENSIONS_SRC overrides,
# =off disables). Same reasoning and same never-destructive rule as models.json
# above: without it every fresh devbox-pi-config volume needed a manual `ln -s`
# per extension per device, and the copies drift. A user's own file of the same
# name wins over the fleet copy. Rationale and cases: the script's header.
if [ -r /usr/local/lib/pi-devbox/link-pi-extensions.sh ]; then
bash /usr/local/lib/pi-devbox/link-pi-extensions.sh || true
fi
# Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
_pi_settings="$HOME/.pi/agent/settings.json"
+11 -7
View File
@@ -3,13 +3,17 @@
# through a published Docker port.
#
# WHY THIS EXISTS
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
# By default pi-studio binds its HTTP/WebSocket server to 127.0.0.1 inside
# the container. A plain `docker run -p 8765:8765` forwards to the
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
# reach Studio. This helper runs a socat TCP relay that listens on the
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
# reach a default Studio. This helper runs a socat TCP relay that listens
# on the container's egress IP and forwards to 127.0.0.1:<port>, so a
# published port (and an `ssh -L` tunnel from your laptop) can reach Studio.
#
# The alternative (pi-studio >= 0.9.60) is `/studio --listen-all`, which
# binds 0.0.0.0 and needs no bridge. Use one or the other on a port, never
# both: whichever starts second fails with EADDRINUSE (0.0.0.0 includes
# the egress IP this relay binds).
#
# SECURITY
# This intentionally exposes Studio beyond loopback — anything that can
@@ -34,7 +38,7 @@ set -euo pipefail
PORT="${1:-${STUDIO_PORT:-8765}}"
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//'
exit 0
fi
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# link-pi-extensions.sh — point ~/.pi/agent/extensions/*.ts at shared, mounted files.
#
# pi auto-loads every .ts file in ~/.pi/agent/extensions/. In pi-devbox that
# directory lives in the devbox-pi-config named volume, which makes both obvious
# ways of providing a fleet extension wrong — the same two ways as models.json
# (see link-pi-models.sh, whose header this mirrors):
# - 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 the extension changes.
# Instead, keep the .ts files in the same host directory that already holds
# models.json, mount that directory read-only, and let this script link them in
# at every container start:
#
# volumes:
# - ./pi-shared:/opt/fleet-pi:ro # a directory whose top-level *.ts are extensions
#
# A SYMLINK, not a single-file bind mount, and for the measured reason given in
# link-pi-models.sh: `git pull` replaces a file by rename, so a single-file mount
# keeps the old inode and serves stale content until the next recreate. A link
# follows the path, so a pulled change reaches the next pi session.
#
# Only TOP-LEVEL *.ts files are linked — no recursive search. The directory is
# fleet-shared pi config: models.json, its own docs and any sub-checkout are not
# extensions, and silently loading them would be worse than missing one.
#
# PI_EXTENSIONS_SRC overrides the directory (default /opt/fleet-pi).
# PI_EXTENSIONS_SRC=off (or empty) disables this script.
#
# Never destructive, per file. It only ever creates a link where nothing exists:
# - no file at the destination → link created
# - the destination is already ours → nothing
# - a real file with that name exists → left alone; one NOTE line says why
# (the user's own extension of that name deliberately wins over the fleet copy)
# - the destination links elsewhere → left alone; a deliberate choice
# A link this script made whose target later disappears (mount removed) is
# harmless and needs no cleanup: pi reports the unreadable extension and the rest
# of the session is unaffected.
#
# Called by entrypoint-user.sh. Safe to run by hand at any time.
set -u
src="${PI_EXTENSIONS_SRC-/opt/fleet-pi}"
case "$src" in
"" | off | OFF | Off) exit 0 ;;
esac
[ -d "$src" ] || exit 0
agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
dest_dir="$agent_dir/extensions"
for f in "$src"/*.ts; do
[ -f "$f" ] || continue
name="$(basename "$f")"
dest="$dest_dir/$name"
if [ -L "$dest" ]; then
# Ours already, or someone else's deliberate link: either way, nothing to do.
[ "$(readlink "$dest")" = "$f" ] || \
echo "NOTE: $dest links to $(readlink "$dest"), not $f; left alone"
continue
fi
if [ -e "$dest" ]; then
echo "NOTE: $dest is a real file; not linking $f (move it away to use the fleet copy)"
continue
fi
mkdir -p "$dest_dir" 2>/dev/null || true
if ln -s "$f" "$dest" 2>/dev/null; then
echo "pi extension: linked $name -> $f"
else
echo "WARN: could not link $dest -> $f (continuing)"
fi
done
exit 0
+64
View File
@@ -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.
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
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,
@@ -356,8 +356,8 @@ window: 0". Leave the indexing alone in this environment.
## 7. pi-studio reachability (only in the `-studio` variant)
Present only if `/opt/pi-studio` exists / the `studio_*` tools are in your tool
list. pi-studio **binds to `127.0.0.1` inside the container** with no host-bind
flag, so a plain `docker -p` publish can't reach it. Two supported paths:
list. By default pi-studio **binds to `127.0.0.1` inside the container**, so a
plain `docker -p` publish can't reach it. Three supported paths:
- **Host networking** (`network_mode: host`): container loopback == host
loopback; open the tokenized URL on the host. (Changes
@@ -365,10 +365,25 @@ flag, so a plain `docker -p` publish can't reach it. Two supported paths:
- **`studio-expose` bridge** (`STUDIO_EXPOSE=1` or run `studio-expose &`): a
`socat` relay from the container's external interface to its loopback, so a
published `127.0.0.1:PORT` + `ssh -L PORT:127.0.0.1:PORT host` reaches it.
- **`/studio --listen-all`** (pi-studio ≥ 0.9.60): binds `0.0.0.0`, so the
published port reaches it with no bridge; the printed URL still says
`127.0.0.1`. **Never on the same port as the bridge**: whichever starts
second fails with `EADDRINUSE`. `ss -ltnp | grep ":$STUDIO_PORT"` showing a
`socat` line means the bridge is up, so start Studio without `--listen-all`.
The real auth token comes from the `/studio` slash command (`/studio --status`
to reprint), **not** from `studio-expose`. For Graphviz, use `dot-watch` →
PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).
to reprint), **not** from `studio-expose`. Hand over the **whole** printed
URL: since pi-studio 0.10 it also carries `studioTabState` + `hostedWorkspace`
(or `docPath`/`watchFile`/… for `--watch`), and a token-only URL is answered
`409`. The URL wraps across TUI lines; if a side panel's text lands inside the
copy, Studio answers *"This link looks damaged…"*. Studio must run on the
**published** port (only the laptop side of `ssh -L` is free to choose), and
`--port` on a running server only warns, so `/studio --stop` first. On the
Docker host, `curl -s http://127.0.0.1:$STUDIO_PORT/` answering `403` means
the chain is up; an empty reply means Studio is down or on another port.
For Graphviz, use `dot-watch` → PNG (Studio renders Mermaid natively and
previews PNG, but not SVG/DOT).
## 8. MemPalace is the shared brain
@@ -23,11 +23,13 @@ Pi has **two distinct extension locations** and it's easy to look in the wrong o
| 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/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` |
**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.
**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.
+69
View File
@@ -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."
+58
View File
@@ -277,6 +277,46 @@ else
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
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
# shared fleet extensions (link-pi-extensions.sh) — same shape as models.json
# above: checked only when the fleet directory actually holds *.ts, so a device
# without a shared directory is not judged by it.
_le_src="${PI_EXTENSIONS_SRC-/opt/fleet-pi}"
case "$_le_src" in "" | off | OFF | Off) _le_src="" ;; esac
if [ -n "$_le_src" ] && ls -1 "$_le_src"/*.ts >/dev/null 2>&1; then
for _le_f in "$_le_src"/*.ts; do
_le_name="$(basename "$_le_f")"; _le_dest="$HOME/.pi/agent/extensions/$_le_name"
if [ -L "$_le_dest" ] && [ "$(readlink "$_le_dest")" = "$_le_f" ]; then
pass "~/.pi/agent/extensions/$_le_name -> $_le_f (shared fleet extension)"
elif [ -L "$_le_dest" ]; then
warn "~/.pi/agent/extensions/$_le_name links to $(readlink "$_le_dest"), not the mounted $_le_f"
elif [ -e "$_le_dest" ]; then
warn "~/.pi/agent/extensions/$_le_name is a real file, so the mounted $_le_f is NOT in use"
else
fail "$_le_src holds $_le_name but ~/.pi/agent/extensions/$_le_name is missing (image without link-pi-extensions.sh? run: ln -s $_le_f ~/.pi/agent/extensions/)"
fi
done
unset _le_f _le_name _le_dest
fi
unset _le_src
# extensions deployed (pi-extensions) — expect ≥4 *.ts
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
if [ "$EXT_COUNT" -ge 4 ]; then
@@ -285,6 +325,24 @@ else
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
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
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
+60 -8
View File
@@ -30,7 +30,8 @@
# client bundle present + registered via `pi install`
# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host
# - 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
set -euo pipefail
@@ -672,6 +673,9 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
exec_test "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 "link-pi-extensions.sh links a mounted *.ts" 'mkdir -p /tmp/lpe/src && echo "export default {}" > /tmp/lpe/src/x.ts && PI_EXTENSIONS_SRC=/tmp/lpe/src PI_CODING_AGENT_DIR=/tmp/lpe/agent bash /usr/local/lib/pi-devbox/link-pi-extensions.sh >/dev/null && test "$(readlink /tmp/lpe/agent/extensions/x.ts)" = /tmp/lpe/src/x.ts && rm -rf /tmp/lpe'
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
@@ -983,14 +987,62 @@ run "no build-time npm cache shipped (/root/.npm)" \
# 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
# 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
# binding at require() time — so a successful load IS the proof that the kept
# platform package is the one this image needs.
run "clipboard native loads at every install site" \
'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'
# Clipboard is the family pruned second. Upstream pi 0.86.0 (#9163) REPLACED the
# external `@mariozechner/clipboard` dependency with bundled macOS/Windows/X11
# helpers plus the OSC 52 fallback, so from 0.86.0 on there is nothing external to
# prune and nothing to require(). pi 0.85.1 declared it; 0.87.1 and 1.0.0 do not.
# 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 ────────────────────────────────────────────────────────
echo ""
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# test-link-pi-extensions.sh — two-sided tests for
# rootfs/usr/local/lib/pi-devbox/link-pi-extensions.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 — and one of them (a real
# extension of the same name wins) is what keeps a user's own file safe.
#
# bash scripts/test-link-pi-extensions.sh # CI (lint.yml) and locally
# LINK_PI_EXTENSIONS=/path/to/variant.sh bash scripts/test-link-pi-extensions.sh
set -u
here="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="${LINK_PI_EXTENSIONS:-$here/rootfs/usr/local/lib/pi-devbox/link-pi-extensions.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: a shared dir holding two extensions (plus files that
# are NOT extensions), an empty HOME, agent dir unset.
world() {
rm -rf "$T/w"; mkdir -p "$T/w/shared" "$T/w/home"
printf 'export default {}\n' > "$T/w/shared/alpha.ts"
printf 'export default {}\n' > "$T/w/shared/beta.ts"
printf '{"providers":{}}\n' > "$T/w/shared/models.json"
printf 'not an extension\n' > "$T/w/shared/README.md"
SRC="$T/w/shared"; AG="$T/w/home/.pi/agent"; DEST="$AG/extensions"
}
# 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_EXTENSIONS_SRC` 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-extensions.sh ($SCRIPT)"
world; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ] \
&& [ "$(readlink "$DEST/beta.ts")" = "$SRC/beta.ts" ] && [[ "$OUT" == *"linked alpha.ts"* ]]; then
ok "links every top-level *.ts when the shared dir exists and nothing is there (creates the extensions dir)"
else bad "link when absent" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ -z "$OUT" ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ]; then
ok "second run is a silent no-op (every container start runs it)"
else bad "idempotent" "rc=$RC out=[$OUT]"; fi
world; run PI_EXTENSIONS_SRC="$SRC"
if [ ! -e "$DEST/models.json" ] && [ ! -L "$DEST/models.json" ] \
&& [ ! -e "$DEST/README.md" ] && [ ! -L "$DEST/README.md" ]; then
ok "non-.ts files in the shared dir are ignored (models.json, README.md)"
else bad "non-.ts ignored" "dest holds: $(ls -A "$DEST" 2>&1 | tr '\n' ' ')"; fi
# A nested .ts must NOT be linked: the dir is fleet pi config, not an extension
# tree, and loading something nobody intended to publish as an extension is worse
# than missing one.
world; mkdir -p "$SRC/nested"; printf 'export default {}\n' > "$SRC/nested/gamma.ts"; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ ! -e "$DEST/gamma.ts" ] && [ ! -L "$DEST/gamma.ts" ]; then
ok "does not recurse — a nested *.ts is left alone (only top level is the contract)"
else bad "no recursion" "rc=$RC found=$(readlink "$DEST/gamma.ts" 2>&1)"; fi
world; mkdir -p "$SRC/dir.ts"; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ ! -e "$DEST/dir.ts" ] && [ ! -L "$DEST/dir.ts" ]; then
ok "a directory whose name ends in .ts is not linked"
else bad "dir named *.ts" "rc=$RC out=[$OUT]"; fi
world; mkdir -p "$DEST"; printf 'my own\n' > "$DEST/alpha.ts"; before="$(sha256sum "$DEST/alpha.ts")"; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ ! -L "$DEST/alpha.ts" ] && [ "$(sha256sum "$DEST/alpha.ts")" = "$before" ] \
&& [[ "$OUT" == NOTE*"real file"* ]] && [ "$(readlink "$DEST/beta.ts")" = "$SRC/beta.ts" ]; then
ok "never replaces the user's own extension of that name (content unchanged, one NOTE line) — and still links the others"
else bad "real file kept" "rc=$RC islink=$([ -L "$DEST/alpha.ts" ] && echo y) out=[$OUT]"; fi
world; mkdir -p "$DEST"; ln -s /somewhere/else.ts "$DEST/alpha.ts"; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts")" = /somewhere/else.ts ] && [[ "$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/alpha.ts") out=[$OUT]"; fi
world; rm -rf "$SRC"; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "no shared dir → creates nothing and says nothing (the default for most users)"
else bad "absent source" "rc=$RC out=[$OUT]"; fi
world; rm -f "$SRC"/*.ts; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "a shared dir with no *.ts → creates nothing (the dir is not treated as an extension itself)"
else bad "empty source dir" "rc=$RC out=[$OUT]"; fi
for v in off OFF ""; do
world; run PI_EXTENSIONS_SRC="$v"
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "PI_EXTENSIONS_SRC='$v' disables it"
else bad "disable '$v'" "rc=$RC out=[$OUT]"; fi
done
world; run PI_EXTENSIONS_SRC="$SRC"; rm -f "$SRC"/alpha.ts; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ -L "$DEST/alpha.ts" ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ] && [ -z "$OUT" ]; then
ok "our link whose target vanished (mount removed) is left as is, silently"
else bad "dangling own link" "rc=$RC out=[$OUT]"; fi
# pi honours PI_CODING_AGENT_DIR, so the links must land THERE, not in $HOME/.pi/agent.
world; AG="$T/w/elsewhere/agent"; DEST="$AG/extensions"; run PI_EXTENSIONS_SRC="$SRC"
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts" 2>/dev/null)" = "$SRC/alpha.ts" ] \
&& [ ! -e "$T/w/home/.pi/agent/extensions" ]; 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/alpha.ts" 2>&1) out=[$OUT]"; fi
# 'off' must be a switch, not merely a path that happens not to exist: with a
# directory literally named "off" in the working directory it must still disable.
world; mkdir -p "$T/w/off"; printf 'export default {}\n' > "$T/w/off/alpha.ts"
OUT="$(cd "$T/w" && env PI_EXTENSIONS_SRC=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_EXTENSIONS_SRC=off disables it even when a directory named 'off' exists in the cwd"
else bad "off is a switch" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
# The default is /opt/fleet-pi — the same dir that holds models.json. Whether it
# exists, and whether it holds any *.ts, depends on where this runs (a fleet devbox:
# maybe both; CI: neither), so derive the expectation instead of assuming it.
world; run -u PI_EXTENSIONS_SRC
default_ts="$(ls -1 /opt/fleet-pi/*.ts 2>/dev/null | head -1)"
if [ -n "$default_ts" ]; then
_dn="$(basename "$default_ts")"
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/$_dn" 2>/dev/null)" = "$default_ts" ]; then
ok "PI_EXTENSIONS_SRC unset → default /opt/fleet-pi (here: holds $_dn) is linked"
else bad "default path (with .ts)" "rc=$RC link=$(readlink "$DEST/$_dn" 2>&1) out=[$OUT]"; fi
unset _dn
else
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "PI_EXTENSIONS_SRC unset → default /opt/fleet-pi (absent or has no *.ts here) → nothing"
else bad "default path (no .ts)" "rc=$RC out=[$OUT]"; fi
fi
echo "── $PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]
+100
View File
@@ -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 ]