@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.
- package/CHANGELOG.md +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- 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.
|