@alphacoder-v0/pi-loops 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,1989 @@
1
+ # Changelog
2
+
3
+ All notable changes to pi-loops are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
5
+
6
+ ## [0.17.3] - 2026-09-13
7
+
8
+ ### Fixed
9
+ - **The `/cron` and `/triggers` subcommand menus named some of what exists.** `/cron`'s one-line
10
+ menu — the thing printed when you get the command wrong — listed fourteen subcommands and left
11
+ out `set`, `clear`, `cost`, `gc` and `host`; `/cron help` covered everything except `snapshot`;
12
+ `/triggers` had lost `hooks` and `panel` from its menu *and* its completions, and its `set` clause
13
+ showed three of the four flags the command takes. `/inbox`'s menu had lost `list`. None of this is
14
+ cosmetic: a subcommand missing from every list a person can read is a subcommand nobody finds. The
15
+ menus now name every subcommand and nothing but; because five more names and their argument
16
+ spellings do not fit on a line a notification can show, the menus name the subcommands and
17
+ `<command> help` holds the arguments, which the menus say. `/triggers` had no help behind its
18
+ usage line — `/triggers help` printed the line itself — so it now has a real one (`TRIGGERS_HELP`,
19
+ the shape `/cron help` and `/inbox help` already had), with the argument spellings that used to be
20
+ crammed into the menu and the ones that were never written down anywhere: `hooks`, `panel`,
21
+ `set --host`, `audit --all`, `enable|disable --all-projects`.
22
+ - **A test holds all three lists to the dispatcher.** `test/command-names.test.ts` reads the `case`
23
+ labels out of each `switch (sub)` in `src/pi-loops.ts` and asserts that every non-alias subcommand
24
+ is named in the menu, spelled in the help text and offered in the completions — with the aliases
25
+ (`ls`, `status`, `resume`, `pause`, `rm`, `delete`, the bare command) declared once as deliberate
26
+ omissions. Adding a `case` now fails the suite until every list that claims to describe the
27
+ command is updated. The three lists had drifted because nothing had ever compared them.
28
+
29
+ ## [0.17.2] - 2026-09-12
30
+
31
+ ### Fixed
32
+ - **`cron_remove` told the agent that removal destroys notes it keeps.** Its tool description ended
33
+ "Removal also deletes the job's saved notes and transcripts", and the tool calls `store.remove`
34
+ with no `purge`, which deletes neither. That string is what the agent repeats to you in the
35
+ sentence before it asks you to confirm, so it frightened anyone who wanted their loop's
36
+ accumulated notes and misled anyone who wanted them gone — the state file and the whole run
37
+ transcript directory were still on disk under an id nothing schedules any more. The description
38
+ now says what removal actually costs and names `/cron gc --purge`, and the tool's own answer says
39
+ it too, with the state path in `details` where a tool puts a path. The comment explaining why the
40
+ confirmation gate exists carried the same error and now says what the gate is really about: what
41
+ stops running, not what is destroyed.
42
+ - **`/cron add`'s usage line advertised one flag out of thirteen.** It is printed when you get the
43
+ command wrong, which is the moment you most need to know what exists, and it named only
44
+ `--stateful`. It now also names `--verify` — the other flag that decides what kind of job this is
45
+ rather than what it runs with, and the other one `/cron set` cannot change afterwards — and points
46
+ at `/cron help` for the rest. A test holds the line to the parser, so a flag added later forces a
47
+ decision about whether it belongs there.
48
+
49
+ ### Added
50
+ - **`npm run check:docs`, because nothing in CI read a README.** That is how 0.17.0 was tagged green
51
+ with an install line that answered 404. The check reads every install command in every tracked
52
+ Markdown file back against `package.json`: the pinned tag must be this version, the repository and
53
+ package name must be the ones declared, and a route may only be advertised if the project offers
54
+ it — `piLoops.publishedToNpm` is the single place that says whether npm is a route yet, so the day
55
+ this is published is a one-line change and the check then stops failing rather than needing a
56
+ rewrite. Only fenced blocks count, so prose about pi's own layout (`docs/cli.md` explaining where
57
+ `pi install npm:` packages land) and the changelog's own history stay free. It fails if it finds no
58
+ install commands at all, because a check over nothing is not a check.
59
+
60
+ ## [0.17.1] - 2026-09-12
61
+
62
+ ### Fixed
63
+ - **The install instructions named a package that is not on npm.** 0.17.0 was cut with an npm
64
+ release ready to go and the documents written as though it had happened: both READMEs led their
65
+ install block with `pi install npm:@alphacoder-v0/pi-loops`, the uninstall lines named the npm
66
+ package, and `docs/troubleshooting.md` used it as its example. The publish did not happen, so that
67
+ command answers 404 and the first thing a reader was told to do could not work. GitHub is the
68
+ install route — `pi install git:github.com/alphacoder-v0/pi-loops@<tag>`, a local checkout, or
69
+ `pi -e` for one run — and the documents say only that. The code that reaches an npm layout is
70
+ correct and stays: it is tested, and it is what the scope is reserved for.
71
+
72
+ ## [0.17.0] - 2026-09-12
73
+
74
+ ### Changed
75
+ - **The package is named `@alphacoder-v0/pi-loops`.** The unscoped `pi-loops` on npm is someone
76
+ else's package, so this one takes a scope of its own rather than a name that would collide — the
77
+ scope is reserved, deliberately, for the day this is published. That day is not today: nothing is
78
+ on npm, and `pi install git:github.com/alphacoder-v0/pi-loops@<tag>` remains the way in. Only the
79
+ package name moved: the command is still `pi-loops` and the data still lives in
80
+ `~/.pi/agent/loops/`.
81
+
82
+ ### Fixed
83
+ - **Nothing with a command line worked from an npm install: `pi-loops` and the headless host both
84
+ died on their first import.** Node strips TypeScript types itself, which is how this package gets
85
+ to have no build step — but it refuses to do it for files under `node_modules`
86
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), and `pi install npm:@alphacoder-v0/pi-loops` puts
87
+ the package exactly there. The extension was fine the whole time (pi loads extensions through
88
+ jiti); it was the launcher, `install-launcher`, `upgrade`, `host status`, `export` and `import`
89
+ that were dead, and the host the last pi hands the clock to that could never start. Both entry
90
+ points now ask the path whether Node can strip it and, when it cannot, load the same sources
91
+ through the jiti that ships inside pi's own install — pi is already a peer dependency, so this
92
+ installs nothing and the "no runtime dependencies" rule holds. When that jiti is missing too, the
93
+ message says which pi was looked for and where, instead of a stack trace naming the wrong problem.
94
+ - **`pi-loops upgrade` handed every copy a `git:` spec, whatever it had been installed from.** A
95
+ `pi install` with the other source replaces nothing — it adds a *second* package, both copies
96
+ register `cron_create` and the rest, and pi refuses to load the second one and exits with
97
+ `Tool "cron_create" conflicts with …`. That is exactly the failure both READMEs and
98
+ `docs/troubleshooting.md` warn about, and a copy that came from anywhere but a `git:` tag walked
99
+ into it by doing nothing except upgrading. `upgrade` now reads pi's install layout to see where
100
+ this copy actually came from and offers the matching spec: `npm:@alphacoder-v0/pi-loops@<version>`
101
+ for an npm install (npm knows the release `v0.17.0` as `0.17.0`), `git:<host>/<owner>/<repo>@<tag>`
102
+ for a git one, and for a local checkout no install at all — that copy has a remote, so it is told
103
+ to `git pull` and restart pi. The hint printed for an unknown command follows the same rule, for
104
+ the same reason.
105
+
106
+ ## [0.16.0] - 2026-09-12
107
+
108
+ ### Removed
109
+ - **The compatibility paths for this project's older names are gone.** Each existed so a config or
110
+ an archive written against an older name would keep working — but two spellings of one thing make
111
+ the current one look optional, and a reader cannot tell which is which. These are breaking changes,
112
+ removed on purpose:
113
+ - The second archive format `import` accepted. `import` reads `.pisession` archives only; anything
114
+ else is refused as `unsupported archive schema …` rather than half-salvaged, and the summary no
115
+ longer carries a flag for the case where a transcript could not come across.
116
+ - `cwd = "pie"` in `hooks.toml`. The values are `project | loops | home`; `loops` is that same
117
+ directory, and a hook that still says the old name is skipped with that diagnostic rather than
118
+ run somewhere unexpected.
119
+ - The `.pie/` project directory. `hooks.toml` and `mcp.toml` are read from `<project>/.pi/` and
120
+ nowhere else, so a project still carrying the other one needs the file moved.
121
+ - The `PIE_*` hook environment variables and `PIE_ALLOW_PROJECT_HOOKS`. A hook command is handed
122
+ `PI_*` only, and the flag is read from `PI_ALLOW_PROJECT_HOOKS`.
123
+ - The MCP notification fields `pie_dedup_key` and `pie_summary`, and both top-level forms,
124
+ `_pi_dedup_key` included. A custom notification's idempotency key is read from
125
+ `_meta.pi_dedup_key` and its summary from `_meta.pi_summary`; the message a dropped push produces
126
+ names exactly the one field it wanted (`missing _meta.pi_dedup_key`).
127
+
128
+ ### Fixed
129
+ - **Scheduling and triggers.**
130
+ - Trigger check transcripts were never pruned: they are written under `triggers-<project>` and the
131
+ prune asked for `triggers/<project>`, a directory that does not exist — so it found nothing to
132
+ delete and silently kept every check ever run. Pruned where they are written now, 40 per project
133
+ as the docs promise — and per project really means per project: the key was the directory's
134
+ basename, so `~/a/web` and `~/b/web` shared one budget of 40 and each project's prune deleted the
135
+ other's evidence. It is `triggers-<project>-<hash of its path>` now.
136
+ - A cron expression that parses and never matches (`0 0 30 2 *`) cost about 300 ms of every
137
+ 30-second tick, in every open window: the scan walked five years of minutes to conclude nothing.
138
+ It skips by the day now, on the same vixie-cron rule the match uses, and `/cron add` refuses an
139
+ expression with no next run (`… has no next run`), which `/cron set` has always done. `import`
140
+ refuses one too: an archive is somebody else's file, and it was the one way in that still
141
+ installed a job nothing would ever fire.
142
+ - `/cron run` on a plain job was a second copy of what the timer does, and it had drifted: a
143
+ one-shot was not retired, `lastDueAt` and `lastCompletedAt` were not written, `lastError` was
144
+ never cleared. One function does both now, headless-host guard included — and `/cron run`
145
+ reports what it did rather than only whether the job was busy: a disabled plain job is refused
146
+ (its prompt would land in whichever chat is open now, not the one it was written for), and so is a
147
+ job on the headless host, which has no chat to inject into. Running one used to clear `lastError`,
148
+ which for a job the dead-session sweep parked is the marker `/cron gc` collects it by — leaving a
149
+ job nothing could fire and nothing could collect.
150
+ - `next-runs.<host>.json` listed jobs pinned to other machines — a next run this host will never
151
+ dispatch. It is filtered the way dispatch filters, and so is `cron_list`, which promised a model a
152
+ `next_run` for another machine's job while `/cron` said `[other host: …]` with none.
153
+ - A job created with no session project of its own — the headless host, `--no-session` — needs an
154
+ absolute `cwd`, and says so. A relative one was resolved against the process's own directory,
155
+ which for the host is `$HOME`: a sub-agent's `cron_create {cwd: "code/piz"}` silently pinned the
156
+ job to a real, unrelated project. (Before that it doubled the segment: `cwd: "sub"` ran in
157
+ `<process cwd>/sub/sub`.)
158
+ - One trigger delivery could write its audit rows under two different projects, splitting a trace,
159
+ when the session's directory moved during a check that takes minutes; the project is resolved
160
+ once per delivery. And a check could pair one rule's model with another rule's thinking level —
161
+ a budget the model does not have. Both now come from the first rule that recorded a model.
162
+ - **Logs.** A log over 2 MB in a single line was rewritten identical for ever — `slice(-0)` is the
163
+ whole array — so every later write paid a full read and write of it. It carries the guard the run
164
+ log and the inbox carry. One log line is capped at 4000 characters, the bound a hook's output has:
165
+ the text is usually something else's stdout, and an unbounded diagnostic is how a log becomes one
166
+ line in the first place.
167
+ - **Locks and paths.**
168
+ - `withFileLock` could spin at 100 % of a core forever on a lock path that exists for `mkdir` and
169
+ not for `stat` — a dangling symlink is how that happens — because the deadline was only checked
170
+ on the branch that could read the lock's age. It times out and says so, like its synchronous
171
+ sibling.
172
+ - A checkout under a path containing a space or a `#` reported version `0.0.0` in every manifest,
173
+ log line and `/pi-loops` output: a file URL's `pathname` is percent-encoded, so no package.json
174
+ was read and the fallback is silent. `fileURLToPath` now.
175
+ - A run with `--cwd ./sub`, in a worktree, or through a symlinked path inside the project this pi
176
+ has open was treated as another project: a spurious "project MCP config … ignored" and a second
177
+ copy of servers this process already runs.
178
+ - `realpathish` (`src/paths.ts`) is the one answer to "what path is this, really". Presence and the
179
+ sub-agent runner had one each, and they agreed only about paths that exist — which a job's cwd
180
+ often does not yet, so the two could call one pair of directories the same project and two.
181
+ - **The headless host.**
182
+ - `PI_LOOPS_HOST_THINKING` is validated instead of cast into every model call: an unknown level is
183
+ dropped with a line naming the ones that exist, rather than being first heard about from a
184
+ provider refusing it. Every other way in is checked the same way, against the same list
185
+ (`src/thinking.ts`): `/cron add --thinking`, `/cron set --thinking` and `/triggers set --thinking`
186
+ refuse a level that does not exist — `--thinking hgih` was stored as typed and only surfaced hours
187
+ later, as a failed run.
188
+ - The host re-resolves its default model every minute, so a credential or provider added after the
189
+ hand-off reaches a process that can live for days. It was resolved once, at startup.
190
+ - An error introduced by editing `config.toml` while the host is up reaches its log. The host
191
+ re-reads the file every minute and reported nothing, so the setting fell back to its default for
192
+ the rest of a process that can live for days. Each new error is logged once.
193
+ - `host status` reads each store once for the whole snapshot. `jobs.json` was read four times, so a
194
+ tick landing in between could have `enabled` counted against one version of the file and `total`
195
+ against another — a status line contradicting itself.
196
+ - **The browser front end.**
197
+ - **Ctrl-C in the terminal that started the session now ends it the way `/quit` does, hand-off
198
+ included.** pi handles SIGTERM and SIGHUP and has no handler for SIGINT, and a terminal's Ctrl-C
199
+ goes to every process in the foreground group — so pi died of that SIGINT instantly, before the
200
+ front end could ask it to quit: no headless host, no line saying so, automation simply off, by the
201
+ most ordinary way there is to stop a program. pi is started in a process group of its own now, so
202
+ the only signal it gets is the SIGTERM the front end sends it, and the front end waits (up to 15
203
+ seconds) for pi to finish quitting — which is what puts "handed the clock to a background host" in
204
+ front of the person who pressed the key.
205
+ - **And that line now actually reaches the terminal.** pi-loops announces the hand-off through
206
+ `ctx.ui.notify`, and `ctx.hasUI` is true in rpc mode — pi binds a real UI context there whose
207
+ `notify` is an event on stdout — so the note was addressed to the page, and the page's server is
208
+ the process on its way out. pi did hand the clock over, `host status` showed the host, and the
209
+ terminal said `pi exited (143)` and nothing else: automation running somewhere the person who
210
+ stopped the session was never told about. A notification arriving while pi quits is relayed to the
211
+ terminal, and when none does (rpc mode does not flush stdout on SIGTERM) the loops directory is
212
+ read the way `pi-loops host status` reads it: `automation handed to a background host (pid N);
213
+ pi-loops host status | stop`, or `no background host started; automation is not running — see
214
+ <loops dir>/host.log`, or `[host] auto = false: automation stops with this pi` — and nothing at
215
+ all when there was no loop or rule for a host to keep running.
216
+ - A slash command that takes no argument runs on one Enter. Typing `/inbox` and pressing Enter
217
+ accepted the completion it was already equal to, added a space, and sent nothing; a second Enter
218
+ was needed, where a terminal runs it on the first. Enter now sends when there is nothing left to
219
+ complete, and still completes a prefix that is genuinely shorter. Tab only ever inserts.
220
+ - The model picker shows the model in use even when pi's catalog does not have it. pi lists only
221
+ providers you have credentials for, and a session can be running on one you do not —
222
+ `--model deepseek` resolving to a gateway that will not authenticate — so nothing matched and
223
+ the picker was blank beside a panel naming the model, which reads as "no model". It is listed
224
+ under its own provider, marked as not in the catalog.
225
+ - **Going back to a session puts it back on the model it was last using.** A `--model` on the launch
226
+ command line — yours, or the one the launcher adds from the model you last chose — lasts as long
227
+ as the process, not the session: pi re-resolves it every time the session inside the process is
228
+ replaced. So resuming a conversation had with one model landed it on a different one, which is
229
+ what a fresh `pi --resume` would never do. A model whose credentials have since gone is reported
230
+ in the terminal rather than forced.
231
+ - A clear or a resume logged `session start:` twice, three milliseconds apart — and ran the whole
232
+ start twice: two MCP source starts, two hook loads, two attempts to take the clock back from the
233
+ headless host. pi rebinds the extensions to a replaced session twice in rpc mode and re-emits the
234
+ session's start event on each bind; a start is now a start until its shutdown, which pi emits
235
+ once.
236
+ - `/rpc` is the escape hatch for what has no route, and it now refuses the commands that do have
237
+ one. Posting `switch_session` through it skipped the mid-turn refusal, the same-session check, the
238
+ "one of this project's sessions" allowlist, and the epoch, backlog and pending-dialog reset a swap
239
+ owes every attached browser. `cycle_model` and `cycle_thinking_level` are refused for the same
240
+ reason (`/model` and `/thinking` refresh the catalog and remember the choice), and `new_session`
241
+ and `clone` outright: both replace the session every browser is watching, with no route to do any
242
+ of that resetting and nothing on the page to notice it. The routes that only read — `/state`,
243
+ `/history`, `/stats` — answer GET and nothing else.
244
+ - pi's dying words are redacted and *then* cut to their last 4000 characters before they are
245
+ broadcast. A provider that refuses to authenticate prints the key it was refused with, so the one
246
+ event whose whole purpose is to explain a failure was the one that could carry a credential — and
247
+ cutting first meant a key straddling the boundary lost the prefix its pattern starts at, so
248
+ nothing matched and the rest of it went out.
249
+ - Everything the panel draws from disk or from pi goes through `plain()` as well as escaping — a
250
+ job's name, schedule and error, a rule's condition and action, an MCP server's name, state and
251
+ last error, a dialog's title, the goal and meta lines, a detail list. Escaping does nothing about
252
+ a bidi override, which makes one line read as another.
253
+ - A pairing guess spends one of twenty tries, so a browser that is already signed in is recognised
254
+ as signed in first: reloading a bookmark that still carried an old `?pair=` burned a try the phone
255
+ across the room needed, and spent the one-shot launcher key the same way.
256
+ - Undo goes through `resync`, the one path that empties the feed. Its own copy left the sequence
257
+ number and the epoch pointing at a transcript it had just replaced, dropped whatever arrived while
258
+ it was reading, and never showed the empty state — so undoing the only message in a session left
259
+ one grey sentence on a blank screen.
260
+ - Unanswered dialogs are bounded at 50 and only one is on the screen at a time; an abandoned one was
261
+ offered again to every browser that attached, every eight seconds, for as long as the process lived.
262
+ - A failed request always yields a value: `api()` catches, so the fire-and-forget callers read
263
+ `success` and `error` whether the request failed at the server or never left the browser.
264
+ - The panel counts jobs and rules apart, each line naming the command that lists them (`/cron all`,
265
+ `/triggers rules --all`). They were added together, and one number covering both agreed with
266
+ neither command — in a line whose whole job is to send someone to the command.
267
+ - `/CLEAR` from a stale tab is recognised as the command the page implements and answered with
268
+ "reload", instead of being told it is not a command at all.
269
+ - An image the page cannot render is refused, with the same visible notice the ten-image cap uses,
270
+ rather than relabelled: a pasted SVG or TIFF went out as `image/png` — bytes claiming to be
271
+ something they are not — and the only sign of it was a broken thumbnail. PNG, JPEG, GIF, WebP and
272
+ AVIF, the same five however the image arrived.
273
+ - Three caps say what they cut, through one helper: a tool result, an extension's message and pi's
274
+ stderr reported it three different ways, and one not at all — a result that stopped at 8000
275
+ characters looked like a result that ended there.
276
+ - `--port` is refused out loud when it is not a number from 0 to 65535, and when it has no value at
277
+ all; `--port 41773x` and a bare `--port` both served 4173 instead, so the address in the terminal
278
+ was not the one asked for. `--allow-host` is listed in
279
+ `--help`: a flag that relaxes a security check and is not documented cannot be audited.
280
+ - The project this session is open in is trusted for unattended runs, and so is what is inside it —
281
+ never what is outside it. The test was symmetric, and blocked only `/` and `$HOME`, so an *ancestor*
282
+ counted as the same project: `cron_create` takes a model-chosen `cwd` with no confirmation, and
283
+ `cwd: ".."` would have had the parent directory's `.pi/extensions` and `.pi/mcp.toml` loaded in a
284
+ run nobody was watching. Scoping a list is a separate question and still answers symmetrically:
285
+ showing a job is not running one.
286
+ - `/session-share`'s header count and the count in the confirmation you approve are the same number —
287
+ the rendered one. They differed exactly when the transcript held a message of a shape the renderer
288
+ skips.
289
+ - `/triggers status` counted this process's two local sources as connected on a pi that is on
290
+ standby, in the same breath as the enumeration below it calling them `standby`.
291
+
292
+ ### Changed
293
+ - **The scheduler says which of its diagnostics are routine.** `log` takes a level, and only the
294
+ scheduler knows whether taking the timer over is bookkeeping or a job being disabled is a problem.
295
+ The extension was recovering that with a regex over the wording of the lines themselves, so
296
+ rewording one silently changed how loudly it was reported. No level means `warning`: a line that
297
+ does not say it is routine is not.
298
+ - `onInject` hands over the run id it minted, rather than leaving the caller to read it back out of
299
+ the `[Trigger …]` prefix it had just formatted.
300
+ - Five things that existed twice now exist once: `backoffWaitMs` (`src/schedule.ts`), shared by loop
301
+ runs and trigger checks because docs/triggers.md promises they are the same numbers; `hostFileTag`,
302
+ the hostname as one filename component; `rotateInPlace`, so the headless host's log follows the
303
+ same "halve it at 2 MB" rule as every other log instead of carrying its own copy; `jobTextOf`,
304
+ which knows the two prompt literals the transcript view was matching on its own; and `realpathish`.
305
+ - Two decisions moved out of the extension entry file, which nothing can import and nothing can test:
306
+ which loops count as failing and the badge line that says so (`src/job-health.ts`), and what change
307
+ deserves a permanent snapshot entry in the session (`src/snapshot.ts`). Both have tests now.
308
+ - `envFlag` reads the current name and nothing else: `1` or `true`, either case, anything else off.
309
+
310
+ ### Documented
311
+ - **The sentences that stopped mid-thought are whole.** `docs/mcp.md` opened with one that ended at
312
+ "that is"; `docs/loops.md` had four (the prompt-shape sentence, the sub-agent ownership one, a
313
+ budget paragraph still comparing this project with another, and `/cron gc` explained twice); and
314
+ `docs/triggers.md` two (the cycle-suppression bound and the shape a promotion is inserted in).
315
+ - **The values that were wrong.** Check transcripts live in `sessions/triggers-<project>/`, one
316
+ directory per project, 40 kept each. An MCP server is reconnected up to 20 times and then left to
317
+ the next pi (the Chinese README said the retry was unlimited). A trigger check draws on
318
+ `[cron] max_concurrent_runs`, shared with loop runs, not a cap of its own. An archive is
319
+ `pi-session-<first 16 characters of the id>.pisession`. There are two pipelines and a goal
320
+ evaluator sharing one sub-agent pool, not three or four. `daily` means 09:00 local and `@daily`
321
+ means midnight — in `docs/loops.md` and in the skill, which is what writes the job. A job whose
322
+ checkout is gone is marked at once and disabled half an hour later, because a mount can be late at
323
+ boot. The inbox drops its oldest already-triaged entries past 1 MB and never anything still new.
324
+ The prompt quoted in the Chinese README is the one the code composes. `/goal`'s statuses include
325
+ `cleared`. The panel always draws `Hooks` and `Runtime` while its other sections hide when empty.
326
+ And both READMEs list `pi-loops sessions` and `pi-loops inspect`, which they did not mention.
327
+ `/cron run` does not leave a schedule untouched, and no longer claims to: a `once` job is fired and
328
+ removed, an `every` job's interval restarts from now, a `cron` job's next run is unchanged, and a
329
+ disabled plain job is refused. One dynamic check evaluates every rule of a project, so it runs under
330
+ one model — the first rule that recorded one, paired with that rule's thinking level — rather than
331
+ each rule under its own, which `docs/triggers.md` had implied. And the Chinese README's `reconnect`
332
+ sample says which number is the example (10) and which is the default (20).
333
+ - **What had drifted into the wrong section moved back.** `docs/loops.md` gains a section for
334
+ stopping, removing and jobs that keep failing, holding the three paragraphs that had settled into
335
+ the budget and timeout sections. `docs/hooks.md`'s host trust requirement is its own bullet rather
336
+ than a tail on the one about draining. `docs/mcp.md`'s repeated-`[[server]]` rule is no longer
337
+ inside the paragraph about which process evaluates a push. `README.zh-CN.md`'s `/goal` lines sit
338
+ with `/goal` instead of in the middle of `/cron`'s. `examples/README.md` drops a note announcing a
339
+ file move that happened releases ago.
340
+ - **`docs/web-ui-parity.md` describes the boundary the code actually keeps.** A preview is anchored
341
+ in the session's directory *and* in `$HOME`, for the types one only looks at, which is where an
342
+ agent leaves something it made for a person; and "image bytes never appear in any event" was never
343
+ true — a message's own image blocks travel with it and the page renders them, and what needed
344
+ redacting was the stderr tail. New lines for `--allow-host`, `--port`, `/rpc` keeping every route's
345
+ guards, the read-only routes, the order the pairing check runs in, the bounded dialog list, the
346
+ three caps and the empty state after an undo, each naming the test that holds it.
347
+ - `docs/troubleshooting.md`: a second front end on `--port <n>` does not sign the browser out. The
348
+ token is the one file both read, and a cookie is not scoped to a port — `localhost:4173` and
349
+ `localhost:4180` are one site to a browser, which is the fact the `Sec-Fetch-Site` check is built
350
+ around.
351
+ - `AGENTS.md` and `README.zh-CN.md` list `src/job-health.ts`, `src/paths.ts` and `src/snapshot.ts`;
352
+ `docs/configuration.md` documents `--loop` and `--inject`, which `/cron add` has always accepted.
353
+ - A doc comment sitting above the wrong declaration, in twenty places across `src/` — the lock's
354
+ synchronous sibling, `parseSchedule`, `computeDue`, `withinProject`, `resolveRuleRef`,
355
+ `createLoopJob`, `mcpToolDefinitions`, `renderHostSnapshot`, `isInsideDir`, the trigger card, the
356
+ tool-call row, the rule for a path written as a code span, and the rest — each moved onto the thing
357
+ it describes, or deleted where what it described has since grown a better one of its own. A comment
358
+ attached to the wrong function is worse than none: it is read as true.
359
+
360
+ ## [0.15.0] - 2026-09-12
361
+
362
+ ### Changed
363
+ - **The name a server author has to get right is this project's own.** A custom MCP notification
364
+ needs an idempotency key, and that field was documented as `_meta.pie_dedup_key` with
365
+ `pi_dedup_key` mentioned as an alternative — so the one thing a server must spell correctly
366
+ carried a prefix that is not this package's own, and the message a dropped push produced
367
+ (`missing _meta.pie_dedup_key`) taught it to the next person. `pi_dedup_key` and `pi_summary` are the
368
+ documented names now, are read first, and are what that message quotes. `pie_dedup_key`,
369
+ `pie_summary` and the `_pie_`-prefixed top-level forms are still read, with a test pinning it, so
370
+ a server already sending them keeps being understood.
371
+ - **An on/off environment variable is read the same way everywhere.** `PI_ALLOW_PROJECT_HOOKS` was
372
+ accepted as `1` or `true` by the hook loader and only as `1` by the `config.toml` reader — the kind
373
+ of difference that costs somebody an evening. `envFlag()` in `src/config.ts` is the single reader
374
+ now (`1` or `true`, either case; `PI_` first, the older `PIE_` prefix after it), and
375
+ `PI_LOOPS_DEBUG` and `PI_LOOPS_HOST` go through it too, so `=true` works for them as well. The
376
+ current name wins when both are set, so `PI_ALLOW_PROJECT_HOOKS=0` can turn off what a shell
377
+ profile switched on years ago under the older name — a switch you cannot reach is worse than one
378
+ you have to spell correctly — and anything that is not `1` or `true` is off, so a typo fails
379
+ closed.
380
+ - **The `.piesession` importer is named after the format it identifies.** `PIESESSION_SCHEMA` and
381
+ `importPiesessionArchive`, and errors that say *the archive's* cron sidecar. The file is the thing
382
+ a person handed over; `ARCHIVE_SCHEMA` beside `PIESESSION_SCHEMA` reads as two formats, which is
383
+ what they are.
384
+
385
+ ### Fixed
386
+ - **The leader file is `scheduler.<host>.json`, and four places said `scheduler.json`.** Both
387
+ READMEs' storage tables, `src/store.ts`'s layout comment and `src/scheduler.ts`'s header. It
388
+ carries the hostname on purpose — a shared `$HOME` gets one per machine — so the shorter name is
389
+ the one a person greps for and does not find.
390
+ - `src/mcp.ts`'s header said tools are not proxied. They have been since tool registration shipped:
391
+ it is the MCP client, notifications **and** tools.
392
+ - The doc comment belonging to `loadMcpConfigFiles` sat above `loadProjectMcpConfig`, describing the
393
+ wrong function.
394
+ - Comments the previous editing pass over this project's wording left mid-sentence, each now stating
395
+ its own reason: `src/danger.ts`, `src/subagent-guard.ts`,
396
+ `src/share.ts` (which also still called the command `/share`), `src/redact.ts`,
397
+ `src/hooks.ts`'s `run_start` rationale, `src/presence.ts`, `src/sdk-runner.ts`, and three
398
+ sentences of `README.zh-CN.md` — one of which had lost its subject and one of which leaked an
399
+ internal type name. Every comment that cited, by line number, a source file the reader cannot open
400
+ now says what the code is for instead; the citations into pi's own installed build stay, because a
401
+ reader can open those.
402
+
403
+ ### Documented
404
+ - **`README.zh-CN.md` is ordered the way somebody reads it.** It opened with three sections of
405
+ architecture before saying how to install anything. The commands and getting-started come first
406
+ now, then usage, then the mechanisms — the English README's path. Its hook fields said
407
+ `cwd = project|pie|home`, missing `loops`, which is the name (`pie` is only its older one); its
408
+ code-structure table was well short of the tree and is now all of it.
409
+ - `AGENTS.md`'s layout listed `src/cli.ts` twice, omitted ten modules and pointed at
410
+ `test/register-pi.mjs`, which lives in `src/`. It is the whole of `src/` now, grouped by what each
411
+ part does.
412
+ - `docs/configuration.md`'s environment table gains `PI_LOOPS_DEBUG` and `PI_BIN`. `docs/loops.md`
413
+ says `/crontab` and `/loop` are `/cron` under other names, which until now only the Chinese README
414
+ mentioned.
415
+ - Hard-coded test counts are gone from `AGENTS.md` and `README.zh-CN.md`. A number that is wrong one
416
+ commit later is worse than no number, and `npm run ci` prints the real one.
417
+
418
+ ## [0.14.4] - 2026-09-11
419
+
420
+ ### Changed
421
+ - **Every doc, comment, test name and user-facing string states its own reason.** The reason a rule
422
+ exists is the rule's own reason, and a person reading `/cron`'s help or a comment in the scheduler
423
+ should find it there, in front of them. All of them were rewritten to say it directly, with the
424
+ reasoning kept, and the README ends with a single line of acknowledgement.
425
+
426
+ ### Added
427
+ - `cwd = "loops"` in `hooks.toml` names the pi-loops data directory. `cwd = "pie"` resolves to that
428
+ same directory, an older name for it this project still accepts: the option is written in
429
+ people's config files, and a hook that silently starts running somewhere else is worse than an
430
+ odd name.
431
+
432
+ ### Fixed
433
+ - A test asserted that "45 minutes from now" is still today, and so failed every night between
434
+ 23:15 and midnight. It uses noon today now.
435
+
436
+ ## [0.14.3] - 2026-09-11
437
+
438
+ ### Documented
439
+ - **The Chinese README says which clock, in Chinese.** Everything about time went into
440
+ `docs/loops.md` and the skill, both English, while `README.zh-CN.md` — where a Chinese-reading
441
+ user actually learns what `/cron add` does — said `本地时间` in a parenthesis and stopped. It now
442
+ has what the English docs have: the stamp format and why it carries an offset, the two measured
443
+ daylight-saving edges and `every 24h` as the way round them, what `at 2026-09-08` with no time
444
+ does, that `in`/`at` freeze an instant while `every` is an interval, and the cross-machine loop
445
+ note.
446
+ - The English README's `/cron add` row points at that section rather than leaving "5-field cron" to
447
+ imply a clock it never names.
448
+
449
+ ## [0.14.2] - 2026-09-11
450
+
451
+ ### Documented
452
+ - **Nothing in the package is written in one machine's timezone any more.** The prompt shape in
453
+ `docs/loops.md` showed `current run started 2026-09-09 09:00 +08:00` — the offset of the machine
454
+ the line was written on, sitting where a reader on any other machine would take it for part of the
455
+ template. The prompt is built from the running machine's clock and always was; the doc now says so
456
+ in the same `<…>` placeholder style as the rest of that block, and the `runs.jsonl` example beside
457
+ it names whose offset it is showing.
458
+ - **The skill says what the machine's clock means for a job it creates** (`skills/pi-loops`): that
459
+ `0 9 * * *` is nine in the morning where the machine is, that `at 2026-09-08` with no time is
460
+ midnight **UTC** by JavaScript's rule while `at 2026-09-08T18:00` is local, and that a job which
461
+ must run once a day and never twice is safer as `every 24h` — clocks go back one night a year.
462
+ The model creating the job is the one who would otherwise write the date-only form.
463
+
464
+ ## [0.14.1] - 2026-09-11
465
+
466
+ ### Fixed
467
+ Both found by following one question: what happens to a loop that runs on a machine in a different
468
+ timezone from the one it last ran on? A job with no `host` runs on any machine sharing the `$HOME`,
469
+ and `/cron set <ref> --host -` asks for exactly that, so this is a supported arrangement rather than
470
+ a hypothetical one.
471
+
472
+ - **A loop is now told how to write a time.** The notes it is asked for hold watermarks —
473
+ "everything up to here has been seen" — and a watermark is a time the model writes in whatever
474
+ shape it likes. Run N could write "checked up to 20:00" in Shanghai and run N+1 read it in New
475
+ York. The run time in the prompt carries its offset, and the prompt now asks for the same of
476
+ anything written back. It goes in pi-loops' own line, leaving the protocol block verbatim.
477
+ Notes written before this version do not have it: a loop that crosses timezones and keeps a
478
+ watermark is worth one look at `/cron state <id>`.
479
+ - **`next-runs.json` is per host**, like the leader record beside it and for the same reason.
480
+ Leadership is per host, and a cron expression is matched against local time, so two machines
481
+ sharing a `$HOME` were both writing that one file with answers computed in different timezones —
482
+ each overwriting the other, the panel showing whichever wrote last. `next-runs.<host>.json` now.
483
+
484
+ ## [0.14.0] - 2026-09-11
485
+
486
+ ### Changed
487
+ - **One clock, and it says which one it is.** Cron expressions were always matched against this
488
+ machine's local time — `0 9 * * *` is nine in the morning where the machine is — but the files
489
+ were written in UTC and `/inbox` printed UTC while `/cron` printed local, neither saying so. Eight
490
+ hours apart, in the same session, with nothing on either to tell them apart.
491
+
492
+ Every timestamp pi-loops writes is now this machine's time carrying its offset:
493
+ `2026-09-11T20:37:59.405+08:00`. That is the same instant `12:37:59.405Z` names, and anything that
494
+ parsed one parses the other — earlier versions' files — so nothing needs migrating.
495
+ What changes is that opening `runs.jsonl` shows the hour you were at your desk, and it agrees with
496
+ the `next` on the `/cron` line that sent you there. `/cron` and `/inbox` name the offset in their
497
+ header; a timestamp that travels away from the screen explaining it — into a sub-agent's prompt,
498
+ into a tool result a model reads — carries its own.
499
+
500
+ Two exceptions, neither a time anybody reads: the name of a session file, which cannot hold the
501
+ `+` and `:` an offset brings, and pi's own session header, whose format is pi's to decide. A lint
502
+ rule (`utc-stamp`) keeps the rest from drifting back, and takes the reason for an exception from
503
+ the comment above it.
504
+
505
+ ### Fixed
506
+ - **`/inbox` timestamps were UTC wearing the shape of a local time.** They were the stored ISO
507
+ string with the `Z` sliced off. Local now, like everything else.
508
+ - **The sub-agent prompt said a time without saying which clock.** `docs/loops.md` described it as
509
+ UTC; it was local, unmarked, and a model was being asked to reason about how long ago the last run
510
+ was. It carries its offset now, and the doc says what it actually says.
511
+ - **The `/triggers` status line found the last run by sorting timestamps as text.** Correct only
512
+ while every one of them ended in `Z`; by instant now.
513
+
514
+ ### Documented
515
+ - **Daylight saving, measured rather than assumed** (`docs/loops.md`). A job at `0 2 * * *` does not
516
+ run on the day the clocks go forward — 02:00 does not exist — and a job at `0 1 * * *` runs twice
517
+ on the day they go back. Vixie cron special-cases both and pi-loops does not; `every 24h` is
518
+ immune, because it counts elapsed time and never consults a calendar.
519
+ - **What `at` does with the text you give it**: `at 2026-09-08T18:00` is local, `…T18:00Z` is UTC,
520
+ and `at 2026-09-08` — a date with no time — is midnight **UTC**, which is JavaScript's rule rather
521
+ than ours and the one that surprises people.
522
+
523
+ ## [0.13.6] - 2026-09-11
524
+
525
+ ### Added
526
+ - **The panel says when a cron-expression job runs next.** It worked this out itself and understood
527
+ only `every <interval>`, so a job on `0 9 * * *` — the first example in the README — showed no
528
+ next run at all. Writing a second cron parser into a page that has no dependencies was the wrong
529
+ fix, and so was the snapshot: that goes into the session file for good, rate-limited and
530
+ deduplicated on purpose, and a next run changes every time a job fires. The process that owns the
531
+ clock has the evaluator, so it writes the answers to `next-runs.json` beside the store — only when
532
+ one of them changes, which for a nightly job is once a day — and the page reads them like every
533
+ other file pi-loops keeps.
534
+
535
+ A time that has already passed is shown as no next run rather than as a past one; a job that is
536
+ disabled or belongs to another machine is not promised one, which is the rule `/cron` follows; and
537
+ a next run that is not today says which day it is, because "15:13" for next January is not a
538
+ shorter way of saying it.
539
+
540
+ ### Fixed
541
+ - **A job's name is no longer clipped.** It is what `/cron set <name> --host here` takes, so the one
542
+ thing in that card you might need to copy was the one thing abbreviated to an ellipsis — while the
543
+ schedule beside it wrapped onto two lines. The name wraps now and the schedule stays in one piece.
544
+
545
+ ## [0.13.5] - 2026-09-11
546
+
547
+ ### Fixed
548
+ - **The panel's "other host" line now gives a command you can run.** It said `/cron set <n> --host
549
+ here`, copied from the terminal, where `n` is a position in a numbered list — which the panel does
550
+ not have. Telling somebody to use one was telling them to go and find a terminal to count in. It
551
+ names the job now: `/cron set <name-or-id> --host here`.
552
+
553
+ ## [0.13.4] - 2026-09-11
554
+
555
+ ### Fixed
556
+ Reported as "my cron job disappeared after I rebooted the machine". Three separate reasons a job
557
+ can be there and not be seen, and one reason it can stop running by itself.
558
+
559
+ - **A directory that has not been mounted yet is waited for, not treated as a deleted project.** A
560
+ stateful job whose `cwd` was missing at the moment it came due was disabled on the spot — and a
561
+ network mount, an external disk or an encrypted volume comes up *after* the first pi does at boot,
562
+ so a nightly job died silently for being twenty seconds early and stayed dead once the mount
563
+ appeared. The slot is owed rather than consumed, the job says `waiting: cwd … is not there yet`,
564
+ and it is disabled only once the directory has been missing for half an hour.
565
+ - **The browser panel stops hiding jobs.** It dropped every job stamped with another hostname —
566
+ and a hostname changes on its own: a rebuilt container, a machine renamed by DHCP, a restored
567
+ backup. What you got was a job sitting enabled in `jobs.json`, never running, invisible in the one
568
+ place you would look. It is listed and marked now, as `/cron` has always listed it.
569
+ - **And stops dropping other projects silently.** The store is machine-wide and the panel's list is
570
+ not; it now says `+ N in other projects — /cron all`, which the terminal has always said. Without
571
+ that line a job made in another directory is indistinguishable from a job that is gone.
572
+ - **The panel and `/cron` now agree about what "this project" is.** The panel compared strings while
573
+ the extension resolved symlinks (`withinProject`), so a project reached through a link — a
574
+ worktree, a `~/code` pointing at a mounted disk — was the same project to `/cron` and a different
575
+ one to the page. `$HOME` is too broad to be a project in both places now, rather than only in one.
576
+
577
+ ### Known gap
578
+ - The panel still shows no next run for a job on a cron expression (`0 9 * * *`); it understands
579
+ only `every <interval>` and `once`. See `docs/web-ui-parity.md` — the fix is for the snapshot
580
+ pi-loops already writes to carry the next run, not for a second cron parser to live in the page.
581
+
582
+ ## [0.13.3] - 2026-09-11
583
+
584
+ ### Added
585
+ - **Starting over, without going back to a terminal.** The context is finished with far more often
586
+ than the window is, and every way of saying so — a new session, an earlier one — meant Ctrl-C in
587
+ the terminal that launched the page and typing `pi-loops` again. On a phone it meant nothing at
588
+ all. `clear` and `resume` are now buttons beside `compact`, and `/clear`, `/new` and `/resume`
589
+ typed in the composer do the same thing: the habit comes from a terminal and the muscle memory
590
+ arrives with it.
591
+
592
+ Both are pi's `switch_session`, which replaces the session inside the process that is already
593
+ running — same model, same MCP connections, same extensions, rebuilt against the new session.
594
+ That is the path pi takes for `/new` and `/resume` in the terminal, and the one pi-loops has
595
+ handled since it was written: the scheduler stops and starts with the session rather than being
596
+ handed to the headless host, and a run the swap aborts gives its slot back and re-fires on the
597
+ next tick. Nothing is deleted by either — the session being left is a file on disk that `resume`
598
+ lists, labelled by what was said in it rather than by a filename.
599
+
600
+ Refused while a turn is running, by the server rather than only by the page: the swap aborts the
601
+ turn, and losing a reply you are waiting for is not something to find out afterwards. Refused,
602
+ too, for the session already open — pi answers a switch to the file it is writing by starting an
603
+ empty session pointed at that file, which is two sessions with one file between them.
604
+
605
+ ### Changed
606
+ - **Compaction says what it did, and can be told what to keep.** The button reported "context
607
+ compacted" and nothing else, for an operation that costs a model call and throws most of the
608
+ conversation away; pi hands back what the context was and what it became, and that is now on the
609
+ screen with what the summary cost. `/compact keep the API shapes` steers it, the way
610
+ `/compact <instructions>` does in the terminal — the browser had no way to say it at all.
611
+
612
+ ### Fixed
613
+ - **A slash command with an argument can be typed again.** Every keystroke asks for completions, and
614
+ the answer to `/compact` arrived *after* the space that closed the list — and put it back. Enter
615
+ then accepted the completion instead of sending the line, so `/compact keep the tests`,
616
+ `/cron add …` and `/goal …` were reachable with a mouse and not by typing. Closing the list now
617
+ counts as newer, so an answer in flight is discarded.
618
+ - **An automatic compaction that failed no longer reports success.** `compaction_end` was answered
619
+ with "context compacted" whether it had worked, been aborted, or failed — and said it a second
620
+ time over the top of a manual compaction that had just reported the opposite. The line now says
621
+ what happened, and only for the compaction nobody asked for; the one you asked for is reported by
622
+ the call that asked.
623
+
624
+ Three more, found the same way — by walking the page as a person would, after the feature above was
625
+ already written, reviewed and green:
626
+
627
+ - **A cron job made in a session started from the browser is no longer parked as an orphan.** pi
628
+ names the sessions it starts after their id — `<timestamp>_<id>.jsonl` — and `sessionExists` read
629
+ that name. A session started from the page is created by asking pi to switch to a path that does
630
+ not exist yet, and the id pi mints for it cannot be known in time to put in the name, so ten
631
+ minutes later the scheduler disabled every inject-and-run job belonging to it as "session no
632
+ longer exists" — and `/cron gc` deletes what it parks. The header decides now, which is what
633
+ `listSessions` always did.
634
+ - **That scan cannot be stopped by a fifo.** Reading headers means opening files, and `openSync` on
635
+ a fifo with no writer never returns — on the leader's tick, for ever. Regular files only, the
636
+ same rule the session picker applies.
637
+ - **A session name cannot reorder the line it is drawn on.** Terminal escapes were already stripped
638
+ from anything shown; the invisible bidi overrides and isolates were not, so a session named
639
+ `delete\u202e evil red` drew as "deleteder live" — a different conversation from the one that would
640
+ open, in the control that decides which one opens. They are stripped everywhere `plain()` is
641
+ used, which is everywhere text from somewhere else is shown. The marks ordinary
642
+ mixed-direction text uses (U+200E, U+200F) are left alone.
643
+
644
+ ## [0.13.2] - 2026-09-10
645
+
646
+ ### Fixed
647
+ - **A path in backticks is a path.** Writing one as `` `~/Downloads/report.html` `` is how a model
648
+ says where it put something, and the renderer was deliberately leaving code spans alone — a rule
649
+ that fired on exactly the case it was meant to serve. A span that is *only* a path is now a link
650
+ (still looking like a path), and one that is only a picture is the picture. A span with anything
651
+ else in it, and anything inside a fenced block, is still a literal string.
652
+
653
+ ## [0.13.1] - 2026-09-10
654
+
655
+ ### Changed
656
+ - **An extension speaking in the middle of a stretch of work stays inside it.** A line like
657
+ `karpathy | write x.html | 166 lines` is something a tool just did, but it arrived as a message —
658
+ so it took a row of its own *and* ended the stretch, turning one block into three rows: six steps,
659
+ the note, thirty-five steps. It goes in the block now, and the block stays one row. A message that
660
+ arrives between turns is still a message.
661
+
662
+ ### Note
663
+ The steps blocks are collapsed by default — verified with a browser whose storage had been cleared:
664
+ the preference reads `closed`, the button says `▸ steps`, and a new block comes up shut. If yours
665
+ are open, the header button was pressed at some point; it remembers, and pressing it again puts
666
+ them back.
667
+
668
+ ## [0.13.0] - 2026-09-10
669
+
670
+ ### Changed
671
+ - **A whole stretch of work is one row.** Thinking and tool calls arrive interleaved — think, read,
672
+ think, run, think — and each one took a row of the conversation, so a long agentic turn was thirty
673
+ rows of plumbing around three sentences of answer. They collect into one block now: while it is
674
+ happening the line says what is happening, and when the answer arrives it closes into
675
+ `6 steps · thinking, read, bash`.
676
+ - **One button opens or closes every one of them**, in the header, remembered in that browser.
677
+ - **A path written in ordinary prose is something you can open.** "I put it in
678
+ `/home/you/Downloads/report.html`" was a sentence with a dead end in it — only Markdown links were
679
+ turned into previews, and that is not how a model says where it put something. Paths beginning
680
+ `/`, `./` or `~/` and naming a file worth showing become links; image paths become the picture
681
+ itself, in the conversation. Paths inside code spans, ordinary words with slashes, and web
682
+ addresses are left alone.
683
+ - **A preview can read your home directory, not only the session's own.** An agent asked to make
684
+ something for a person puts it where a person keeps things, and refusing to show you your own
685
+ `~/Downloads/report.html` because the session started in `~/code` is a rule that serves nobody.
686
+ - **A `reload` button**, next to the rest.
687
+
688
+ ### Security
689
+ The widening above was reviewed before it shipped, and three things came back:
690
+ - **A symlink was a way past the "no dot segments" rule.** Membership was decided after symlinks
691
+ were resolved and the dot rule applied before — so `~/Documents/cfg/creds.json`, where `cfg`
692
+ points into `~/.config`, was served. Both are decided on the resolved path now.
693
+ - **Outside the session's own directory, only what one looks at**: pictures, PDFs and pages. A home
694
+ directory holds service-account keys named like ordinary JSON and password exports named like
695
+ ordinary CSV, and the dot heuristic says nothing about either.
696
+ - **A preview may not fetch anything**, and scripts run only for the session's own files.
697
+ `~/Downloads` is where a browser puts what the web gave you, and running that under an address
698
+ you trust is not previewing. Both are now in the response's own policy.
699
+ - `HOME=/` is not treated as a home directory, which it is in some containers.
700
+
701
+ ## [0.12.4] - 2026-09-10
702
+
703
+ ### Added
704
+ - **A page left open across an upgrade says so**, in a bar across the top: which version this window
705
+ is running, which one is installed, and click to reload. A stale tab is indistinguishable from a
706
+ current one otherwise — the panel does show a version, but that is the server's, read live, so
707
+ the one thing on screen that looks like an answer to "how old is this page" answers a different
708
+ question. Three rounds of "no reply appears" were spent on a page that could not have received
709
+ one.
710
+
711
+ ### Note
712
+ Refreshing `http://127.0.0.1:4173/` is enough after an upgrade — the token in the URL is only for a
713
+ browser that has never been here, and the page is served `no-store`, so a normal reload always
714
+ fetches the current one. What is *not* a reload is asking the desktop to open a URL the browser
715
+ already has open: it brings that tab forward untouched, which 0.12.3 works around by opening a
716
+ distinct address each time.
717
+
718
+ ## [0.12.3] - 2026-09-10
719
+
720
+ ### Fixed
721
+ - **Starting the session again handed you back the tab you were already looking at, unchanged.**
722
+ When a server is already on the port, the second launch opens that one's window — at the same
723
+ address, which a browser answers by bringing the existing tab forward *without reloading it*. So
724
+ a page from before an upgrade kept being presented as though it were the new one, which is how
725
+ "no reply appears" survived being fixed twice. The handover opens a distinct address each time,
726
+ and the tab is replaced rather than merely focused.
727
+
728
+ ## [0.12.2] - 2026-09-10
729
+
730
+ ### Fixed
731
+ - **A page that stops receiving events now notices by itself.** Everything in the front end reacts
732
+ to events, which is no use at all when the events are what stopped arriving — and they do: a
733
+ server restarted under an open page, a stream the browser dropped while the tab sat in the
734
+ background. The page went on looking alive, drawing what you typed and never showing an answer,
735
+ until somebody thought to reload it. `/state` — which the page polls anyway — now carries the
736
+ same two numbers the stream does, so a page that is behind can see that it is behind and take the
737
+ conversation again. A restart is acted on at once; a stream that has merely gone quiet has to be
738
+ behind on two polls in a row, because one poll can simply overtake an event in flight.
739
+ - The reload and the poll no longer call each other: a reload ends by refreshing, and a refresh
740
+ polls.
741
+
742
+ ### Note
743
+ This is the fix for "I send a message and no reply appears" after an upgrade. The cause was that
744
+ the event stream is numbered per run of the server process, and a page loaded before the upgrade
745
+ kept counting against the numbering of the process that had been replaced — skipping everything the
746
+ new one sent, for ever, while still polling happily. Reloading fixed it, which is why 0.12.1 said
747
+ so; now the page fixes itself.
748
+
749
+ ## [0.12.1] - 2026-09-10
750
+
751
+ ### Fixed
752
+ - An event arriving while the conversation was being reloaded was drawn *before* the transcript it
753
+ belongs after, putting the newest message above the conversation. Events that land during a
754
+ reload now wait their turn. (Nothing was lost: the feed is emptied before the wait, not after —
755
+ an earlier note here said otherwise and was wrong.)
756
+
757
+ ### Note
758
+ If a tab that was open before an upgrade stops responding, reload it. The server's event numbering
759
+ starts fresh with each run of the process, and a page loaded from a version before 0.12.0 has no
760
+ way to notice that — it goes on skipping numbers it thinks it has already seen. 0.12.0 and later
761
+ detect the restart and reload the conversation by themselves; that fix cannot reach a page that was
762
+ already open when it was installed.
763
+
764
+ ## [0.12.0] - 2026-09-10
765
+
766
+ ### Added
767
+ - **You can see what the session made.** A reply that says "the chart is in ./out/chart.png" is a
768
+ reply you cannot see the chart in, and a page the model wrote was HTML source in a code block.
769
+ Now: a Markdown image in a reply is an image; a link to a path is something to open — png, jpeg,
770
+ webp, gif, pdf, html, csv, json, txt; an HTML block written into the reply has a **preview**
771
+ button; and an image a tool returned is shown instead of being dropped, which is what happened to
772
+ every screenshot until now.
773
+ - **The model and thinking level you chose are remembered**, in `ui.json` beside the loops, and the
774
+ next session starts on them — the terminal window as well as the browser one, since the launcher
775
+ is what applies them. Not when you said which model yourself, and not for `--continue`,
776
+ `--resume` or `--session`, whose session already has the model its conversation was had with.
777
+
778
+ ### Security
779
+ Everything a preview serves is anchored inside the session's own directory, restricted to a list of
780
+ types worth showing, capped, and sandboxed into an opaque origin — a page the model wrote can be
781
+ looked at and cannot act. From the review of that:
782
+ - **The token was in the URL of every generated link and preview**, where a page the model wrote
783
+ could read it out of `location.search` and post it anywhere. It is gone: these are same-origin
784
+ requests from this page and the cookie already authenticates them.
785
+ - **A request from an opaque origin (`Origin: null`) was treated as same-site.** Nothing on this
786
+ server produced such a document before; the previews do. It is refused now, which is what keeps a
787
+ sandboxed page out of `/rpc` on a browser that sends no `Sec-Fetch-Site`.
788
+ - **A path with a dot segment is refused.** A session started in a home directory has
789
+ `.claude/.credentials.json` and `.env` inside it, and `.json` is a type worth showing. Nothing
790
+ anybody wants to *look at* begins with a dot.
791
+ - The file is opened once and read through that descriptor, rather than resolved three times;
792
+ `referrer-policy: no-referrer` on both routes, since a served page picks its own otherwise; and
793
+ the MIME type of an image block is pinned to an image type rather than repeated from the tool
794
+ result.
795
+ - **Events are numbered per run of the process.** A browser holding number 40 from the process that
796
+ just exited was quietly ignoring the first forty events of the one that replaced it — every
797
+ number looked like one it had already seen. A restart is not a gap, it is a different sequence.
798
+ - The notice that says the conversation was reloaded is written after the reload rather than
799
+ before, where it was the first thing the reload removed.
800
+
801
+ ## [0.11.0] - 2026-09-10
802
+
803
+ ### Changed
804
+ - **A run of tool calls is one row of the conversation, not one each.** A turn that reads four
805
+ files and runs two commands spent six rows saying so, and the conversation is the thing you are
806
+ reading. Consecutive calls collect into a single line: while they run it says what is running, and
807
+ afterwards it says what ran — `3 tools · read, bash`. Everything is still there, one click in.
808
+ Closed, it is a line of text rather than a box drawn around one line.
809
+ - **Thinking says enough about itself to be worth a click.** It was already closed by default and
810
+ has always opened on click; what it lacked was any reason to. The line now carries the first of
811
+ what it says.
812
+ - **The model picker is grouped by provider and describes the models**: their own names, how much
813
+ context, and whether they take images — with the ones you have chosen recently at the top of the
814
+ list, remembered in that browser. Thirty-three lines of `provider/id` is a list, not a picker.
815
+ (pi only offers models from providers you have configured, so nothing in there is unusable.)
816
+ - **The thinking picker offers the levels this model actually has.** pi maps them per model and the
817
+ map has holes in it; a level a model does not implement did nothing at all.
818
+ - **Attaching or pasting an image into a model that cannot see one is refused, with the reason.**
819
+ pi says which models take images and the button was guessing.
820
+
821
+ ## [0.10.0] - 2026-09-10
822
+
823
+ A third pass against the reference UI. Two of these are things it does that this did not; two are
824
+ its lesson applied rather than copied; two are its problems avoided.
825
+
826
+ ### Added
827
+ - **A way back to the newest message.** Reading back through a long conversation, an update never
828
+ yanks the page — and until now it never announced itself either, so a reply could arrive with
829
+ nothing on screen to say so. There is a pill above the composer while there is something below
830
+ the fold.
831
+ - **Every block says when it happened**, on hover. A long session had no answer to "when was
832
+ that".
833
+ - **A gap in the event stream reloads the conversation.** The reference UI streams a whole snapshot
834
+ and re-renders, which is always consistent and costs it a selection, an open tool panel, and a
835
+ mechanism to put both back. This keeps appending — and takes the consistency by noticing when a
836
+ number is missing (a reconnect, a tab the browser suspended) and taking the transcript again
837
+ rather than carrying on with a hole in it.
838
+ - **Ten images per message**, and it says so rather than silently building an enormous request.
839
+ - **Clicking away from a dialog closes it.** A native `<dialog>` does not do that on its own.
840
+
841
+ ### Fixed
842
+ - **A tap on "send" could land on nothing while the keyboard was up.** Tapping a button beside the
843
+ box blurs the box, which dismisses the soft keyboard, which relayouts the page before the click
844
+ is dispatched. The button appears dead. This is invisible in a desktop browser, which has no
845
+ soft keyboard — it came from reading how the reference UI solves it.
846
+
847
+ ## [0.9.2] - 2026-09-10
848
+
849
+ A second pass through the front end in a real browser, this time clicking everything: completion,
850
+ prompt history, model and effort, queueing and stopping, approvals, the panel's counts and its run
851
+ buttons, search, undo, copy, image attach, the door page, pairing by typing the code, and pi dying
852
+ underneath it.
853
+
854
+ ### Fixed
855
+ - **`@src/we` listed the whole of `src/`.** Every keystroke asks for completions and the answers do
856
+ not come back in the order they were asked for — the reply to `@src/` arrived after the reply to
857
+ `@src/we` and overwrote it. Only the newest request may draw now.
858
+ - The door page — the first screen a new device sees — had no viewport tag and no colour scheme:
859
+ desktop-width text on a phone, and a white page on a device in dark mode.
860
+ - `undo` said "it is back in the composer" whether or not anything came back.
861
+
862
+ ### Verified, not changed
863
+ Everything else behaved: slash and `@` completion including Tab and Enter, prompt history on the
864
+ arrow keys, switching model and thinking level, queueing a turn while one is running and clearing
865
+ it, stop leaving unfinished tool calls marked rather than spinning, a confirmation showing what it
866
+ is about to do with the focus on cancel, a question from an extension reaching a browser that
867
+ arrived *after* it was asked, a metric tile opening the list behind it when clicked on the number,
868
+ running a job from the panel, search across abandoned branches, copy, attaching an image and
869
+ removing it again, and pi exiting underneath the page saying so instead of going quiet.
870
+
871
+ The QR was checked the whole way again — encoded here, drawn as SVG, rendered by Chrome,
872
+ screenshotted, and decoded back out of the pixels by OpenCV to the exact pairing URL — and so was
873
+ the first-run path: cookies cleared, the door page, six digits typed, signed in, and still signed
874
+ in after a reload.
875
+
876
+ ## [0.9.1] - 2026-09-10
877
+
878
+ Everything here was found by opening the page in a real browser and using it — typing, sending,
879
+ expanding, tapping — rather than by reading the code. 0.9.0 shipped without that, and this is what
880
+ was wrong with it.
881
+
882
+ ### Fixed
883
+ - **A reply could freeze as raw Markdown with its tools stuck on "running".** The page replayed the
884
+ transcript and then joined the live stream, and dropped every `message_end` for the first 300ms
885
+ so the backlog would not double what it had just drawn. That guess also dropped the *live* ones
886
+ whenever the backlog held a turn that was still running — which is to say, whenever you opened or
887
+ reloaded the page while pi was answering. Both the tool results and the end-of-message that turns
888
+ a streamed reply into rendered Markdown are `message_end`. Every event is numbered now and the
889
+ transcript hand-off says which number it was taken at, so the skip is exact.
890
+ - **Every code block on the page was rendered with wide letter spacing.** The pairing code claimed
891
+ `class="code"`, which is also what a fenced block gets. It is styled by its id now.
892
+ - **A tall block was squeezed instead of scrolling the feed.** The feed is a column flexbox with a
893
+ definite height, so its children shrank to fit: an expanded tool call had its last line cut in
894
+ half.
895
+ - **The composer on a phone was about 150px wide**, with three buttons beside it and a scrollbar of
896
+ its own. The box takes the width now and the buttons take a row underneath.
897
+ - **The drawer could only be dismissed by tapping the 47px strip it did not cover.** It has a scrim
898
+ now — the whole of "somewhere else" closes it, and what it covers is dimmed, which is also how
899
+ you can tell the panel is on top of the conversation rather than beside it.
900
+ - The header on a phone gives up the working directory and the program's own name, which are in the
901
+ session panel and on the home-screen icon respectively.
902
+
903
+ ## [0.9.0] - 2026-09-10
904
+
905
+ ### Changed
906
+ - **A tool call and what it returned are one block, and it starts closed.** They used to be two,
907
+ both open: a wall of argument JSON followed by however many thousand characters came back. One
908
+ shell command could push the conversation off the screen, and on a phone it did. The summary line
909
+ is the part that matters — which tool, on what — and it says `running` until the result arrives.
910
+ - **You are on the right in a bubble; the model is full-width prose; status lines are small, quiet
911
+ and monospace.** Three kinds of thing were being drawn in one voice, and a long session turned
912
+ into a wall. The shape now says who is speaking before a word of it is read.
913
+ - **The conversation has a reading width.** It used to run the full width of the window, which on a
914
+ wide monitor is a line nobody can read.
915
+ - **Prose is proportional now, and only what came from a terminal is monospace.** Both stacks name
916
+ CJK faces: the mono one so a box drawn with line characters keeps its corners, the proportional
917
+ one so Chinese reads like text rather than a grid.
918
+ - **Light and dark are both designed.** Every surface is a named colour instead of a translucent
919
+ grey over whatever the browser happened to paint, and the stored theme is applied before the
920
+ first paint rather than after it — applied late, a dark page renders light and then blinks.
921
+ - **An empty session says what it is.** A blank rectangle is the one thing a front end can show
922
+ that says nothing at all, and it is also the first screen a newly paired phone gets.
923
+ - **Counts in the side panel are a row of figures**, each one a target big enough for a finger and
924
+ each one opening the list behind it.
925
+ - **The header fits on a phone.** Eleven controls do not. The ones you reach for mid-conversation
926
+ stay; the rest move — not copy — into a sheet behind one button.
927
+ - Image thumbnails have a visible ✕. "Click to remove" lived in a tooltip, and a finger cannot
928
+ hover.
929
+ - The copy button waits for a hover on a mouse and is simply always there on a touch screen.
930
+
931
+ ### Fixed
932
+ - **Two calls to the same tool no longer swap results.** They were paired by name, first in first
933
+ out, which is only correct if results come back in call order — two shells started together
934
+ finish when they finish. They are paired by call id now, with the name as a fallback.
935
+ - **A result nobody called for used to break every later pairing for that tool.** The orphan block
936
+ was pushed onto the queue it had just failed to match, and everything after it was off by one for
937
+ the life of the tab.
938
+ - A tool call that never returns stops saying it is running when the turn ends.
939
+ - `undo` clears the feed; it now also clears what was pointing into it, rather than appending later
940
+ output to nodes that are no longer on the page.
941
+ - **The find bar could not be hidden.** A rule that sets `display` beats the browser's own `[hidden]`
942
+ rule, so it was open on every page load and the button did nothing visible.
943
+ - A metric tile was only clickable on its 8px padding ring: the click almost always lands on the
944
+ number or the label, and the handler was looking for the key on whatever it hit.
945
+ - An action tapped in the phone sheet now closes the sheet before it runs. A modal dialog makes the
946
+ rest of the page inert, so `find` had its focus call ignored and no keyboard came up.
947
+
948
+ ## [0.8.3] - 2026-09-10
949
+
950
+ ### Documentation
951
+ - The browser front end's own configuration was not written down anywhere: `--port`, `--host`,
952
+ `--allow-host`, `--no-auth`, `--no-open`, `PI_WEB_TOKEN`, and the `web-token` file — including
953
+ what deleting that file does, which is sign every device out.
954
+ - Troubleshooting for the things a person actually hits with it: a browser that has not been here
955
+ before, a phone that cannot reach it at all, a QR that scans to nowhere because it was pressed in
956
+ a window on 127.0.0.1, a port already in use, a pairing code that stopped working, and every
957
+ device signed out at once.
958
+ - The design note said reaching a session from another device had no equivalent here. It has a
959
+ different one: `tailscale serve` puts the page on your phone with nothing of yours passing
960
+ through a third party, rather than a hosted broker in the middle. What that does not cover — a
961
+ phone on neither network — is now stated as the gap it is.
962
+
963
+ ## [0.8.2] - 2026-09-10
964
+
965
+ ### Fixed
966
+ - **A sub-session could load a second copy of pi-loops on macOS**, which pi refuses to start with
967
+ (`Tool "cron_create" conflicts with …`). `isInsideDir` resolved symlinks only for paths that
968
+ already exist, and fell back to the unresolved path otherwise — so a comparison between
969
+ `/private/var/…` and `/var/…` said "outside" for a file that was plainly inside. On macOS that is
970
+ every path under a temporary directory, since `/var` is a link to `/private/var`; anywhere else
971
+ it is any project reached through a symlink. It now resolves the deepest ancestor that does exist
972
+ and re-attaches the rest.
973
+ - CI has been failing on macOS since the pipeline was added, on these two tests, and every release
974
+ since 0.4.0 shipped with it red. One was this bug; the other was a test comparing `piPackageDir`
975
+ (which resolves symlinks, because `pi` is a bin symlink) against a path that had not been
976
+ resolved. Running the suite locally on Linux is not the same as running it, and nothing was
977
+ watching the part that said so.
978
+
979
+ ## [0.8.1] - 2026-09-10
980
+
981
+ ### Changed
982
+ - **Adding a device is a button, not a curl command.** 0.8.0 shipped the pairing code with `POST
983
+ /pair` as the way to get another one, which is an instruction for an operator, not a product.
984
+ Press **add device** in a browser that is already signed in: it shows a **QR code** to point the
985
+ phone at — nothing typed at all — with the six digits underneath for when a camera is not what
986
+ you want to use.
987
+ - The QR encodes the address *this browser reached the server on*, which under `tailscale serve` is
988
+ the tailnet name and works from anywhere on your tailnet. Pressed in a window that is on
989
+ `127.0.0.1`, there is no address that would work from a phone, so it says that and what to do
990
+ about it instead of showing a QR that cannot work.
991
+ - The QR encoder is written here — byte mode, error correction M, versions 1 to 6, about 250 lines
992
+ and no dependency. It was checked by *decoding* its output with a real scanner (OpenCV) at every
993
+ payload length it supports, not by comparing against another encoder. Two bugs turned up that way
994
+ and neither would have failed a self-consistent test, because both produced a well-formed
995
+ picture: a Reed-Solomon generator polynomial built in the wrong direction, and the two copies of
996
+ the format bits transposed. The test in the repo freezes a matrix that a decoder read.
997
+
998
+ ### Security
999
+ - **The QR is no longer aimed at whichever address the machine happened to list first.** `internal`
1000
+ in Node means loopback and nothing else, so the candidate list also held every virtual bridge on
1001
+ the machine — docker0, libvirt, VirtualBox — and those addresses belong to something else on the
1002
+ phone's network. Named ones are dropped, the rest are ordered tailnet-first, and when more than
1003
+ one survives the dialog asks which rather than guessing: a live pairing code sent toward the
1004
+ wrong host is a secret handed to a stranger.
1005
+ - **A pairing code expires after ten minutes**, and closing the dialog retires it. It used to stay
1006
+ armed for the life of the process, which was tolerable when one was minted per launch and is not
1007
+ now that a button mints them — a QR bundles the address and the code into one thing a camera
1008
+ resolves in a single frame, so one left on screen behind whatever you did next is a complete
1009
+ sign-in.
1010
+ - Minting a code no longer resets the guess budget separately from the code itself, and JSON
1011
+ responses say `cache-control: no-store` — one of them now carries a code.
1012
+
1013
+ ### Fixed
1014
+ - A block comment opened and never closed had swallowed a hundred lines of the page — valid
1015
+ JavaScript, accepted by `node --check`, invisible to the linter, and the page still loaded with a
1016
+ hole where the encoder used to be. There is a test now that asks the page which of its own
1017
+ helpers it can actually see.
1018
+
1019
+ ## [0.8.0] - 2026-09-09
1020
+
1021
+ ### Added
1022
+ - **The browser front end works from a phone.** It binds loopback, which a phone cannot reach, so
1023
+ there are now two ways across. `tailscale serve --bg 4173` is the good one: this server stays on
1024
+ 127.0.0.1 and the tailnet does TLS and identity, so the request arrives looking local and
1025
+ carrying the tailnet name as its `Host` — accepted for that reason, and for no other name.
1026
+ `pi-loops --host 0.0.0.0` is the other, for a phone on the same wifi; `--no-auth` is refused
1027
+ outright in that mode, since it would put an unauthenticated shell on the network.
1028
+ - **A six-digit pairing code**, printed at startup. The token is 32 hex characters, which is fine
1029
+ to click and miserable to type on a screen keyboard; the door page asks for the code instead, and
1030
+ that device stays signed in afterwards. One use, and twenty wrong guesses disable it.
1031
+ - **The page is installable**: a web app manifest and an icon, so it opens from the home screen
1032
+ without browser chrome. No service worker — nothing here is worth caching, and a stale copy of a
1033
+ front end whose whole job is to be live is worse than no copy at all.
1034
+ - **Replies are rendered as Markdown**: headings, lists, quotes, rules, tables, inline and fenced
1035
+ code, emphasis, and links that have to be http(s) before they are made into links. A model writes
1036
+ Markdown whether or not the front end reads it, and a page that shows the source is showing you
1037
+ asterisks where a list was meant. Everything is escaped first: a reply is not trusted input, and
1038
+ a tool result quoted inside one is whatever a web page said.
1039
+ - **A count in the side panel leads to the list behind it.** "24 tools" answers the wrong question;
1040
+ which twenty-four is the question people have.
1041
+ - **A theme switch** (system, light, dark) and a side panel that can be collapsed on a wide screen,
1042
+ both remembered in that browser and nowhere else.
1043
+ - **[docs/web-ui-parity.md](docs/web-ui-parity.md)**: what the browser front end owes you, as a
1044
+ gate rather than a wish list. A release either keeps every line or moves one to "held" with the
1045
+ reason. Each line names the test that enforces it, where one does.
1046
+ - **A copy button** on messages, tool calls and results, with a fallback for the plain-http case,
1047
+ where the clipboard API is unavailable because the context is not secure.
1048
+
1049
+ ### Fixed
1050
+ - **Enter no longer sends half a sentence while an input method is open.** Typing Chinese, Japanese
1051
+ or Korean means Enter picks a candidate from the IME's own list; the page was treating it as
1052
+ "send", posting the unfinished text and clearing the box. It now stands back while a composition
1053
+ is in progress, including on the browsers that end the composition first and hand the same Enter
1054
+ to the key handler afterwards.
1055
+ - **The automation panel is reachable on a narrow screen.** It used to be hidden below 900px, which
1056
+ is where "what is my loop doing" is the reason to open this at all. It is a drawer now, behind a
1057
+ button in the header, and tapping the conversation puts it away.
1058
+ - Layout for phones: `dvh` instead of `vh`, so a sliding address bar does not push the composer
1059
+ below the fold; safe-area padding for a notch; 16px inputs, because anything smaller makes iOS
1060
+ Safari zoom the page and never zoom back; finger-sized buttons where the pointer is coarse.
1061
+ - **A confirmation shows what is about to run apart from the reasoning about it.** Run together as
1062
+ one paragraph they read as prose and get waved through. The focus starts on cancel, so a stray
1063
+ Enter cannot approve anything.
1064
+ - `--host=0.0.0.0` is the same flag as `--host 0.0.0.0`. Written with an equals sign it used to
1065
+ parse as no `--host` at all, which bound loopback and skipped the refusal that goes with binding
1066
+ anywhere else.
1067
+
1068
+ ### Security
1069
+ - **`--no-auth` now means loopback, whatever route the request took.** Refusing it at bind time was
1070
+ not enough: a loopback-bound server reached through `tailscale serve` — or `tailscale funnel`,
1071
+ which is the open internet — arrives as a local socket carrying a tailnet name, and was served.
1072
+ - The tailnet allowance is narrowed to connections that actually come from a tailnet: the local
1073
+ socket `tailscale serve` produces, or the 100.64/10 range Tailscale hands out. A `.ts.net` name
1074
+ from anywhere else is a name someone pointed at this machine.
1075
+ - **The pairing path is behind the same cross-site lock as everything else.** Without it a page on
1076
+ any site could point an iframe at the pairing URL twenty times and burn the code your phone was
1077
+ waiting for — and, with a small probability each time, be handed the cookie.
1078
+ - A pairing guess that is not six digits is a wrong guess, not a 500. It used to throw inside the
1079
+ constant-time compare, which told a stranger a code was armed and did it without spending one of
1080
+ the twenty tries.
1081
+ - The pairing code is printed only on a terminal, like the token. It stays live until someone
1082
+ pairs, so a log file holding it is a log file holding the way in. `POST /pair` mints another for
1083
+ a browser that is already signed in.
1084
+ - The manifest and the icon are behind the cross-site lock too. Served to anyone, an icon that
1085
+ loads is a load/no-load bit: a page could sweep ports and addresses and learn exactly where this
1086
+ is running.
1087
+ - **A content security policy on the page.** It holds a token that outlives the process and it now
1088
+ turns model output into DOM; the renderer was reviewed and nothing got through it, but
1089
+ `connect-src 'self'` means a mistake there tomorrow still cannot send the token anywhere, and
1090
+ `frame-ancestors 'none'` means no other page can reach in.
1091
+ - A Markdown link whose URL contains a code span is left as text: the address would not have been
1092
+ the one the link showed.
1093
+
1094
+ ## [0.7.3] - 2026-09-09
1095
+
1096
+ ### Added
1097
+ - **`pi-loops --no-auth`.** No token, no cookie, nothing to carry: the browser UI is open to
1098
+ anything on this machine that can reach the port. The one check that stays is that the request
1099
+ did not come from another site, which is what keeps a page on `http://localhost:5173` from
1100
+ posting into your session. Right on a machine only you use; wrong on a shared host.
1101
+
1102
+ ### Fixed
1103
+ - **Every message you sent appeared twice.** The page draws it the moment you press Enter, and pi
1104
+ sends the same message back when it lands — which is how a window opened later learns about it.
1105
+ Both are right; showing both was not.
1106
+ - **Terminal escape codes were shown as text.** An extension's startup banner, a coloured diff,
1107
+ anything written to the session for a terminal, arrived in the browser as literal `ESC[38;5;240m`
1108
+ around every character. They are stripped now — from the accumulated text, so a sequence split
1109
+ across two stream deltas goes too.
1110
+ - **Chinese text broke every box and column.** Nothing in the font stack could draw CJK, so the
1111
+ browser reached for a proportional fallback whose characters are not twice the width of an ASCII
1112
+ one — and a box drawn by a terminal came apart on the first Chinese character. The stack now
1113
+ names monospace CJK faces; where one is installed, the corners line up.
1114
+
1115
+ ### Changed
1116
+ - **The browser front end has one address: `http://127.0.0.1:4173/`.** The port was already fixed;
1117
+ the token was not — it was made fresh every launch, so the address in your bookmark was stale by
1118
+ the next one. It now lives in `<loops dir>/web-token` at mode 0600, and the first visit leaves a
1119
+ `SameSite=Strict` cookie, so after that the bare address works and you never see a token again.
1120
+ `PI_WEB_TOKEN` still overrides it.
1121
+ - There is a check at all because anything that reaches this server gets the whole session, and
1122
+ that includes a website open in another tab — it cannot read the answers, but nothing would stop
1123
+ it sending your agent instructions. `SameSite=Strict` is the browser's promise not to attach the
1124
+ cookie to anything another site started, which is what makes "no token in the URL" safe rather
1125
+ than merely convenient.
1126
+ - **Running `pi-loops` while one is already up opens that window instead of failing.** A fixed port
1127
+ means colliding with yourself, and starting a session twice is a normal thing to do. The second
1128
+ process recognises the first, opens the browser at it, and leaves — taking its own `pi` with it
1129
+ rather than orphaning one behind a server that never bound. A port held by something else still
1130
+ says so, and `--port <n>` still moves it.
1131
+ - A browser that has never been here gets a page that says what to do about it, instead of the
1132
+ words `bad or missing token`.
1133
+
1134
+ ### Security
1135
+ - **A request that another site started is refused, on every route.** `SameSite=Strict` sounds like
1136
+ it covers this and does not: a *site* ignores the port, so every page served from
1137
+ `http://localhost:5173` — any dev server on this machine, or anything with an XSS in it — is
1138
+ handed the cookie by the browser. The request's own account of where it came from
1139
+ (`Sec-Fetch-Site`, and `Origin` for browsers that do not send it) is what actually closes it.
1140
+ - The second launch that finds the port busy no longer sends its token to whatever is listening
1141
+ there. It asks an unauthenticated question instead — an instance of this program answers the
1142
+ identifying header on its 403 too — because a secret sent to find out who is on the other end has
1143
+ already been sent.
1144
+ - The token file is checked before it is trusted: a regular file, owned by you, and tightened to
1145
+ 0600 if it came back from a backup at 0644. A symlink or a directory in its place is now an
1146
+ error that says so rather than something to write through.
1147
+ - The token is printed to the terminal only when there is a terminal. It outlives the process now,
1148
+ and stdout redirected to a file would be a credential written to a file.
1149
+
1150
+ ## [0.7.2] - 2026-09-09
1151
+
1152
+ ### Fixed
1153
+ - **The browser front end could not show a reply.** `renderRuntime` called a `num()` that was never
1154
+ defined, so the first `refresh()` threw, the startup sequence died with it, and no `EventSource`
1155
+ was ever created — you could send a message, pi would answer in full, and the page would show
1156
+ nothing but your own text, which it had drawn locally before sending. Present since the front end
1157
+ shipped, in every release since.
1158
+ - What let it through: everything about this file was checked by asking its HTTP routes with curl,
1159
+ which never executes a line of the page. `node --check` parses and does not run; the linter reads
1160
+ `src/*.ts` and would not look inside a template literal either way. `test/web-page.test.ts` now
1161
+ runs the page's own script against a DOM stub and asserts that a streamed reply, a tool call, its
1162
+ result and a dead pi all reach the screen. Verified it fails without the fix.
1163
+ - **One malformed job stopped the whole automation panel from redrawing.** The sidebar is built as
1164
+ one string and assigned at the end, so `lastError.slice(...)` on a job whose `lastError` was a
1165
+ number threw before anything was assigned — every other job's card went with it. Those files are
1166
+ written by earlier versions and by hand; the panel now draws what it is given.
1167
+ - Counts from those files reach the panel through `num()` like every other one. `inboxNew` and the
1168
+ two lengths are computed here rather than read from disk, so nothing could be injected through
1169
+ them today — but they were the one place the rule was not being followed.
1170
+
1171
+ ### Security
1172
+ - `@mention` expansion and path completion are anchored to the session's own directory, which this
1173
+ process reads from pi, instead of to a directory the browser sends. The browser only ever echoed
1174
+ back what it was told, but a root supplied by the caller is not a boundary — `"/"` would have
1175
+ made the containment check pass for every file on the disk. Reaching those routes still requires
1176
+ the token, and the token still means the whole session, so this restores a stated invariant
1177
+ rather than closing a way in.
1178
+ - `PI_WEB_TOKEN` is checked at startup. It is substituted into a JavaScript string literal in the
1179
+ page, where a quote would have ended the literal early and turned the rest into code.
1180
+ - The one-shot browser-launch key is now subject to the same "request came from this machine" check
1181
+ as every other route, and the page that carries the token is served `cache-control: no-store`.
1182
+
1183
+ ## [0.7.1] - 2026-09-09
1184
+
1185
+ ### Fixed
1186
+ - An unknown subcommand says which one, and which version this is, before the usage list. The
1187
+ likeliest reason a subcommand is unknown is that it was added after the copy you are running —
1188
+ `pi-loops upgrade` on 0.6.1 being the first example, since an upgrade command can never be in the
1189
+ version that predates it — and a usage list with no message is the wrong answer to that: it looks
1190
+ like you typed something wrong rather than that you are a version behind.
1191
+
1192
+ ## [0.7.0] - 2026-09-09
1193
+
1194
+ ### Added
1195
+ - `pi-loops upgrade` installs the newest release from the repository this copy came from, and
1196
+ `--check` says whether there is one without doing it. `pi update --extensions` deliberately does
1197
+ not move you between versions — it reconciles a git package to the ref you pinned — so taking a
1198
+ release meant looking up which tag was newest and retyping it, which is work a command should do.
1199
+ It reads `repository.url` from the package's own `package.json`, so a fork upgrades from the
1200
+ fork; it compares versions numerically, so `v0.10.0` beats `v0.9.0`; and it ignores release
1201
+ candidates and branch-shaped tags, which are not things to move someone onto unasked.
1202
+ - Both READMEs open with the five commands you actually type, before the explanation of any of them.
1203
+ - `--port 0` takes any free port, and the URL printed is the one actually bound. A fixed default is
1204
+ a fight with whatever else is on the machine, and losing it should not need a second guess.
1205
+
1206
+ ### Fixed
1207
+ - A port already in use says so (`cannot listen on port 4173: …`) instead of an unhandled error.
1208
+
1209
+ ## [0.6.1] - 2026-09-09
1210
+
1211
+ ### Fixed
1212
+ - The browser front end no longer dies with the pi it started, and says why that pi died. When pi
1213
+ refuses to start — two copies of an extension installed, a provider that will not authenticate —
1214
+ it exits before answering anything, and the front end wrote to a stdin that was already closed:
1215
+ an unhandled EPIPE that took the server down too, leaving a browser tab pointing at nothing and
1216
+ the reason visible only in a terminal you may have opened this window to avoid. pi's stderr is
1217
+ now kept, bounded, and shown on the page when it exits.
1218
+ - `/pi-loops install-launcher` does from inside pi what `pi-loops install-launcher` could not do
1219
+ from a shell: put the `pi-loops` command on your `PATH`. The command line's own version needs
1220
+ itself to already be on the `PATH` it is about to write to, and 0.6.0's install instructions led
1221
+ with it anyway — a first step that cannot be the first step. pi is already on your `PATH` and the
1222
+ extension is already loaded there, so that is where the circle breaks. It asks before writing.
1223
+ - Installing pi-loops twice — a checkout you are working on plus `pi install git:` of the published
1224
+ one — makes pi refuse to load the second copy and exit, because both register the same tools. The
1225
+ install instructions now say to pick one, and troubleshooting covers the message you get and the
1226
+ consequence nobody expects: `install-launcher` writes the path of whichever copy ran it, so
1227
+ removing that one leaves `pi-loops` pointing at a package that is no longer loaded.
1228
+ - The install instructions now name the directory a `pi install git:` package actually lives in
1229
+ (`~/.pi/agent/git/<host>/<owner>/<repo>`) instead of saying "the package directory" and leaving
1230
+ you to find it.
1231
+
1232
+ ## [0.6.0] - 2026-09-09
1233
+
1234
+ ### Added — an onboarding path
1235
+ - Both READMEs now read as five steps rather than a list of facts: confirm a provider actually
1236
+ answers before trusting anything unattended, install, put the command on your PATH, start a
1237
+ session, write your first loop, and set a spend cap before leaving it running overnight. The old
1238
+ version told you how to install and then dropped you into a `/cron add` example, which is the
1239
+ right example and the wrong place to meet it.
1240
+
1241
+ ### Changed — `pi-loops` is how you start a session
1242
+ - Bare `pi-loops` starts one, choosing the window: the browser front end at a local
1243
+ terminal, pi itself over ssh or with no terminal at all, where a browser on this machine would
1244
+ help nobody. `--web` and `--tui` say which when the guess is wrong, and anything the command does
1245
+ not recognise goes to pi, so `pi-loops --model anthropic/claude-opus-5 -e .` means what it looks
1246
+ like — and `pi-loops --continue` opens the session you were just in, which is what people
1247
+ actually mean when they say the browser front end "starts a different session". A bare word is never passed on: `pi-loops exprot` is a typo, and starting a session instead
1248
+ of saying so would hide it.
1249
+ - The front end moved from `examples/pi-web.mjs` to `src/web.mjs`. It was never an example — it was
1250
+ the product, filed where you would have to know a path inside a checkout to run it. Getting a copy
1251
+ of the repository in order to open a browser window is not an invocation anyone should have to
1252
+ learn.
1253
+ - `pi-loops install-launcher` writes a launcher into a directory already on your `PATH`
1254
+ (`~/.local/bin` by default). `pi install` puts this package under pi's managed directory rather
1255
+ than on `PATH`, which left the command that is supposed to start your sessions reachable only by
1256
+ absolute path. It is a two-line `sh` script naming the node and the package it was written with,
1257
+ rather than a symlink, so it survives either of them moving for the other's reason.
1258
+
1259
+ ### Fixed
1260
+ - The model and thinking pickers in `examples/pi-web.mjs` were unreadable when open. A `<select>`'s
1261
+ dropdown is drawn by the platform rather than by the page, so a transparent background left the
1262
+ list painted on system white while the options kept the page's text colour — light text on white
1263
+ in a dark theme. They now use `Canvas` / `CanvasText`, which follow `color-scheme` in both
1264
+ directions, as the dialog and the completion popup in the same file already did.
1265
+
1266
+ ## [0.5.0] - 2026-09-09
1267
+
1268
+ Everything here came out of one audit run from two opposite directions — one walking daily usage
1269
+ scenarios from the outside, one inventorying mechanisms from the inside — and the eleven issues it
1270
+ produced. The two passes converged on exactly one finding, which is the one that leads this list.
1271
+
1272
+
1273
+ ### Changed — a scheduled run has its own two hook events
1274
+ - `run_start` and `run_end` join the hook vocabulary, and both the headless host and an interactive
1275
+ pi fire them for every scheduled run (#7). Until now the host fired `agent_start` / `agent_end`
1276
+ and an interactive pi fired nothing, so the same `hooks.toml`, the same job, and a notification
1277
+ that arrived or did not depending on which process happened to hold the clock. Silence that looks
1278
+ like success is worse than no notification at all.
1279
+ Reusing `agent_*` in both places would have fixed the asymmetry and broken something quieter: a
1280
+ rule you wrote about your own turns would have started firing for automation. These are their own
1281
+ events because a scheduled run is not a turn: it happens with no conversation at all, or beside
1282
+ one.
1283
+ The payload says what the run did: `run_job`, `run_id`, and on `run_end` also `run_ok`,
1284
+ `run_findings`, `run_error` and `run_cost_usd`, so "tell me when a loop fails" is
1285
+ `[ "$PI_RUN_OK" = false ]` rather than a string match on a summary. `run_*` hooks are always
1286
+ queued off the run in both processes, whatever `[hooks] mode` says: a webhook that hangs must not
1287
+ hold up the clock, and `sync` is about ordering inside a conversation turn.
1288
+
1289
+ ### Fixed — three of the six gaps the September audit filed
1290
+ - The headless host fires `hooks.toml` hooks for the runs it makes (#1). A webhook that told you a
1291
+ run finished worked while pi was open and went silent the moment the host took the clock, which
1292
+ is the window the host exists for. A run is the agent here, so it fires `agent_start` and
1293
+ `agent_end` and nothing else; the outcome rides in `message_kind` (`loop_run_ok` /
1294
+ `loop_run_failed`), so `$PI_MESSAGE_KIND` alone answers "did last night's loop fail". Each run
1295
+ gets its own runner bound to that job's project, and a project's own `hooks.toml` needs pi's
1296
+ trust for exactly that directory — `allow_project_hooks` is a statement about projects you open,
1297
+ not about a directory a model-chosen `cron_create` pointed at. `docs/hooks.md` now states
1298
+ exactly when hooks fire instead of listing exceptions.
1299
+ - `/cron set --prompt` and `--schedule` change a job in place (#3). Rewording a loop used to mean
1300
+ remove-and-re-add, which minted a new id and abandoned `state/<old id>.md` — months of "what I
1301
+ have already reported" gone, so the next run reported all of it again. The schedule is validated
1302
+ through the same parser `/cron add` uses, the confirmation prints the new next run, and a cron
1303
+ change anchors `lastDueAt` to now so moving a daily job to `*/5` does not fire for slots that
1304
+ only exist retroactively. One-shot schedules are refused on an existing job: firing one deletes
1305
+ the job, which would destroy the notes this feature exists to protect.
1306
+ - An MCP push refused while the machine is busy is held and retried instead of dropped (#5). A
1307
+ periodic check can be dropped safely — the next poll re-examines the world — but a push happened
1308
+ once and no server re-sends it, and both took the same path. Pushes now wait in a bounded list
1309
+ (32, about the width of the dedup window that defines a push's identity) and are retried oldest
1310
+ event first, carrying their original timestamp so the check knows when the thing actually
1311
+ happened. Over budget still drops rather than queues: too busy clears in minutes, a daily cap can
1312
+ last until midnight, and acting on the morning's deploy event at 23:59 is worse than not acting.
1313
+ A rule whose check keeps failing now backs off like a failing job instead of re-billing every
1314
+ poll forever.
1315
+
1316
+ ### Fixed — the rest of the six gaps the September audit filed
1317
+ - The daily budget stops a run that is already going, not just the next one to start (#4). A run
1318
+ admitted at $4.99 of a $5.00 cap could spend any amount, and three admitted together could each
1319
+ spend any amount — a limit consulted only at the entrance is a rate limiter, not a budget. The
1320
+ check now runs before setup, before the prompt, and after each completed turn, and it counts what
1321
+ this process has in flight as well as what the run log already knows: the runs beside this one,
1322
+ and the maker a `--verify` checker is reviewing. A stopped run is recorded as aborted rather than
1323
+ failed, so the slot is still owed and the job's failure streak is untouched; the reason says
1324
+ plainly that the budget stopped it, because "stopped" and "failed" must not be debugged the same
1325
+ way.
1326
+ - A `/goal` continuation is held when you typed something else while the evaluator was running
1327
+ (#6). It used to be delivered as a follow-up on *your* new turn, so the goal quietly took over
1328
+ the question you had just asked. "The branch moved" deliberately does not mean "the leaf moved":
1329
+ run cards and panel snapshots move the leaf all the time, and treating those as your input would
1330
+ have stalled every goal on a busy machine. It means a user message arrived after the point the
1331
+ goal was judged at, or that point is gone.
1332
+ - `/inbox` shows which project each finding came from, and defaults to this project with `--all`
1333
+ for every project — the scoping `/cron` and `/triggers` already use. With loops running in
1334
+ several projects, `/inbox claim 3` used to run a finding about one repository in another
1335
+ repository's directory. Note this scopes `/inbox all` (the history) too.
1336
+ - A job that has been failing repeatedly says so in the status line and at startup, e.g.
1337
+ `2 job(s) failing (check-issues ×7)`. The count was already stored; nothing outside the backoff
1338
+ logic read it, so forty consecutive failures looked exactly like a healthy job until you typed
1339
+ `/cron`.
1340
+ - `session_compact_failed` reaches the `compaction` hook with a `compaction_failed` field. A
1341
+ session that cannot compact is a session about to hit its context limit, which is the case a
1342
+ watcher most wants to hear about.
1343
+ - Hook command stdout is captured into the per-process log, bounded and redacted, instead of being
1344
+ discarded — so the usual debugging move of printing something and looking at it works.
1345
+
1346
+ ### Fixed — one limit, meaning what it says
1347
+ - `[cron] max_concurrent_runs` bounds sub-agents, not sub-agents per pipeline (#2). Loop runs and
1348
+ trigger checks counted separately against the same number, so `= 3` permitted three of each plus
1349
+ a goal evaluator: seven. Both now draw from one pool (`src/slots.ts`), and `/triggers running`
1350
+ reports it, because a number that can be exceeded should at least be visible when it is.
1351
+ The point was never the arithmetic. Two pipelines each answering "am I under the limit" about
1352
+ themselves meant every admission rule had to be written twice, and the second copy drifted —
1353
+ which is how the deferred-versus-dropped difference between them came about. There is now one
1354
+ place that answers "may something start now", and it decides nothing about what a refusal means:
1355
+ the scheduler still leaves the tick owed, and the trigger runtime still queues a push and drops a
1356
+ periodic check.
1357
+ The `/goal` evaluator and `/cron run` take a slot but are never refused one — they are things you
1358
+ asked for directly, and a machine quietly declining to evaluate a goal is indistinguishable from
1359
+ a goal that was never set. That is also what makes `4 of 3 slots in use` a state you can reach
1360
+ and see.
1361
+
1362
+ ### Fixed — a free delivery paying rent
1363
+ - An injected summary arrives even when the day is over budget (#9). `inject_summary` puts the
1364
+ push's own text into the chat and runs no model call — its audit row has always recorded
1365
+ `cost_usd: 0` — and it was being refused by a cap it does not consume. The day the cap trips is
1366
+ the day you still want to be told what is arriving. Deliveries that do spend are still refused,
1367
+ and a summary that goes through while the cap is tripped says so in the audit and the log, so
1368
+ "everything else stopped today, why did this run" has an answer.
1369
+
1370
+ ### Fixed — the follow-ups the parallel work left behind
1371
+ - The headless host writes hook stdout to `host.log` (#11). Capturing it was added to the
1372
+ interactive extension by one agent while another was giving the host hooks, and neither could see
1373
+ the other's file — so the capture landed everywhere except the process where "what did my
1374
+ automation do last night" is actually asked.
1375
+ - `/cron set --name` applies the rule `/cron add` applies (#10). A rename could store a name with a
1376
+ space, or a second `ci`, and a name is how a job is referred to — two of them make every later
1377
+ `/cron run ci` resolve to whichever the lookup reached first. The rule now lives in one function
1378
+ both paths call, so they cannot drift again. Renaming a job to what it is already called is not a
1379
+ collision.
1380
+
1381
+ ### Fixed — a command of ours that pi already owned
1382
+ - `/share` is now `/session-share`. pi has a built-in `/share` of its own, and an extension command
1383
+ that takes a built-in's name is dropped from autocomplete and shadowed at the prompt — so the
1384
+ command did nothing in the terminal while working fine everywhere without built-ins, which is
1385
+ where it had been verified. The new name matches `/session-export` and `/session-import`, which
1386
+ are about the same object. A test now reads pi's built-in list out of the installed build and
1387
+ fails if any of our command names collides, because this is not a mistake worth making twice.
1388
+ Worth knowing: pi's own `/share` is not the same command. It exports the raw session JSONL and
1389
+ offers it to a hosted gateway first, falling back to a private gist, unredacted and with nothing
1390
+ shown to you beforehand.
1391
+
1392
+ ### Changed — a decision you can test
1393
+ - `/cron set`'s decisions moved out of the command handler into `src/job-edit.ts` (#8). Nothing can
1394
+ import the extension's default export, so everything the handler decided was covered by reading:
1395
+ which stamp to anchor when a schedule changes, whether the job is now due at once, whether an
1396
+ expression that parses will ever match. `applyJobEdit(job, edit, ctx)` returns a patch, the lines
1397
+ worth logging, and when the job runs next; the handler is left with arguments, the store and
1398
+ printing. Behaviour is unchanged — the point was to be able to prove that.
1399
+ It returns a patch rather than a rebuilt job on purpose: `JobStore.update` re-reads under a lock,
1400
+ so a tick that started a run in between has already set `running`, and writing back a whole job
1401
+ built from a stale copy would erase it. That is now a property with a test rather than a habit.
1402
+ Two things nobody had checked are now checked: turning a job into a one-shot is refused (running
1403
+ one deletes the job, taking the loop's notes with it — the opposite of why editing in place
1404
+ exists), and an empty prompt is refused the way `/cron add` refuses one.
1405
+ This is the first seam; `AGENTS.md` now says where a decision goes, so the next one lands in the
1406
+ same shape.
1407
+
1408
+ ## [0.4.0] - 2026-09-09
1409
+
1410
+ ### Added — a browser front end, and the state one needs
1411
+ - `examples/pi-web.mjs`: a browser UI for pi in one dependency-free file. It runs `pi --mode rpc`
1412
+ and passes that protocol through to a page — the session is a real pi session, and `pi --resume`
1413
+ picks it up afterwards. A second front end cannot replace pi's terminal, so
1414
+ this is the same shape through the door pi already provides. Streaming feed, history, queue,
1415
+ abort, model/thinking, compact, images, `/` and `@` completion, `@file` expansion, search, undo,
1416
+ HTML export, cost, and pi-loops' approval dialogs answered in the browser.
1417
+ - `pi_loops_snapshot`: a session entry carrying what only this process knows — which MCP servers
1418
+ connected and what they exposed, the active tools, hooks, whether this pi owns the clock, the
1419
+ last check. The TUI panel had it and nothing else could get at it; a front end that is not a
1420
+ terminal now reads it structurally instead of parsing text meant for a person. Written when it
1421
+ changes (not per tick — it goes into the session file), and `/cron snapshot` forces one.
1422
+ - `/share` uploads this session's transcript as a GitHub gist through `gh` —
1423
+ but redacted first, and it says what it is about to publish before it does: how many messages and
1424
+ tool results, how many secrets the redactor masked, whether the gist is public, and where the
1425
+ local copy is so you can read it. Secret by default; `--public` needs its own confirmation.
1426
+ Rendering the transcript unredacted and shelling straight out to `gh gist create`, which sits
1427
+ badly next to a project that redacts everything else it puts on a screen.
1428
+ - `/triggers run <id>` checks one rule now, without waiting for its poll slot —
1429
+ which existed for cron jobs (`/cron run`) but not for rules. It goes through the same path a
1430
+ periodic check takes, so dedup, audit, the sub-agent and promotion all behave identically, and
1431
+ it is refused for a rule belonging to another project: enabling one from here is one thing,
1432
+ starting a sub-agent there from a session that never listed it is another.
1433
+
1434
+ ### Added — the checks themselves
1435
+ - CI (`.github/workflows/ci.yml`): typecheck, lint and the test suite, on Linux and macOS, with
1436
+ every provider credential cleared. The suite is offline by construction; clearing the keys is
1437
+ what makes that a fact rather than an intention. This repository is installed straight from git,
1438
+ so a broken main was previously a broken install with nothing standing in the way.
1439
+ - `npm run ci` runs exactly what CI runs.
1440
+ - `scripts/lint.mjs`: two rules, no dependency (TypeScript is borrowed through npx, the way
1441
+ `typecheck.mjs` already borrowed `tsc`). **floating-promise** — pi installs no
1442
+ `unhandledRejection` handler, so a promise nobody awaits ends the whole session on a rejection;
1443
+ `void x()` counts, since that is the shape the bug takes here. **silent-catch** — an empty
1444
+ `catch {}` with no comment. It found ten floating promises on its first run.
1445
+
1446
+ ### Fixed
1447
+ - Ten promises that could have ended a session, found by the new lint rule. Most were safe by
1448
+ careful reasoning rather than by construction — `tick()` catches everything but its own error
1449
+ path calls back into hooks and logging; `handle()` is documented not to reject. One was a real
1450
+ latent bug: `HookRunner.fire` built its payload *outside* the try, so a throw in `payloadFor`
1451
+ rejected the shared queue promise, which every caller deliberately does not await.
1452
+ - The redactor covers the shapes a secret takes in a *file*, not only in a prompt: Stripe-style
1453
+ `sk_live_…` keys, PEM private-key blocks, and `name: value` / `"name": "value"` pairs whose name
1454
+ mentions a token, secret, password or key. `/share` uploads whole transcripts, so the gap between
1455
+ "what a prompt looks like" and "what a config file looks like" started to matter.
1456
+ - The headless host's control channel now works from a deeply nested `PI_LOOPS_DIR`. A unix socket
1457
+ path is capped at 108 bytes, so `<dir>/host.sock` under a long path failed to listen with EINVAL —
1458
+ the host ran on with no control channel and `pi-loops host status|abort|stop` reported a healthy
1459
+ host as "not answering". A long path falls back to a short one in the temp directory, named by a
1460
+ hash of the loops directory — inside a per-user directory this process owns, verified rather than
1461
+ assumed, because that socket accepts `abort` and `stop`: a predictable path loose in a shared
1462
+ temp directory is one any local account could bind first, and `host stop` would then report
1463
+ success against a forged reply while the real host kept running. `askHost` checks the socket is
1464
+ ours before believing it, a channel that cannot be opened no longer takes the host down with it,
1465
+ and what a snapshot prints is stripped of control characters like everything else that reaches a
1466
+ terminal.
1467
+
1468
+ ## [0.3.0] - 2026-09-09
1469
+
1470
+ ### Added — the last of the third audit's list
1471
+ - `/cron disable --all` pauses every job in this project (`--all-projects` for the machine), and
1472
+ `/cron enable --all` resumes. Quitting pi is the *on* switch here — the host takes over — so
1473
+ "stop everything" needed to be one command rather than one per job.
1474
+ - `/cron remove` keeps the loop's notes and transcripts; `--purge` deletes them, and `/cron gc`
1475
+ reports orphaned state with `--purge` to clear it. Remove-and-re-add is how a schedule or prompt
1476
+ gets changed, and that used to throw away months of accumulated state with no warning.
1477
+ - `PI_LOOPS_DEBUG=1` traces what a sub-agent did — each tool call, provider retries, compactions —
1478
+ into the log file.
1479
+ - `pi-loops sessions [--all]` lists the session ids `export` accepts, and `pi-loops inspect <file>`
1480
+ shows what an archive contains without writing anything.
1481
+
1482
+ ### Added — being able to tell what happened
1483
+ - Every pi process writes its diagnostics to `logs/pi-<pid>.log` in the loops directory, rotated at
1484
+ 2 MB with the newest five processes kept. Until now everything except the headless host went to a
1485
+ chat notification, which is never written to the session file — `/new` or a crash erased every
1486
+ warning the automation had produced, so a loop that failed at 03:00 left nothing to read at 09:00.
1487
+ `/cron scheduler` prints the path.
1488
+ - Diagnostics that matter (a job disabled, a write that failed, a paused budget) are warnings, not
1489
+ info. pi replaces an info status line in place, so several in one tick collapsed to the last one.
1490
+ - The headless host writes the same cron audit rows the interactive extension does, so
1491
+ `/triggers audit` is no longer blank for exactly the hours nobody was watching.
1492
+ - A session says what it starts with: how many loops and rules are active here and when the next
1493
+ one is due, printed on every start.
1494
+ - `/triggers running` shows how long each run has been going and, for loop runs, the transcript
1495
+ being written right now — "is it stuck or is it working" no longer waits for the run to end.
1496
+ - A deduplicated push says so instead of vanishing into an audit row.
1497
+ - `pi-loops host status` falls back to the recorded pid and log path when the host does not answer,
1498
+ instead of reporting that no host is running; `host stop` escalates to SIGTERM.
1499
+ - The host's snapshot carries health: the last few runs and their outcomes, jobs currently in error,
1500
+ the next due time and today's spend. A host that has failed every run for six hours no longer
1501
+ reads exactly like one that succeeded an hour ago.
1502
+ - `[danger] allow` lets a project permit the exact command prefix an unattended run needs, without
1503
+ opening the whole class.
1504
+
1505
+ ### Fixed
1506
+ - `jobs.json` is version 2. The constant had been 1 since 0.1.0 while the on-disk shape gained
1507
+ `host` (which gates dispatch), `verify`, `timeoutMs` and the failure counter, so an older
1508
+ pi-loops sharing a `$HOME` silently rewrote the file without them.
1509
+ - `polls.json` drops slots nobody has claimed for a day; it only ever grew, one entry per project
1510
+ and session, and is read and rewritten on every tick.
1511
+ - A presence entry from a machine whose clock is ahead ages out. A negative age never exceeded the
1512
+ staleness window, so such an entry kept a dead session's jobs from ever being parked.
1513
+ - A failed atomic write removes its temp file. On a full disk that was one abandoned file per
1514
+ process per tick, consuming inodes long after the failure itself was handled.
1515
+ - Trigger transcripts are kept per project rather than sharing one 40-file budget across the
1516
+ machine, which three projects polling every ten minutes exhausted within hours.
1517
+ - A job that fails three times in a row is retried on a widening gap (5 minutes, doubling, capped at
1518
+ six hours) instead of at every due tick. A loop whose sub-agent killed the process re-fired on the
1519
+ very next start, in a loop, with nothing counting the failures.
1520
+ - Quitting no longer claims a hand-off that did not happen: it waits for the host to record itself,
1521
+ and says automation is not running if it never does. A host that died during module resolution
1522
+ used to be announced as a success.
1523
+ - A holder that overran the stale window no longer deletes the lock of whoever broke it, which let a
1524
+ third caller in and lost writes. Each holder writes a token and only releases its own lock.
1525
+ - A project MCP tool whose name collides with a built-in is offered as `<server>_<tool>` rather than
1526
+ silently dropped, as the interactive path already did.
1527
+ - A run in an untrusted project says so once, instead of silently losing that project's AGENTS.md,
1528
+ skills, extensions and settings.
1529
+
1530
+ ### Added — what automation costs, and a cap on it
1531
+ - `[limits] daily_budget_usd` stops dispatching once today's automation has cost that much. Loop
1532
+ runs and trigger checks both stop, the job says why in `/cron`, and the slot stays owed rather
1533
+ than being skipped, so work resumes when the day rolls over or the cap is raised. There is the
1534
+ same primitive and never exposes it, because its loops die with the session; a headless host runs
1535
+ for days, so nothing else bounds the bill.
1536
+ - `/cron cost [today|7d|all]` adds up the run log by job and shows today's spend against the budget.
1537
+ Every number was already recorded and nothing added them up.
1538
+ - The `/goal` evaluator is recorded in the run log like any other model call. It used to be spend
1539
+ that appeared nowhere at all.
1540
+ - Trigger checks and actions share `[cron] max_concurrent_runs`. Spawning every accepted trigger
1541
+ concurrently, which a person watching the feed bounds in practice; unattended, a server pushing
1542
+ distinct events opened one sub-agent per event with no limit.
1543
+ - `/cron clear <ref>` releases a `running` marker left by a process that is gone. When its pid has
1544
+ been reused, nothing could clear it and the loop was parked for good; hand-editing `jobs.json`
1545
+ was the only way out.
1546
+
1547
+ ### Fixed
1548
+ - A timestamp from the future no longer wedges the clock. A wrong clock later corrected by NTP, a
1549
+ restored VM snapshot or a synced `$HOME` from a machine that was ahead used to leave `lastDueAt`,
1550
+ `lastFiredAt`, the poll ledger and the dedup window in a state where every comparison skipped
1551
+ forever — the job never fired again while `/cron` still rendered a next run.
1552
+ - `inbox.jsonl` is rotated past 1 MB, dropping the oldest already-triaged entries and never
1553
+ anything still unread. It was the one log with no cap, and `newCount()` re-parses it on every
1554
+ badge refresh.
1555
+ - `host.log` is rotated past 2 MB. It is the file the docs tell users to read, it also carries the
1556
+ host's stdout and stderr, and it was unbounded.
1557
+ - Rules whose project no longer exists are disabled with the reason, like cron jobs already were.
1558
+ They used to start a sub-agent in the missing directory every poll interval, forever.
1559
+
1560
+ ### Fixed — the pre-release security review
1561
+ - `[danger] allow` means one command, not a prefix. It matched by raw prefix, so
1562
+ `allow = ["rm -rf /var/cache/mybuild"]` also permitted `rm -rf /var/cache/mybuild; rm -rf /` —
1563
+ arbitrary shell handed to exactly the actor the gate exists to stop, a model that may have been
1564
+ prompt-injected by repo content or tool output. Nor could "arguments may follow" be salvaged:
1565
+ `rm -rf /var/cache/mybuild /` needs no metacharacter at all, and an allowed wrapper (`ssh host`,
1566
+ `docker run`) would carry a whole second program as its arguments. An entry now matches that
1567
+ command exactly, or the same command aimed at a path strictly inside the one it names
1568
+ (`…/mybuild/tmp`, never `…/mybuild/../..`). The `rm -rf` scan also looks inside `` ` `` and
1569
+ `$( )`, so `echo $(rm -rf /)` is no longer invisible to it.
1570
+ - The daily budget survives log rotation. It was summed from `runs.jsonl`, which is halved once it
1571
+ passes 1 MB — so on a busy machine the morning's costs disappeared and the cap read the day as
1572
+ cheap and resumed dispatching. Rotation now folds what it drops into a small per-day ledger, and
1573
+ `/cron cost` says how much of the total came from there.
1574
+ - A run whose timestamp will not parse no longer counts toward today forever. `NaN < since` is
1575
+ false, so one such record above the cap would have paused every job on the machine permanently.
1576
+ - The budget also gates plain (non-stateful) jobs. Injecting one makes the parent agent take a
1577
+ billed turn, and the check sat after the branch that handles them.
1578
+ - `/cron gc` collects this project's dead jobs, not the machine's. It deleted other projects' jobs —
1579
+ and with `--purge` their loop state — from a session that had never listed them; `--all` is now
1580
+ how you ask for that.
1581
+ - A lock holder whose token file has vanished no longer removes the directory, and neither does one
1582
+ that never managed to write a token. That was the same race the token was added to close,
1583
+ reopened from the other side: it fired in the window between a new holder's `mkdir` and their
1584
+ token write.
1585
+ - A one-shot that already ran — or whose slot the scheduler declined because catch-up was off —
1586
+ stays retired across a clock correction, which used to drop its stamps and make it owe its single
1587
+ slot again.
1588
+ - `pi-loops inspect` and `pi-loops import` strip control characters, newlines and bidi overrides
1589
+ from what they print of an archive. The archive is a file someone sent you and `inspect` is what
1590
+ you run before trusting it, so escape sequences in a prompt could repaint the listing you were
1591
+ reading it for, or forge a row in it.
1592
+ - A running process's log is never pruned, however old it looks. A headless host that has been up
1593
+ for days is exactly the log someone goes looking for.
1594
+
1595
+ ## [0.2.1] - 2026-09-09
1596
+
1597
+ ### Fixed — found by the third audit, mostly in 0.2.0's own new code
1598
+ - `pi-loops import` restored the transcript into a directory pi never reads. It hand-rolled the
1599
+ project directory name (`encodeURIComponent`) while pi uses `--home-u-proj--` and `list()` reads
1600
+ only that one directory — so the documented "restore on a fresh machine" flow imported a session
1601
+ `/resume` could not see. It now asks pi for the name.
1602
+ - `/goal`'s evaluator judged only the run that had just ended, not the conversation. `agent_end`
1603
+ carries that run's messages, so evidence produced in an earlier turn was invisible and a
1604
+ satisfied goal kept returning "insufficient evidence" until the continuation budget ran out. It
1605
+ now reads the active branch through `sessionManager.buildContextEntries()`, reading its
1606
+ transcript snapshot.
1607
+ - A goal no longer evaluates after a turn the user aborted or the provider failed, so Esc actually
1608
+ stops a goal instead of paying for one more evaluator call and being sent back to work; `/goal
1609
+ pause|clear` and setting a new condition abort an evaluation already in flight; and a decision
1610
+ about a goal the user has since changed is discarded rather than written over the new one.
1611
+ - A goal continuation is delivered with `deliverAs: "followUp"` when the session is not idle, like
1612
+ every other injection site. It used to throw into a swallowed rejection and be lost, after the
1613
+ iteration had already been counted.
1614
+ - `/goal pause|resume|clear` are matched as whole words. `/goal clear all the type errors and get
1615
+ CI green` wiped a live goal instead of setting that condition; `/goal start …` is now refused
1616
+ with usage rather than becoming a condition named "start …". The evaluator also has its own
1617
+ 2-minute timeout instead of the 15-minute trigger timeout, and its outcomes reach stderr in
1618
+ non-UI modes.
1619
+ - Run cards, trigger cards and catch-up notices go to the project whose work they report, not to
1620
+ whichever window happens to own the timer.
1621
+ - Every listing now uses the same project predicate as the runtime (realpath + containment), so a
1622
+ pi opened in a subdirectory, a worktree or through a symlink no longer shows "(none in this
1623
+ project)" while that project's rules fire into its chat. This covers `/cron`, `/triggers rules`,
1624
+ `/triggers audit`, the panel, both numeric-ref resolvers and the model-facing `cron_list` /
1625
+ `list_triggers`.
1626
+ - A job or rule stamped with another machine's hostname is marked `[other host: <name>]` and shows
1627
+ no next run — it never had one, since the scheduler filters it out. `/cron set <ref> --host here`
1628
+ (and `--host -` for any machine) re-homes it, which a renamed machine or a rebuilt container
1629
+ needs as much as a second machine does.
1630
+ - An existing but empty `jobs.json` is treated as damage instead of "no jobs", so the next tick can
1631
+ no longer overwrite every job with an empty store; the last content that parsed is kept as
1632
+ `jobs.json.bak`; and `writeFileAtomic` fsyncs the file and its directory so a crash cannot leave
1633
+ the rename applied and the data missing.
1634
+ - A corrupt store can no longer kill the session. The badge and panel paths report the file and the
1635
+ problem once instead of throwing, the tick has a last-resort catch, and both leadership-hook call
1636
+ sites are guarded — pi installs no `unhandledRejection` handler, so any of those was fatal.
1637
+ - The goal evaluator's transcript is redacted before it is sent and before it is kept as a
1638
+ sub-agent transcript — it now carries the whole branch, not one run's messages.
1639
+ - A damaged store can no longer abort `session_shutdown` half way and strand MCP child processes:
1640
+ the hand-off decision is isolated, so the hooks, the MCP pool and the servers are always torn
1641
+ down. A tick that fails entirely is reported as a warning (and on stderr without a UI) rather
1642
+ than as routine chatter, and the dead-session check joins its guarded neighbours.
1643
+ - `remove_trigger { all: true }` counts the rules it will actually remove: the approval preview and
1644
+ `clear()` now use the same project predicate.
1645
+ - `jobs.json.bak` is written atomically, so a kill mid-write cannot destroy the backup the error
1646
+ message points at.
1647
+ - Inbox appends wait for their lock instead of spinning on it. A lock directory left by a killed
1648
+ process froze the whole process for the full stale window (measured: 10 seconds with zero event
1649
+ loop ticks, once per finding); both lock helpers now also wait longer than a lock takes to go
1650
+ stale, so a stale lock is broken rather than waited out and then thrown on.
1651
+
1652
+ ## [0.2.0] - 2026-09-09
1653
+
1654
+ ### Added — three things that were missing
1655
+ - **`/goal <condition>`** (`src/goal.ts`): the session is held to a stop condition.
1656
+ After every settled turn an evaluator with no tools judges the condition against a bounded
1657
+ transcript and either stops with the evidence, sends the agent back to work with what is missing,
1658
+ or pauses. At most 8 continuations; an evaluator that cannot decide pauses rather than looping;
1659
+ the state is appended to the session so `--resume` picks it up. `/goal pause|resume|clear`.
1660
+ - **A command line** (`pi-loops export|import`, `src/cli.ts`): archives as
1661
+ subcommands that need no pi session, for backups from cron or CI and for restoring on a fresh
1662
+ machine. `--session` takes an id or a unique prefix, `--activate-triggers=off|ask|on` says what to do with
1663
+ the automation inside, and a `.piesession` archive is accepted for its automation sidecars.
1664
+ - **A window into the headless host** (`pi-loops host status|abort|stop`, `src/host-control-channel.ts`):
1665
+ while no pi is open the host publishes what it is running — loop runs, trigger checks, what is
1666
+ enabled, the inbox count, each MCP server's state — over a 0600 unix socket, and one run or check
1667
+ can be interrupted. `/cron host` shows the same snapshot. Read-mostly on purpose: a host you
1668
+ could prompt would be a second chat.
1669
+
1670
+ ### Security — found by the pre-release review
1671
+ - **An unattended run trusts only the exact directory the user trusted** (`src/trust.ts`). pi's own
1672
+ trust lookup inherits from ancestors, which is right for a person opening a subdirectory and wrong
1673
+ for a job whose cwd a model can choose: `<trusted repo>/node_modules/anything` used to count as
1674
+ trusted, so its `.pi/mcp.toml` could have its `command` spawned by the headless host with nobody
1675
+ watching.
1676
+ - **An imported archive's schedule is validated** (`isValidSchedule`). A hand-made `.pisession`
1677
+ could carry `{kind:"cron",expr:"nope"}` or `{kind:"every",ms:0}`, and the throw from `computeDue`
1678
+ escaped the tick — killing an interactive pi outright (pi installs no `unhandledRejection`
1679
+ handler) and stopping the headless host's clock. A job that is somehow still unusable is now
1680
+ disabled with the reason instead of taking the tick down.
1681
+ - The dangerous-command gate is no longer walked past by quoting (`su''do`), extra flags
1682
+ (`chmod -R 777 /`), a second pipe (`curl … | tee … | bash`), command substitution
1683
+ (`eval "$(curl …)"`), a force refspec (`git push origin +main`), or an unresolved target
1684
+ (`X=/; rm -rf $X`).
1685
+ - The goal's continuation budget cannot be defeated by a `goal_state` entry with a non-numeric
1686
+ `iterations` (an archive carries those verbatim), and the evaluator's reason is redacted and
1687
+ capped before it is handed back to the agent as a user message.
1688
+ - The loops directory is created 0700 and the host's control socket is closed rather than left
1689
+ reachable if its chmod fails; `listen()` creates it with the process umask, so the directory mode
1690
+ is what closes that window.
1691
+ - `withFileLockSync` waits longer than a lock takes to go stale, so a lock left by a killed process
1692
+ is broken instead of waited out and then thrown on.
1693
+ - Suppressing extension staleness across shared sub-session runs no longer disables pi's event-bus
1694
+ unsubscribers, which leaked every subscription a shared extension made in a long-lived host.
1695
+ - A rule created with `/` or `$HOME` as its project governs only itself, not everything beneath it.
1696
+
1697
+ ### Changed — multi-project correctness
1698
+ - A rule belongs to the session that created it: while that session is open, its own window runs
1699
+ its checks and receives its promotions. Only when the creating session is gone does the project's
1700
+ owner take over. Two windows in one repo no longer answer each other's triggers.
1701
+ - A project is a realpath, not a string: a pi opened in a subdirectory, through a symlink or in a
1702
+ worktree is the same project as the rule or job that names its root, for ownership, promotion
1703
+ routing, the audit filter and the listings.
1704
+ - An MCP push deferred to a window that never claims it is taken back by the process that received
1705
+ it, instead of being lost with a `deferred` audit row.
1706
+ - A promoted result carries a default template (`<source> fired <event>.\nResult: …`), and the
1707
+ trigger audit records the idempotency key, the replacement policy and the arrival time, so a
1708
+ dedup window can be reconstructed afterwards.
1709
+ - A check killed by the run timeout still disarms the fire-once rules whose action already ran, so
1710
+ an action with external side effects is not repeated on the next poll.
1711
+ - A sub-agent resolves its model through the parent's runtime, so `pi --api-key`, `/login` and a
1712
+ rotated credential reach loop runs. A pinned model that stops resolving (or loses its credential)
1713
+ falls back to the session's model with a warning on the run record instead of failing daily.
1714
+ - Extension instances are loaded once per project and reused across runs, and a run no longer emits
1715
+ `session_shutdown` to them — a `-e` extension that opens a browser is no longer re-opened per run
1716
+ and no longer torn down under the interactive session.
1717
+ - The run deadline and abort now cover setup, so a stalled `npm`/`git clone` in a project's package
1718
+ resolution cannot hold a job's claim and a concurrency slot forever.
1719
+ - Run records keep cache tokens and record provider retries and context compactions, and `/cron`
1720
+ shows them: a run that silently retried five times no longer looks identical to a clean one.
1721
+ - `jobs.json` is only written when something changed, and an idle machine no
1722
+ longer creates it at all. A `version` newer than this build understands is refused, not rewritten.
1723
+ - The run log rotates under its own lock, so records appended during a rotation are not dropped.
1724
+ - A deferred run (concurrency cap) says so in `/cron` instead of looking like it never ran.
1725
+ - A failed one-shot job is retried once and then removed, instead of sitting enabled forever with
1726
+ no next run.
1727
+ - Importing an archive is idempotent: the same archive imported twice adds nothing the second time.
1728
+ An export carries the automation the exporting session created, not every session's in the
1729
+ project. A transcript with duplicate ids or dangling parents is refused instead of silently
1730
+ truncating history when the session is opened.
1731
+
1732
+ ### Security — what an unattended run may do
1733
+ - Loop, checker and trigger sub-agents run under the dangerous-command policy
1734
+ (`src/danger.ts`): sudo, `curl … | sh`, `dd` to a block device,
1735
+ `mkfs`, `chmod 777 /`, shutdown/reboot, `git push --force` on main/master, pipes into `eval`,
1736
+ the fork bomb, and `rm -r -f` aimed at `/`, an absolute path or `$HOME` are refused before they
1737
+ run, with the reason handed back to the model. Cloning the parent's tool-call hook into
1738
+ every sub-agent; pi has no built-in denylist, so the gate is injected into each sub-session
1739
+ (`src/subagent-guard.ts`).
1740
+ - `/triggers remove --all` and `remove_trigger{all:true}` clear only the current project.
1741
+ `/triggers remove --all-projects` is the new opt-in for the machine-wide sweep.
1742
+ - `cron_list` and `list_triggers` show the calling project's automation; `all_projects: true`
1743
+ asks for the rest. Another project's prompts no longer reach a model that never asked for them.
1744
+ - `cron_remove` goes through the same confirmation gate as the other control-plane tools, so a
1745
+ sub-agent can no longer delete a job (with its loop state and transcripts) unapproved, and a
1746
+ job outside the current project needs its exact id.
1747
+ - An MCP config file can only name an environment variable prefixed `PI_MCP_TOKEN_` as a bearer
1748
+ credential; pi's credential store is unchanged. A project file naming `ANTHROPIC_API_KEY` no
1749
+ longer sends it to that server's endpoint, and the error no longer echoes the ref.
1750
+
1751
+ ### Changed — a run belongs to its project, not to the window that happens to run it
1752
+ - A sub-agent inherits the parent session's active tools (`pi.getActiveTools()`), which hands
1753
+ its sub-agent the parent's live tool list. It used to fall back to pi's four-tool default, which
1754
+ both dropped what the session had (grep, find, web_fetch…) and restored what `-xt` had taken
1755
+ away. A job's `--tools` still narrows that set and can no longer widen it.
1756
+ - A run in another project gets that project's own MCP servers (`src/mcp-pool.ts`), connected on
1757
+ demand and only when the user has trusted that project. The interactive process used to lend
1758
+ every run its own project's servers, and the headless host had none at all, so the same loop
1759
+ behaved differently depending on who owned the clock.
1760
+ - The automation tools a sub-agent calls act in that run's project and model. A loop for project B
1761
+ that scheduled a follow-up used to pin it to whichever project the running pi was open in; the
1762
+ headless host already did this correctly.
1763
+ - A run interrupted by quitting, a session swap (`/new`, `/resume`, `/reload`, `/fork`) or
1764
+ `/cron abort` hands its slot back instead of counting as a run, so the next tick re-fires it
1765
+ rather than skipping to the next due time. pi rebuilds the extension on a session swap, so the
1766
+ scheduler still stops there — but the tick is no longer lost.
1767
+
1768
+ ### Fixed
1769
+ - A `--verify` loop is no longer re-fired while its checker is still running. The run id now
1770
+ covers both sub-agents, so the overlap guard, `/triggers running`, the concurrency cap and
1771
+ abort all cover the checker phase (findings were entering the inbox twice, billed twice).
1772
+ - A run interrupted by a crash is recovered by more than its pid: a marker written before the
1773
+ last boot is treated as dead (a recycled pid used to park the job forever), and a marker from
1774
+ another machine on a shared `$HOME` is left to that machine for a day instead of being cleared
1775
+ or trusted. `RunningMarker` records its host.
1776
+ - `Inbox.append` takes the inbox lock, so a finding written while `/inbox dismiss|clear` rewrites
1777
+ the file is no longer lost. With machine-global loops the concurrent case is the normal one.
1778
+ - A promoted trigger result keeps its line structure (`capRedacted`): diffs, file contents and
1779
+ test output arrive in the chat and in the audit as themselves, not collapsed onto one line.
1780
+ The one-line TUI previews still collapse, as before.
1781
+ - An imported archive is re-stamped with this machine's hostname, so restored automation runs
1782
+ instead of sitting enabled and silent on the machine it was imported to.
1783
+ - A streamable-HTTP server that answers `405`/`404` on the optional GET stream stays usable: tool
1784
+ calls keep working and the source no longer re-handshakes in a hot loop (the spec makes the
1785
+ server→client stream optional, and POST stays independent of it).
1786
+ - The reconnect budget is refunded only after a connection has lasted 30 seconds, so a server that
1787
+ answers `initialize` and then exits is retried a bounded number of times instead of forever.
1788
+ - A `Mcp-Session-Id` the server rejected (`404`/`400`) is dropped before the next attempt, so a
1789
+ restarted remote server recovers; a plain reconnect still resumes the stream with `Last-Event-ID`.
1790
+ - Only a real `401`/`403` marks a server `auth_failed`. A command path containing "auth"
1791
+ (`authbind`, `/opt/oauth-mcp/…`) used to disable the server for the life of the process.
1792
+ - A stdio MCP server that ignores SIGTERM is SIGKILLed after two seconds instead of being leaked.
1793
+ - A source parked on a server with no push stream reconnects when that server rejects its session,
1794
+ instead of looking connected while every tool call fails.
1795
+ - `Last-Event-ID` is recorded only from the server→client stream, not from POST response streams
1796
+ whose ids belong to a different space.
1797
+ - MCP sources are restarted when a session swap changes the config or the project's trust; they
1798
+ used to keep running while the panel described the new configuration.
1799
+ - Project MCP servers lent to another project's run are re-checked against that project's trust on
1800
+ every run, disconnected when trust is revoked, and the pool is bounded (8 projects, least
1801
+ recently used dropped).
1802
+ - `PI_MCP_TOKEN_` is enforced where the environment is actually read, in both the interactive
1803
+ extension and the headless host. The restriction was previously bypassed by their own resolver.
1804
+ - A project's MCP tool can no longer shadow a pi built-in in the headless host (`read`, `bash`,
1805
+ `grep`… are reserved everywhere, not just where a pi session could be asked).
1806
+ - The model-facing tools take an id, prefix or name, never a bare ordinal: the list a model sees is
1807
+ not the one the user is looking at.
1808
+ - Sub-agents cannot request the machine-wide listing (`all_projects` is ignored above hop 0), and
1809
+ disabling another project's job or rule needs the same approval enabling does.
1810
+ - A run whose bookkeeping throws releases its run id instead of parking the job forever.
1811
+ - `withFileLockSync` honours its deadline on every path, so an unreadable lock directory cannot
1812
+ spin with the event loop blocked.
1813
+ - `rm -r -f /` is refused even when `HOME` is unset (only the `~`/`$HOME` rules need it).
1814
+
1815
+ ## [0.1.3] - 2026-09-09
1816
+
1817
+ ### Added — nobody around: a headless host keeps the clock
1818
+ - When the last interactive pi on the machine quits with loops, rules or MCP servers configured,
1819
+ it starts a headless host (`src/host.ts`: same stores, same in-process runner, its own MCP
1820
+ clients) that keeps running everything except chat-bound inject jobs; chat-bound results go to
1821
+ the inbox. The first pi to open takes the clock back (an interactive scheduler preempts a `host`
1822
+ leader) and the host exits. `/cron host [start|stop]`, `[host] auto`, `host.json` / `host.log`.
1823
+ `scripts/pi-loops-host.sh` is gone. Tools a host-run sub-agent uses act in that run's project and
1824
+ model, and its control-plane operations are audited into `triggers-audit.jsonl`
1825
+ (`cron_control_plane`); a host record whose process is gone is reported as a crash by the next
1826
+ pi, never signalled (pid-recycling, boot-time and exact entry-path guards). The host takes the
1827
+ handing-off pi's model and thinking level for unpinned work, writes its record under a lock so
1828
+ two pis quitting together leave exactly one host, and evaluates an MCP push once per project
1829
+ that has rules, in that project. `/cron host start|stop` override `[host] auto` for that pi.
1830
+ - A scheduler tick no longer waits for the run it starts: heartbeats, leadership, presence and
1831
+ trigger checks keep going during long runs, `/cron run` returns at once, and `stop()` waits
1832
+ (bounded) for aborted runs to write their records.
1833
+
1834
+ ### Changed — sub-agents run in-process
1835
+ - Loop runs, maker/checker runs and trigger checks/actions are no longer `pi -p` child processes.
1836
+ Each is an `AgentSession` opened inside the interactive pi through pi's SDK (`src/sdk-runner.ts`):
1837
+ fresh context and its own transcript file, but the parent's live MCP client instances (a browser
1838
+ tab or database session opened in the chat is the one the loop sees), its `-e` extensions,
1839
+ system-prompt and skill flags, its model unless the job pins one, and the project's trust when
1840
+ the run is in the same project. Nothing is re-spawned per run; the cold-start cost is gone.
1841
+ `PI_LOOPS_CHILD`, `PI_LOOPS_HOP`, `PI_LOOPS_PARENT_*` and `PI_LOOPS_PI_BIN` no longer exist.
1842
+ - Sub-sessions get the automation tools at hop 1 as custom tools (`cron_create`, `cron_remove`,
1843
+ listing, disabling); Prompt-class operations stay denied there, and a sub-session never loads a
1844
+ second copy of this extension or runs the trigger runtime. The parent's extensions receive
1845
+ `session_start` and `session_shutdown` in each sub-session, like pi's own headless modes.
1846
+ - Project-local resources of a sub-session's cwd are loaded only when that project is trusted —
1847
+ by this session, or by a decision pi saved earlier — never by default.
1848
+
1849
+ ### Changed — the scenarios the old "by design" choices had closed
1850
+ - A project's dynamic checks and push evaluations now run in a pi that is open in that project
1851
+ (preferring the session that created the rules; `presence/` registry), so `promote_to_chat`
1852
+ lands in the right chat. The machine leader covers only
1853
+ projects with no pi open (results to the inbox). The poll interval is enforced machine-wide.
1854
+ - A plain cron job created by a sub-agent binds to the session the sub-agent acts for, not to
1855
+ the sub-agent's own throwaway session.
1856
+ - MCP pushes: injected pushes reach every window that has the server (per-process dedup), rule
1857
+ evaluation happens once per project by its owner; no more first-window-wins.
1858
+ - Model, thinking level and timeout of a job or rule are editable: `/cron set`, `/triggers set`
1859
+ (`--model -` follows the running session). Trigger checks/actions are capped by
1860
+ `[triggers] run_timeout_secs` (900) or the rule's `--timeout` instead of a fixed 15 minutes.
1861
+ - Sub-agents inherit the parent pi's runtime flags (`-e`, `--append-system-prompt`,
1862
+ `--system-prompt`, `--skill`, `--no-skills`, …) and the project's trust when the parent trusted
1863
+ the same project.
1864
+ - Plain jobs whose session no longer exists are parked as disabled by the leader; `/cron gc`
1865
+ removes them. `/triggers rules` marks rules created by another session.
1866
+ - Trigger audit rows also become session custom entries (`trigger`, `trigger_result`,
1867
+ `trigger_promotion`) with the project's `cwd`; `/triggers audit [N] [--all]` shows this
1868
+ project's rows by default.
1869
+ - Hooks are awaited inline (`[hooks] mode = "async"` for the old queued behavior).
1870
+ - `PI_LOOPS_HOST=1` lets a `pi -p` run host the timer for as long as it lives.
1871
+ - `[cron] catch_up = false` switches start-up catch-up off for every job (the global switch wins
1872
+ over `--catchup`); `[cron] max_concurrent_runs` bounds the burst.
1873
+ - Jobs and rules record their `host`; other hosts sharing `$HOME` ignore them, leader election is
1874
+ per host (`scheduler.<host>.json`), and orphan detection never disables another host's loop.
1875
+ - A promotion while the agent is busy goes to the follow-up queue and runs a turn after the
1876
+ current one.
1877
+
1878
+ ### Changed — the small things, fixed
1879
+ - Ids are `cron-<32 hex>`; `inbox.jsonl` keeps a stable record shape on disk
1880
+ (`created_at`, `trace_id`, `session_id`, …) and still reads lines written by earlier versions.
1881
+ - Cron control-plane audit entries use the custom type `cron_control_plane` and carry an
1882
+ `audit_entry_id`, which `cron_create` / `cron_remove` / `set_cron_job_state` return in `details`;
1883
+ `cron_create` answers with three lines and `cron_list` details include `next_run` and
1884
+ `last_due_at`; `verify = true` implies `stateful` on the tool path as on the slash path.
1885
+ - `/inbox` lists the full finding with a `created_at[..16]` timestamp; `/cron` shows a
1886
+ `last fired:` line; `/cron`, `/triggers` and `/new-trigger` have fixed usage and error wording;
1887
+ `/triggers enable|disable` prints condition/action/fire-once; `/triggers sources` lists MCP
1888
+ servers, the cron hook and the dynamic checker in registration order and `/triggers status` adds
1889
+ a `sources: N total, M connected, K require attention` line.
1890
+ - Tool confirmations show an approval card (Action / Tool / value-free Reason /
1891
+ args hash / redacted Preview) and log `approval required` / `approved` / `denied` feed lines;
1892
+ `new_trigger` requires `condition` and `action` and rejects unknown fields.
1893
+ - Promotions and injected summaries are `[Trigger <trace>] <text>`
1894
+ (the `<source> fired <event>. Result:` wrapper is gone); running-trigger previews are 80 chars of
1895
+ the action prompt; inject-and-run turns announce `running triggered turn (trace …)`.
1896
+ - Side panel: a Polling entry (source / event, trace, summary — shown whenever a check ran),
1897
+ MCP aggregate (`servers N · tools M · notification hooks N`), and Hooks / Runtime sections.
1898
+ - Hooks: every payload field is present (`null` when absent), custom messages report their
1899
+ `customType` as `message_kind`, failures reach stderr when there is no UI, `<project>/.pie/hooks.toml`
1900
+ is read when `.pi/hooks.toml` is absent (same for `mcp.toml`).
1901
+ - MCP: a repeated server name replaces the earlier entry with a diagnostic; a successful
1902
+ push clears `last error`; stdio stderr is reported separately as `stderr:`; dedup audit records
1903
+ the first arrival's replacement policy; idempotency keys hash any Unicode control character;
1904
+ the SSE frame cap counts bytes; stdio-server validation no longer says `streamable_http`.
1905
+ - Session archives: a sensitivity warning is printed first and on failure; the imported header
1906
+ drops the source machine's parent-session pointer.
1907
+ - Redaction masks browser-login and loopback-callback URLs; an invalid poll interval
1908
+ (config or `--trigger-poll-secs`) is diagnosed instead of silently ignored; the loop prompt's
1909
+ `[loop-state]` line is worded once and kept; User-Agent / MCP clientInfo carry the real version.
1910
+ - `examples/mcp-notify-server.mjs`: a dependency-free MCP push server.
1911
+
1912
+ ## [0.1.2] - 2026-09-09
1913
+
1914
+ ### Fixed
1915
+ - streamable_http MCP sources: the idle timeout was a deadline on the whole GET stream, so a busy
1916
+ stream was cut every `sse_idle_timeout_ms` (60 s), failing in-flight calls and re-handshaking.
1917
+ It now bounds only the wait for the response headers and for each chunk.
1918
+ - Sub-agent processes (`pi -p` loop runs and trigger checks) consumed MCP pushes and could spawn
1919
+ nested trigger sub-agents with no ceiling. Sub-agents keep the MCP tools but ignore
1920
+ pushes, and the trigger runtime audits anything reaching hop ≥ 1 as `cycle_suppressed`.
1921
+ - `/session-export --exclude-triggers` still bundled cron jobs and loop state; it drops
1922
+ every automation sidecar. `/session-import` validates all sidecars before writing the session
1923
+ file and rolls back store writes on failure, so a rejected archive leaves nothing behind.
1924
+ - A failing audit or dedup write inside trigger handling became an unhandled rejection. Audit
1925
+ writes are best-effort (`lastPersistenceError`, logged once per distinct
1926
+ error), `TriggerRuntime.handle()` never rejects, and scheduler hook failures cannot strand a run.
1927
+ - Prompt-class control-plane tools (`new_trigger`, `remove_trigger`, re-enabling a trigger or a
1928
+ cron job) were auto-approved in sub-agents; they are denied fail-closed there.
1929
+
1930
+ ### Security
1931
+ - `/session-import` rejects cron job and trigger rule ids that are not plain tokens: ids become
1932
+ file and directory names (`state/<id>.md`, `sessions/<id>/`), so an archive could otherwise
1933
+ reach outside the store through `/cron remove` or the import rollback.
1934
+ - streamable_http MCP: `stop()` during the handshake now aborts it (the connection controller is
1935
+ held from the first POST) instead of leaving an unowned event stream.
1936
+
1937
+ ## [0.1.1] - 2026-09-08
1938
+
1939
+ ### Fixed
1940
+ - `promote_to_chat` results and `inject_*` MCP feeds no longer land in another project's chat:
1941
+ they are promoted only into a chat in the rule's `cwd`, otherwise routed to the inbox (`redirected` in audit).
1942
+ - Project-level MCP servers' notifications were dropped in processes that did not own the timer.
1943
+ Every process now consumes what it receives; a machine-wide dedup window (`dedup.json`) keeps it to once per push.
1944
+ - Loops and trigger checks ran with the timer owner's model; jobs and rules now record the creating
1945
+ session's model/thinking and run with those.
1946
+ - A run that died with its process was skipped until the next slot; it is retried on the next tick.
1947
+ - Stdio MCP reconnects no longer notify on every attempt; each distinct error once, 20 attempts by default.
1948
+ - Queued lifecycle hooks are drained (≤3 s) on shutdown instead of being lost.
1949
+
1950
+ ### Changed
1951
+ - Plain (inject) jobs no longer catch up missed ticks by default; `--catchup` opts in. Loops still do.
1952
+ - Sub-agents keep the cron/trigger tools while `PI_LOOPS_HOP < 2` (hop-bounded cycle suppression) instead of never having them.
1953
+ - `/cron` marks plain jobs whose session is not open as `[dormant …]`; loops whose `cwd` vanished are auto-disabled and marked `[orphan]`.
1954
+ - `/cron status` means list; the scheduler view is `/cron scheduler`.
1955
+
1956
+ ## [0.1.0] - 2026-09-08
1957
+
1958
+ First release. A whole automation layer, as a pure pi extension.
1959
+
1960
+ ### Added
1961
+ - `/cron add [--stateful] [--verify] "<schedule>" <prompt>` with list/enable/disable/remove
1962
+ surface, schedule aliases (`hourly`, `daily`, `每小时`, …), `every 30m`, `in 10m`, `at <ISO>`.
1963
+ - Stateful loops: fresh `pi -p` sub-agent per run, ≤2000-char notes carried between runs
1964
+ (`<loop-state>`), findings routed to the inbox (`<inbox>`), transcripts kept (`/cron trace`).
1965
+ - Maker/checker (`--verify`): an adversarial second sub-agent keeps or drops each
1966
+ finding before it enters the inbox; fail-open on checker failure.
1967
+ - `/inbox` triage with fixed list formats and `new → claimed/dismissed` lifecycle;
1968
+ `/inbox claim` starts a real agent turn.
1969
+ - Dynamic triggers: `/new-trigger`, `/triggers status|rules|sources|enable|disable|remove|running|audit|abort`,
1970
+ `new_trigger` / `list_triggers` / `remove_trigger` / `set_trigger_state` tools, periodic sub-agent
1971
+ evaluation, fire-once, `promote_to_chat`, `[Trigger <trace>]` prefix, 5-minute dedup window.
1972
+ - MCP: notification sources (stdio + streamable HTTP) with an `mcp.toml` schema, dedup keys,
1973
+ redacted summaries, `inject_summary` / `inject_and_run`; server tools registered with the agent.
1974
+ - Lifecycle hooks (`hooks.toml`): events, payload, `PI_*` and `PIE_*` env, command + webhook,
1975
+ sequential execution, process-tree kill on timeout, project hooks gated.
1976
+ - Session archives: `/session-export` / `/session-import` (`.pisession`; a `.piesession`
1977
+ layout plus `loops/<id>.md` state files).
1978
+ - A side panel above the editor (`/cron panel on|off`), `Inbox: N new · running: …`
1979
+ status badge, run cards in the transcript.
1980
+ - Machine-global job store with leader election across pi processes, one-shot catch-up of missed
1981
+ ticks (`--no-catchup` to opt out), per-job transcripts, run log, redaction everywhere.
1982
+
1983
+ ### Deliberate design choices
1984
+ - Jobs and rules are machine-global with a `cwd`, not session-scoped; `/cron` and `/triggers rules`
1985
+ list the current project by default.
1986
+ - Missed ticks are caught up once by default, collapsed rather than replayed.
1987
+ - Prompts may be up to 8 KB.
1988
+ - MCP notifications are consumed by the single process that owns the timer; every process still
1989
+ connects for tools.