Compare commits

...

6 Commits

Author SHA1 Message Date
Joakim Persson 29b62093f0 v1.8.0: bump pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1, audited
Publish Docker Image / resolve-versions (push) Successful in 12s
Lint / actionlint (push) Successful in 23s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / hadolint (push) Successful in 1m20s
Publish Docker Image / build-base (push) Successful in 41m34s
Publish Docker Image / smoke (push) Failing after 4m41s
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
Publish Docker Image / smoke-studio (push) Failing after 8m22s
Publish Docker Image / build-variant-studio (push) Has been skipped
pi 0.84.2 closes the Amazon Bedrock tool-argument poison pill that v1.6.4
recorded as "Not fixed upstream". pi-ai 0.84.2 adds a recursive
sanitizeBedrockDocument() and applies it at exactly the site that entry named
(dist/api/bedrock-converse-stream.js, line 692 -> 704; upstream PR #7882):

  - toolUse: { ..., input: c.arguments },
  + toolUse: { ..., input: sanitizeBedrockDocument(c.arguments) },

It strips object members whose key is the empty string, recursing through arrays
and nested objects. It runs at request-build time, so it covers the live turn and
a resume alike: a session already bricked by an empty-key tool argument now
replays instead of dying on a Bedrock ValidationException. pi-session-repair is
therefore no longer the recovery path on this image -- it stays useful for older
images and for inspection, since the fix sanitises what is sent, not what was
recorded.

Bumping PI_VERSION is the ONLY way to get that fix: pi publishes an
npm-shrinkwrap.json, so pi 0.84.1 pins pi-ai to exactly 0.84.1 even though its
package.json range (^0.84.1) would admit 0.84.2. Transitive upstream fixes never
leak into this image.

pi-atelier v0.8.1 is the matching companion -- both sides changed fullscreen
input handling within three days. Its only code change (src/split-pane.ts) stops
atelier writing its own 1002h/1006h pair around a sidebar resize under pi's
fullscreen renderer, which had been tearing down the mouse reporting pi itself
enabled and leaving the wheel dead.

Audited against the surfaces the pin policies name:
  - session .jsonl: identical migrateV1ToV2/migrateV2ToV3 ladder
  - node engine floor: unchanged >=22.19.0 (image ships 22.23.2)
  - atelier's three private couplings all intact in pi-tui 0.84.2 --
    class TuiAltScreen extends TuiBase (detected by constructor NAME, so a
    rename would fail silently), inputListeners still `new Set()` at the same
    line 103, own render(width) descriptor still present
  - pi's mouse sequences byte-identical between 0.84.1 and 0.84.2
  - atelier metadata unchanged: engines >=22.19.0, peerDeps >=0.80.7, zero
    runtime deps, so the "no npm install step" note holds

Not proven by execution: CI smoke does not drive the TUI, and 0.84.2 adds a
focused fullscreen search overlay that also participates in input handling. The
pairing is reasoned from the diffs. Worth an alt+a plus a sidebar resize and
wheel scroll in fullscreen on first use.
2026-08-15 00:22:08 +02:00
Joakim Persson cbd7cf5c67 entrypoint: announce the "remote palace, no inbox" skip instead of vanishing
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 1m8s
When MEMPALACE_REMOTE_URL is set but MEMPALACE_PI_SSH_TARGET is not, the feeder
has nowhere to ship staged transcripts, so skipping is correct. The problem was
that the branch was a bare `:` AND the skip happens before the subshell that
writes ~/.pi/agent/mempalace-catchup.log -- so a container in that state
contributed nothing to the palace and left no artifact at all, not even an empty
log, to explain why. It is indistinguishable from a healthy run that had nothing
to file, which is the worst property a memory system can have: the failure looks
exactly like success.

Surfaced while flipping the first client onto the shared palace, where this is
the single most likely way to end up quietly memory-less -- the palace is the
only thing that survives a container recreate.

The notice goes to both the container start output (docker logs) and the log
path anyone debugging looks at first. It names both variables, says what still
works (MCP tools read/write the shared palace; only this container's own
conversations go nowhere), and points at MEMPALACE_FEED=0 for anyone who meant
it -- "HTTPS first, mining later" is a documented interim state, so the notice
has to be silenceable without being ignorable.

Guarded against becoming a startup failure. mkdir -p in a branch that previously
touched no filesystem is a new risk: an unwritable ~/.pi (root-owned volume, a
classic Docker accident) fails under set -e and would abort the entire
entrypoint. It now degrades to stdout-only. Verified all five paths by executing
the extracted block under `set -euo pipefail`: the trap prints and writes the
log; unwritable ~/.pi still exits 0 and still prints; MEMPALACE_FEED=0 stays
completely silent (no message, no file); and both normal-remote and local mode
still background the feeder with no notice.

Two smoke assertions guard against a regression to the silent no-op. They test
the entrypoint as shipped in the image rather than behaviour, because this branch
only runs at container start and a `docker run` one-shot cannot reach it.

entrypoint-user.sh is COPY'd in Dockerfile.base, so this rides the base rebuild
the Unreleased feeder work already needs.
2026-08-13 16:30:55 +02:00
Joakim Persson 7c00dd6001 mempalace: drop the stage ENV pin, fix the shared-server compose
Lint / actionlint (push) Successful in 21s
Lint / hadolint (push) Successful in 29s
The feeder now defaults to <palace-root>/pi-stage upstream, so pinning
MEMPALACE_PI_STAGE into ~/.pi here is unnecessary -- and was actively wrong. It
created a second convention that could still diverge from the palace: keep the
devbox-palace volume, drop devbox-pi-config, and a scoped `mempalace sync`
prunes every conversation drawer, because dedup keys on the staged path. Both
the ENV and the entrypoint export are gone; a comment explains why adding one
back re-introduces the split it was meant to fix.

docker-compose.mempalace.yml was broken on mempalace 3.6.0 in both directions:
  - `--host 0.0.0.0` with no token in the environment makes the server refuse
    to start, crash-looping under `restart: unless-stopped`.
  - Supply a token and the healthcheck's unauthenticated `tools/list` POST 401s,
    marking a perfectly healthy server unhealthy forever.
Now the token is required via ${MEMPALACE_REMOTE_TOKEN:?...} so it fails fast at
`docker compose up` with a readable message, and the healthcheck probes the
deliberately token-free /healthz. The "no authentication of its own" security
note has been stale since 3.6.0 and is replaced with the actual posture
(bearer token + Host pin + Origin allowlist), including why browser-shaped auth
must not be put in front of it.

Dockerfile.base: mempalace-pi-session symlinked onto PATH, with a build-time
`--help` check so a broken feeder fails the image build rather than the first
session.

smoke-test: assert the stage resolves beside the palace (default, and following
$MEMPALACE_PALACE_PATH) instead of asserting the removed ENV pin. The two
behavioural guards -- a synthetic session that must be captured, an abandoned
one that must not be -- are unchanged.

.env.example: recommend `mempalace serve` on the docker0 gateway rather than
`mempalace-mcp --transport http --host 0.0.0.0`, with the two binds to avoid.
2026-08-12 17:04:14 +02:00
Joakim Persson 7649d53f3b .env.example: say why GIT_USER_* must be set
Lint / hadolint (push) Successful in 30s
Lint / actionlint (push) Successful in 59s
Both keys shipped empty with no explanation, so they read as optional. They
are not: ~/.gitconfig is not on a persistent mount, so with these unset every
repo in the container fails "Author identity unknown" on first commit after
every recreate — and an agent asked to commit then infers an identity from
git log and picks the wrong one. Observed today across three repos on one
machine (two wrong-address commits, plus older `pi <pi@devbox>` fossils from
earlier sessions guessing the same way).

Also records that the address is per-machine (corporate vs private), so it
belongs in the per-machine .env rather than a skill or a repo-local override.
2026-08-09 15:42:29 +02:00
joakimp ade58131d6 docs(readme): use mkdir -p instead of install -d for the peer's ~/.ssh
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 23s
`install -d -m 700 ~/.ssh` was correct but wrong for the audience. This snippet
gets pasted onto an arbitrary peer — a NAS, a router, a BSD box — and `install`
is not in POSIX, so it is not guaranteed to be there. `mkdir -p` + `chmod` is
POSIX, present everywhere, and self-evidently idempotent to a reader deciding
whether it is safe to run on a machine that already has keys.

Behaviour was checked rather than assumed, on both coreutils and BSD/macOS
`install`: on an existing ~/.ssh it exits 0 and leaves authorized_keys intact
in content and mode, but it also silently chmods the directory (755 -> 700).
Desirable here, yet invisible in a doc — which is the second reason to prefer
the explicit two-step form, and why the surrounding text now states outright
that re-running is safe on an already-configured peer: mkdir -p is a no-op, the
chmods only tighten, and appending never touches keys already listed.

Verified the whole block end-to-end against a fresh HOME and one with a
pre-existing 755 ~/.ssh and an older key: 700/600 in both cases, older key
preserved, new line appended.
2026-08-08 01:24:51 +02:00
joakimp ffd44ad9cf docs(readme): say where the authorized_keys line actually goes
Lint / actionlint (push) Successful in 13s
Lint / hadolint (push) Successful in 13s
Step 2 of "Giving the container its own key for a peer" showed a correctly
narrowed authorized_keys line but never named the file it belongs in, and never
said which account's — the only mention of authorized_keys was an aside 35
lines further down about revoking one key per machine. A reader following the
steps had a public key, a line to construct, and nowhere to put it.

Now explicit: append to ~/.ssh/authorized_keys of the account named as `User`
in step 3, via a heredoc that shows `>>` rather than `>` (the truncation that
revokes every other key on that account), with `install -d -m 700 ~/.ssh` and
`chmod 600` so the file is created correctly the first time.

Adds the three failure modes that make a good key look broken, none of which
announce themselves on the client: the options prefix must be on the same
physical line as the key (a wrapped paste is the usual culprit), `ssh-copy-id`
cannot add that prefix at all so the line must be appended by hand, and sshd's
StrictModes silently ignores authorized_keys when the home directory, ~/.ssh or
the file is group- or world-writable — reporting it only in the peer's own log.
2026-08-08 01:17:48 +02:00
8 changed files with 486 additions and 20 deletions
+53 -3
View File
@@ -12,16 +12,59 @@ SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ───────────────────────────
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
# docker-compose.yml to persist it across container recreation.
# docker-compose.yml to persist it across container recreation — that one
# volume now covers the mined conversation transcripts too, since the pi and
# opencode feeders stage inside the palace root (<palace-root>/pi-stage), so
# the staged files and the palace dedup keys pointing at them cannot be
# separated.
#
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
# + native), set the URL below. When set, the extension connects over HTTP
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
#
# Serve it with: mempalace serve --host 172.17.0.1 --port 8765
#
# NOT `mempalace-mcp --transport http --host 0.0.0.0`: `serve` is the turnkey
# wrapper that mints/keeps a bearer token (0600, passed via env so it stays out
# of `ps`) and can terminate TLS. Two binds to avoid:
# 0.0.0.0 - exposes the palace to the whole LAN.
# 127.0.0.1 - behind a tunnel this 403s every proxied request (the Host pin
# is only enforced on loopback binds) AND silently starts with
# no token at all, since auto-minting is gated on the bind being
# non-loopback. Bind the docker0 gateway: reachable from the host
# and its containers (so a newt/proxy container works), not from
# the LAN. Set MEMPALACE_MCP_HTTP_TOKEN explicitly server-side.
# MEMPALACE_REMOTE_URL=https://mempalace.example.com/mcp
# MEMPALACE_REMOTE_TOKEN=
# ── MemPalace: automatic capture of pi sessions ───────────────────────
# The mempalace.ts extension feeds this container's pi transcripts into the
# palace by itself: on session_shutdown, and on a debounced agent_settled so a
# crash loses at most one window rather than the whole session. The entrypoint
# also runs a catch-up at container start, which is the only thing that can
# recover transcripts after a hard kill (no handler runs on SIGKILL).
# Nothing below is required for the local-palace case; the defaults work.
#
# MEMPALACE_FEED=0 # disable automatic capture entirely
# MEMPALACE_FEED_DEBOUNCE_MS=600000 # min gap between mid-session feeds (10 min)
# MEMPALACE_FEED_WING=wing_conversations
#
# REMOTE PALACE ONLY (MEMPALACE_REMOTE_URL set above): the palace is on another
# host, and `mempalace_mine` resolves its source path in the SERVER process, so
# the server cannot see this container's transcripts. The feeder therefore
# rsyncs its staged exports into a per-device inbox on the palace host and asks
# the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the
# feeder is skipped (a remote palace with no inbox has nothing to mine).
# MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path
# MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — must be
# the container path if the server runs in Docker
# (see docker-compose.mempalace.yml)
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
# MEMPALACE_PI_REMOTE_PATH=/data/feed
# MEMPALACE_PI_DEVICE=
# ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint
@@ -52,6 +95,13 @@ SSH_KEY_PATH=~/.ssh
# DEVBOX_ATELIER=1
# ── Git Configuration ────────────────────────────────────────────────
# Set BOTH. If unset, every repo inside the container fails with
# "Author identity unknown" on first commit, and an agent asked to commit
# will guess an identity from git log — often the wrong one. The e-mail is
# per-machine (work machines use the corporate address, personal machines the
# private one), so it belongs in this per-machine .env, never in a skill or a
# repo-local override. Consumed by entrypoint-user.sh -> ~/.gitconfig, which is
# NOT persistent across container recreate — this file is the source of truth.
GIT_USER_NAME=
GIT_USER_EMAIL=
+227
View File
@@ -11,6 +11,233 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## v1.8.0 — 2026-08-15
Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The
image already shipped `mempalace-toolkit`, but its pi feeder
(`mempalace-pi-session`) was never symlinked onto `PATH`, so nothing ever mined
pi's transcripts — the palace only ever contained what an agent remembered to
file by hand. A container that gets recreated regularly has no other memory, so
a missed wind-down was a permanently lost session.
Also here: **pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1**, bumped
together. The pi bump closes the Amazon Bedrock tool-argument poison pill that
v1.6.4 recorded as unfixed upstream; the atelier bump is the matching companion,
since both sides changed fullscreen input handling in the same fortnight. Audits
for both are below.
*Why event-driven and not a timer:* there is nothing schedulable inside the
container — PID 1 is `bash -l`, with no systemd and no cron — and anything
installed would not survive recreate anyway. The triggers therefore live where
the events already are: pi's own lifecycle, plus container start.
### Added
- **`mempalace-pi-session` symlinked onto `PATH`** (`Dockerfile.base`,
alongside its `mempalace-session` / `mempalace-docs` siblings, with the same
`--help` build-time check). `entrypoint-user.sh` also self-heals the symlink
into `~/.local/bin` (already ahead of `/usr/local/bin` on `PATH`, and
writable by `developer`) so the feature works on images whose base predates
this change.
- **Container-start catch-up feed** (`entrypoint-user.sh`, backgrounded). pi's
mempalace extension feeds the palace on `session_shutdown` and on a debounced
`agent_settled`, but a hard kill (`docker kill`, OOM, host reboot) runs no
handler at all; this is the only trigger that can recover the previous life's
transcripts. Skipped when a remote palace is configured without an inbox to
ship to — **and the skip now says so** (see Changed) — and skippable entirely
with `MEMPALACE_FEED=0`.
- **`MEMPALACE_PI_STAGE` no longer needs pinning here — the feeder's default
was fixed upstream instead.** It used to stage under `~/.cache`, which is
disposable in a container; the first cut of this change pinned the env var
into the persisted `~/.pi` volume. That was the wrong fix: it created a second
convention that could still diverge from the palace (keep the palace volume,
drop `devbox-pi-config`, and a scoped `mempalace sync` prunes every
conversation drawer, because dedup keys on the *staged* path). The feeder now
defaults to `<palace-root>/pi-stage`, resolved with mempalace's own
precedence (`$MEMPALACE_PALACE_PATH``$MEMPAL_PALACE_PATH`
`~/.mempalace/config.json``~/.mempalace/palace`), so the stage inherits
whatever persistence the palace has and the two cannot be separated by
accident. No `ENV` and no entrypoint export: adding one back would
re-introduce exactly the split it removes.
- **Transcript inbox mount in `docker-compose.mempalace.yml`**
(`${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro`). A client cannot mine into a
remote palace directly: `mempalace_mine` expands its source path in the
*server* process, so the server can only see paths inside its own container.
Clients rsync their staged exports to a per-device subdirectory and then ask
the server to mine `/data/feed/<device>`. Read-only because mining only reads
sources — all locks live palace-side.
- **Smoke tests** for the above: `mempalace-pi-session` on `PATH`, two
assertions that the stage resolves next to the palace (default, and following
`$MEMPALACE_PALACE_PATH`), and two behavioural guards that feed the exporter a
synthetic pi session — one that must be captured, one abandoned session that
must not be. The second matters because pi expands skills/context into the
user prompt, so an abandoned session can look substantial by byte count while
containing no assistant output; and if pi's JSONL shape ever changes, the
exporter would silently capture nothing.
- **`.env.example`**: documents `MEMPALACE_FEED`,
`MEMPALACE_FEED_DEBOUNCE_MS`, `MEMPALACE_FEED_WING`, and the remote-palace
shipping vars `MEMPALACE_PI_SSH_TARGET`, `MEMPALACE_PI_REMOTE_PATH`,
`MEMPALACE_PI_DEVICE`.
### Changed
- **The "remote palace, no inbox" skip announces itself instead of vanishing**
(`entrypoint-user.sh`). When `MEMPALACE_REMOTE_URL` is set but
`MEMPALACE_PI_SSH_TARGET` is not, there is genuinely nothing the feeder can
ship to, so skipping is correct — but the branch was a bare `:`, and the skip
happens *before* the subshell that writes `~/.pi/agent/mempalace-catchup.log`.
A container in that state therefore contributed nothing to the palace and left
**no artifact at all**, not even an empty log, to explain why — indistinguish-
able from a healthy run that had nothing to file. Found while flipping the
first client onto the shared palace (2026-08-12), where it is the single most
likely way to end up quietly memory-less. The notice now goes to both the
container start output and that log path, names the two variables that fix it,
states that MCP tools still work (only *this* container's transcripts go
nowhere), and points at `MEMPALACE_FEED=0` for anyone who meant it.
Deliberately incapable of breaking startup: an unwritable `~/.pi` — root-owned
volume, a classic Docker accident — would make `mkdir -p` fail under `set -e`
and abort the whole entrypoint, so it degrades to stdout-only. That was a real
new risk, since this branch previously touched no filesystem whatsoever.
Covered by two smoke assertions against the entrypoint as shipped in the image
(the branch only runs at container start, so a `docker run` one-shot cannot
reach it).
### Bumped: pi 0.84.1 → 0.84.2
- **`ARG PI_VERSION=0.84.2`** (`Dockerfile.variant`), with the audit the pin
policy in that file requires.
**Headline for this image: the Bedrock tool-argument poison pill is FIXED
upstream.** The v1.6.4 entry below recorded it as *"Not fixed upstream … still
replayed unsanitised"* — that note is now superseded. pi-ai 0.84.2 adds a
recursive `sanitizeBedrockDocument()` and applies it at exactly the site that
entry named ([#7882](https://github.com/earendil-works/pi/pull/7882)):
```diff
- toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
+ toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) },
```
(`dist/api/bedrock-converse-stream.js` — line 692 in pi-ai 0.84.1, 704 in
0.84.2; it was 644 in 0.83.0 and 634 in 0.82.1.) The sanitiser drops object
members whose key is the empty string, recursing through arrays and nested
objects and preserving every valid value. It runs while the request is built,
so it covers the live turn *and* a resume: a session already bricked by an
empty-key tool argument now replays instead of dying on a Bedrock
`ValidationException`. **`pi-session-repair` (in `cli_utils`) is therefore no
longer the recovery path on this image.** It stays useful for older images and
for inspecting a transcript, because the stored `.jsonl` is still malformed —
the fix sanitises what is *sent*, not what was *recorded*.
**Why bumping `PI_VERSION` is the only way to get it:** pi publishes an
`npm-shrinkwrap.json`, which pins transitive dependencies *exactly*. pi
0.84.1's shrinkwrap pins `@earendil-works/pi-ai` to **0.84.1**, so although
0.84.1's `package.json` range is `^0.84.1` — which would otherwise admit
0.84.2 — rebuilding the old pin can never pick the fix up. Transitive upstream
fixes do not leak into this image; `PI_VERSION` is the whole gate.
Rest of the audit, against the integration surface the pin policy names:
- **Session `.jsonl` format — unchanged.** Identical
`migrateV1ToV2`/`migrateV2ToV3` ladder in both versions, so existing sessions
on the named volume load as-is and `pi-session-repair`'s parse target is
untouched.
- **Node engine floor — unchanged** at `>=22.19.0` (image ships 22.23.2).
- **pi-atelier — no change needed.** The pin stays `v0.8.0`: the hard floor is
"never pair < 0.7.1 with pi >= 0.84", this bump does not leave 0.84.x, and
atelier's `peerDependencies` (`>=0.80.7`) are satisfied. pi-atelier **0.8.1**
is published but deliberately NOT adopted here — one variable at a time, and
atelier is the component that has drawn blood at startup.
- **Directly relevant to `pi --ssh` use of this image:** 0.84.2 fixes split
`Alt+Enter` over SSH being misread as Escape, and adds `PI_TUI_ESC_TIMEOUT`
for high-latency terminals.
- **Keybindings — one surface worth knowing.** `pi-toolkit` ships exactly one
override, `tui.input.newLine: [shift+enter, ctrl+j, alt+j]`. 0.84.2's new
fullscreen transcript search (`Ctrl+Shift+F`) binds `Shift+Enter` to
*previous match* while its overlay is focused. Different context, so no
conflict is expected — but it is the one place the override meets a new
default, and the first place to look if "shift+enter stopped inserting a
newline" is ever reported.
- **New `defaultTools` setting** (choose startup built-in tools globally or per
project) is additive; `pi-toolkit`'s `settings.example.json` does not set it,
so the bootstrap template needs no change.
### Bumped: pi-atelier v0.8.0 → v0.8.1
- **`ARG PI_ATELIER_REF` / `ARG PI_ATELIER_VERSION` = `v0.8.1`**
(`Dockerfile.variant`), bumped *together* with `PI_VERSION` as that pin's
comment requires — and this pairing is a good advert for the rule, because both
sides touched fullscreen input handling within three days of each other.
atelier 0.8.1 (2026-08-12) is two changes, only one of them code: *"Preserve
fullscreen transcript mouse-wheel scrolling after Sidebar resize and visibility
changes by leaving Pi's persistent mouse reporting enabled"*, plus a README
simplification. The single source file that differs from 0.8.0 is
`src/split-pane.ts`. It extracts an `isPiFullscreenRenderer()` predicate and,
under pi's fullscreen renderer, stops writing its own
`\e[?1002h\e[?1006h` / `\e[?1006l\e[?1002l` pair around a sidebar resize —
previously it enabled mouse reporting on grab and disabled it on release, which
tore down the reporting **pi itself** had switched on and left the wheel dead
afterwards. Outside fullscreen it manages mouse mode exactly as before. It also
now captures the terminal it enabled mouse on and writes the disable sequence to
*that* terminal instead of to whatever `tui` currently points at.
**The audit that matters is the private-internals coupling**, since that is what
hung startup at 0.6.0/0.7.0. atelier reaches into three pi internals; all three
are unchanged in pi 0.84.2:
- **`TuiAltScreen`** — detected *by constructor name*, so a rename would
silently disable both the resize-input prioritisation and the new mouse
behaviour, with no error. Still
`class TuiAltScreen extends TuiBase implements ViewportTUI`.
- **`tui.inputListeners`** — a private `Set` that atelier deletes from and
re-adds to, to get its resize handler ahead of pi's viewport listener (which
"consumes every mouse event for text selection"). Still `inputListeners = new
Set()`, at the identical line 103 of `pi-tui/dist/tui.js` in both versions,
and still a `Set` — atelier guards with `instanceof Set`.
- **the prototype `render` descriptor** it wraps via `findPrototypeRender`.
Still an own `render(width)` on `TuiAltScreen`.
pi's mouse sequences are byte-identical between 0.84.1 and 0.84.2 (same
`1002h`/`1006h`/`1002l`/`1006l`/`1003h` occurrence counts), so atelier's
assumption about what pi leaves enabled still holds. `pi-tui`'s base class
changed additively only (one new `isOverlayFocused()`), and `TuiAltScreen`'s own
changes are the new search feature (`activeSearch`, `openSearch`/`closeSearch`,
the two search match styles, `copySelection`).
**Caveat, stated plainly:** pi 0.84.2 adds a focused fullscreen *search overlay*
that participates in input handling, while atelier reorders input listeners
around pi's viewport listener. The two look convergent — 0.84.2 separately fixes
*"focused fullscreen overlays not receiving mouse wheel or viewport scroll
keys"* — but this pairing is reasoned from the diffs, **not proven by
execution**: the CI smoke test does not drive the TUI, so a fullscreen
interaction regression would not be caught before pull. Worth an `alt+a` plus a
sidebar resize and a wheel scroll in fullscreen on first use of this image.
Version metadata is unchanged: `engines.node >=22.19.0`, `peerDependencies`
still the uninformative `>=0.80.7` on both pi packages (so still nothing in npm
metadata encodes the real floor), and still zero runtime dependencies — so the
"no `npm install` step" note above stays true. The GitHub tag `v0.8.1` exists
(commit `c31d7439`), which is what CI resolves to a SHA.
### Notes
- The `Dockerfile.base` change moves the base hash, so this needs a base
rebuild; the `~/.local/bin` self-heal exists so the feature does not have to
wait for one. The skip-notice change is in `entrypoint-user.sh`, which is
`COPY`d in `Dockerfile.base` too, so it rides the same rebuild — until then,
older images keep skipping silently and the two commands in the toolkit's
`phase-1-exposure-runbook.md` §3.7 are the way to tell.
- Requires the matching `mempalace-toolkit` change (`--prepare` two-phase
split, remote transport, and the auto-feed triggers in
`extensions/pi/mempalace.ts`). The split exists because the palace is
single-writer: a live pi session holds it through the extension's own
`mempalace-mcp`, so a CLI `mempalace mine` during a session fails with
"palace ... is held by PID". Staging is therefore done by the CLI and the
mine itself by whichever process already holds the palace.
---
## v1.7.0 — 2026-08-07
Minor release. Headline: **pi-atelier is now part of the image** — the TUI
+4 -1
View File
@@ -424,9 +424,12 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
[ "$ok" = "1" ] && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
/opt/mempalace-toolkit/bin/mempalace-pi-session && \
mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \
mempalace-pi-session --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi
+3 -3
View File
@@ -56,7 +56,7 @@ ARG USER_NAME=developer
# current when it was first populated (shipped the same bytes for pi-devbox
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
# branch below is kept only for a deliberate local `docker build` override.
ARG PI_VERSION=0.84.1
ARG PI_VERSION=0.84.2
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
@@ -92,9 +92,9 @@ ARG PI_OBSMEM_REF=master
# the /opt checkout. Adding an install here would be a no-op that only costs
# build time.
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
ARG PI_ATELIER_REF=v0.8.0
ARG PI_ATELIER_REF=v0.8.1
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
ARG PI_ATELIER_VERSION=v0.8.0
ARG PI_ATELIER_VERSION=v0.8.1
RUN set -e && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
+23 -4
View File
@@ -882,12 +882,31 @@ cat ~/.ssh-local/mypeer_devbox_ed25519.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7
```
**2. On the peer** — authorize it narrowly rather than bare:
**2. On the peer** — append that public key to `~/.ssh/authorized_keys` **of
the account you will log in as** (the `User` from step 3), narrowly authorized
rather than bare:
```
```bash
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys <<'KEY'
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
KEY
chmod 600 ~/.ssh/authorized_keys
```
Both lines are safe on a peer that is already set up: `mkdir -p` is a no-op
when the directory exists, the `chmod`s only tighten, and appending never
touches keys already listed. Use `>>`, never `>` — one stray truncation
revokes every other key on that account. The options prefix must sit on the
**same physical line** as the key, comma-separated with no spaces: a paste
that wrapped is the likeliest reason a key that looks right is refused.
`ssh-copy-id` cannot add that prefix, so append by hand (or let it copy the
bare key and edit the line afterwards). If authentication still fails with no
clear reason, suspect permissions — sshd's `StrictModes` silently ignores
`authorized_keys` when the home directory, `~/.ssh` or the file itself is
group- or world-writable, and says why only in the peer's own log
(`journalctl -u ssh`, `/var/log/auth.log`).
`restrict` disables pty, agent/X11 and port forwarding; append
`port-forwarding` and `permitopen="127.0.0.1:<port>"` after it if you need one
specific tunnel. `from=` must list the **host's** addresses, not the
@@ -970,8 +989,8 @@ resolved to `latest` at build time:
| Component | Pin | Where |
|---|---|---|
| pi | `0.84.1` | `ARG PI_VERSION``Dockerfile.variant` |
| pi-atelier | `v0.8.0` | `ARG PI_ATELIER_REF``Dockerfile.variant` |
| pi | `0.84.2` | `ARG PI_VERSION``Dockerfile.variant` |
| pi-atelier | `v0.8.1` | `ARG PI_ATELIER_REF``Dockerfile.variant` |
| mempalace | `3.6.0` | `ARG MEMPALACE_VERSION``Dockerfile.base` |
The objective is **not** to freeze versions. Bumping is routine — usually one
+36 -9
View File
@@ -5,6 +5,7 @@
# Point every client at it by setting, in that client's .env:
#
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
# MEMPALACE_REMOTE_TOKEN=<the shared bearer token>
#
# (see .env.example). When set, the client connects over HTTP and does NOT
# spawn its own local mempalace-mcp.
@@ -18,12 +19,21 @@
# (both are pinned by the same image build). Override with a slimmer image via
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
#
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
# Do NOT expose port 8765 to an untrusted network. The default below binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
# attach them to the shared `mempalace-net` network (container-to-container, no
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
# ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
# earlier version of this comment said otherwise and was wrong. The server
# compares `Authorization: Bearer <token>` with hmac.compare_digest and
# **refuses to start on a non-loopback bind without a token**, so
# MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
# service crash-loops. It also pins `Host` and allowlists `Origin`.
#
# Still do not publish port 8765 to an untrusted network. The default binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, attach
# them to the shared `mempalace-net` network (container-to-container, no host
# port needed — use http://mempalace-server:8765/mcp). To reach it from
# elsewhere, terminate TLS in a tunnel/reverse proxy and let the bearer token be
# the authentication — do NOT add browser-shaped auth (SSO/PIN/password) in
# front, because every MCP client here is a headless JSON-RPC POST and would
# receive a login page where JSON should be.
name: mempalace-server
@@ -40,6 +50,11 @@ services:
user: "0:0"
environment:
- HOME=/data
# Required: mempalace refuses a non-loopback bind without a token (it
# would exit at startup and, with restart:unless-stopped, crash-loop).
# `:?` fails fast at `docker compose up` with a readable message instead.
# Clients send the same value as MEMPALACE_REMOTE_TOKEN.
- MEMPALACE_MCP_HTTP_TOKEN=${MEMPALACE_REMOTE_TOKEN:?set MEMPALACE_REMOTE_TOKEN in .env — the shared palace requires a bearer token}
command:
- mempalace-mcp
- --transport
@@ -60,16 +75,28 @@ services:
- mempalace-shared:/data/.mempalace
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
- mempalace-shared-chroma:/data/.cache/chroma
# Transcript inbox. Clients cannot mine into a remote palace directly:
# `mempalace_mine` expands its source path in THIS process, so it can only
# see paths inside this container. Each client rsyncs its staged session
# exports to a per-device subdirectory on the host (see
# MEMPALACE_PI_SSH_TARGET in .env.example) and then calls mempalace_mine
# with the container-side path below (MEMPALACE_PI_REMOTE_PATH=/data/feed).
# Read-only: mining only reads sources, and all locks live palace-side.
- ${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro
networks:
- mempalace-net
healthcheck:
# A tools/list round-trip proves the server is answering MCP (python3 is
# always present — mempalace itself is a python tool in the image).
# GET /healthz, which is Host/Origin-gated but deliberately token-free —
# so this probe needs no credentials. Do NOT go back to POSTing
# `tools/list` here: that carries no Authorization header and now 401s,
# marking a perfectly healthy server unhealthy forever. The Host pin is
# only enforced on loopback *binds* (this one is 0.0.0.0), so a request to
# 127.0.0.1 inside the container passes.
test:
- CMD
- python3
- -c
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8765/healthz',timeout=5).status==200 else 1)"
interval: 30s
timeout: 10s
retries: 3
+76
View File
@@ -92,6 +92,82 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
fi
fi
# ── MemPalace: pi transcript feeder ─────────────────────────────────
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
# session transcripts into the palace. pi's mempalace extension drives it on
# session_shutdown and on a debounced agent_settled; this is the catch-up for
# the one case no handler can cover — a hard kill (docker kill, OOM, host
# reboot) runs nothing at all, so without this the previous life's transcripts
# are never mined.
#
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
# referencing it share one lifetime — whatever persistence the palace has, the
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
# re-introduce the very split that design prevents: palace volume kept, stage
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
#
# Backgrounded: a cold mine can take tens of seconds and must never delay the
# shell. Contention with a live session is handled by the tool itself (it exits
# 0 and lets the palace holder do the mine).
# Self-heal onto PATH for images whose base predates the toolkit symlink.
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
! command -v mempalace-pi-session >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
fi
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
# login shell, and a silently-skipped catch-up is exactly the failure we are
# here to prevent.
MEMPALACE_FEEDER=""
if command -v mempalace-pi-session >/dev/null 2>&1; then
MEMPALACE_FEEDER="mempalace-pi-session"
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
fi
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
# Remote palace, but no inbox to ship transcripts to — the feeder genuinely
# cannot do anything here, so skipping is right. Saying so is the point:
# this branch used to be a bare `:`, and the skip happens *before* the
# subshell below that writes mempalace-catchup.log, so a container in this
# state contributed nothing to the palace and left no artifact at all — not
# even an empty log — to explain why. That is indistinguishable from a
# healthy run that simply had nothing to file. `tee` puts the notice both in
# the container's start output (docker logs) and at the path anyone
# debugging "why is nothing from this container in the palace?" looks first.
# This is an entrypoint: a notice must never be able to stop a container
# from starting. An unwritable ~/.pi (root-owned volume — a classic Docker
# permission accident) makes `mkdir -p` fail, and under `set -e` that would
# abort startup entirely: a brand-new failure mode in precisely the branch
# that used to do nothing at all. Degrade to stdout-only instead.
_mp_log="$HOME/.pi/agent/mempalace-catchup.log"
mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null
{
echo "MemPalace catch-up skipped: remote palace with no transcript inbox."
echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})"
echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this"
echo " container's staged sessions. MCP tools still read and write the shared"
echo " palace — but this container's own conversations are mined nowhere."
echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env,"
echo " or unset MEMPALACE_REMOTE_URL to keep the palace local."
echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this."
} | tee "$_mp_log" 2>/dev/null || true
unset _mp_log
else
mkdir -p "$HOME/.pi/agent"
(
"$MEMPALACE_FEEDER" --reason container-start \
>"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true
) &
fi
fi
# ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
+64
View File
@@ -91,6 +91,70 @@ run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
run "mempalace-mcp" "mempalace-mcp --help"
run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
# The staging dir must sit next to the palace, not in a disposable cache: the
# palace keys per-source dedup on the STAGED path, so a stage that can be wiped
# while the palace survives lets `mempalace sync` prune every drawer mined from
# it. Assert the resolved default, not an env var — the guarantee is "stage
# shares the palace's lifetime", which an ENV pin would quietly break.
# NOTE: --sessions-dir gets an EMPTY temp dir, never /tmp. The stage banner is
# printed before any export, so nothing needs to be found — and pointing a
# default-staged run at a populated dir would export whatever transcripts it
# finds into the real stage, which is how a synthetic test session ends up
# staged for mining as if it were a real conversation.
run "pi stage defaults next to the palace (not a cache dir)" '
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
'
run "pi stage follows MEMPALACE_PALACE_PATH" '
out=$(MEMPALACE_PALACE_PATH=/tmp/alt/.mempalace/palace \
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
'
# Regression guard for the pi transcript exporter. If pi ever changes its
# session JSONL shape, the exporter stops recognising sessions and the palace
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
# synthetic session and assert it is actually exported. Uses --dry-run so no
# palace is touched, and a temp stage so nothing real is written.
run "pi transcript exporter recognises a pi session" '
set -e
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
{
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question one\"}}"
a=$(printf "a%.0s" $(seq 1 1200))
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$a\"}]}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question two\"}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"short reply\"}]}}"
} > "$s/2026-01-01T00-00-00-000Z_smoke.jsonl"
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
echo "$out" | grep -q "Exported 1 session"
'
# The same guard from the other side: a session with no real assistant output
# (an abandoned prompt, whose bulk is injected skill text) must NOT be filed.
run "pi transcript exporter rejects an abandoned session" '
set -e
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
{
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke2\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
u=$(printf "u%.0s" $(seq 1 13000))
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"$u\"}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ready. What would you like to work on?\"}]}}"
} > "$s/2026-01-01T00-00-00-000Z_smoke2.jsonl"
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
echo "$out" | grep -q "no sessions qualified"
'
# The remote-palace-without-inbox skip must ANNOUNCE itself, not vanish. This
# branch of entrypoint-user.sh runs at container start (not reachable from a
# `docker run` one-shot), so assert against the entrypoint that actually shipped
# in the image. Guards a silent regression back to the bare `:` no-op, which
# left a container contributing nothing to the palace with no artifact saying
# why — the log it would normally leave is written by the other branch.
run_expect "remote-palace-without-inbox skip is announced, not silent" \
"grep -o 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | head -1" \
"MemPalace catch-up skipped"
run "...and the skip notice names the variable that fixes it" \
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
# v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version"
run "typst" "typst --version"