Compare commits

...

7 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
14 changed files with 709 additions and 62 deletions
+6
View File
@@ -96,6 +96,12 @@ jobs:
# 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
+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`).
+212
View File
@@ -11,6 +11,218 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## 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
+1 -1
View File
@@ -61,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-10-14 (v1.11.1 — rootfs skill floor to pi-extensions 750b2a8, mempalace-toolkit d5a03f4; apt and base tools re-resolve (upstream unchanged since 2026-10-12: uv 0.13.0, nvim 0.12.6, fzf 0.74.5); previous marker 2026-10-12 / v1.11.0)
# 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}" && \
+47 -7
View File
@@ -289,7 +289,35 @@ ARG USER_NAME=developer
# none); a copy of tor-ms22's real ~/.pi/agent (all packages, pi-extensions
# 750b2a8) starts with the same [Extensions] list as on 1.1.0 and no
# [Extension issues]; `pi -p` answers PONG via ERDC in 1.0 s, Bedrock in 5.2 s.
ARG PI_VERSION=1.2.0
#
# 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
@@ -416,9 +444,20 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
# renderers on pi 1.1.0 before tagging (see the PI_VERSION note).
# v1.11.2: unchanged (still the newest tag); painted in both renderers on
# pi 1.2.0 before tagging.
ARG PI_ATELIER_REF=v0.15.0
# 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.15.0
ARG PI_ATELIER_VERSION=v0.16.0
RUN set -e && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
@@ -676,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 ───────────────────────────────────────────
+117 -26
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
@@ -916,6 +986,27 @@ To share one file across devices, keep it in a host directory and mount that
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:
@@ -1198,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.2.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:
@@ -1232,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.2.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi | `1.3.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
| pi-atelier | `v0.15.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.10.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
| 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
+10
View File
@@ -381,6 +381,16 @@ if command -v pi &>/dev/null; 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
@@ -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
+22
View File
@@ -295,6 +295,28 @@ if [ -n "$_mj_src" ] && [ -f "$_mj_src" ]; then
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
+1
View File
@@ -675,6 +675,7 @@ exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/exte
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'
+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 ]