@alphacoder-v0/pi-loops 0.22.8 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +114 -78
  4. package/README.zh-CN.md +62 -41
  5. package/docs/cli.md +352 -91
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +404 -61
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +41 -28
  17. package/docs/web-ui-parity.md +702 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/browser-open.ts +169 -0
  32. package/src/cli-entry.mjs +12 -3
  33. package/src/cli.ts +960 -143
  34. package/src/config.ts +53 -5
  35. package/src/conversation-rows.ts +469 -0
  36. package/src/danger.ts +227 -6
  37. package/src/engine.ts +1240 -0
  38. package/src/goal.ts +71 -0
  39. package/src/held-read.ts +27 -0
  40. package/src/hooks.ts +49 -18
  41. package/src/host-control-channel.ts +455 -40
  42. package/src/host-control.ts +602 -111
  43. package/src/host-entry.mjs +5 -3
  44. package/src/host-runtime.ts +455 -42
  45. package/src/host.ts +659 -123
  46. package/src/inbox-card.ts +278 -0
  47. package/src/inbox.ts +139 -62
  48. package/src/job-edit.ts +7 -1
  49. package/src/job-health.ts +14 -2
  50. package/src/job-lines.ts +201 -13
  51. package/src/job-owner.ts +74 -13
  52. package/src/job-tune.ts +110 -0
  53. package/src/lease.ts +537 -0
  54. package/src/lock.ts +7 -2
  55. package/src/mcp-pool.ts +66 -14
  56. package/src/mcp.ts +1 -1
  57. package/src/open-file.ts +54 -0
  58. package/src/page-children.ts +1507 -0
  59. package/src/page-devices.ts +192 -0
  60. package/src/page-server.ts +3150 -0
  61. package/src/path-precheck.ts +215 -0
  62. package/src/paths.ts +24 -0
  63. package/src/pi-floor.ts +10 -0
  64. package/src/pi-loops.ts +1370 -473
  65. package/src/pi-resolver.d.mts +1 -0
  66. package/src/pi-resolver.mjs +110 -10
  67. package/src/presence.ts +12 -63
  68. package/src/protocol.ts +10 -1
  69. package/src/recipe.ts +43 -1
  70. package/src/requests.ts +354 -0
  71. package/src/rule-lines.ts +68 -0
  72. package/src/run-card.ts +105 -0
  73. package/src/runner.ts +18 -1
  74. package/src/schedule.ts +1 -2
  75. package/src/scheduler.ts +595 -245
  76. package/src/sdk-runner.ts +321 -27
  77. package/src/seen.ts +278 -0
  78. package/src/session-head.ts +21 -0
  79. package/src/share.ts +12 -2
  80. package/src/snapshot.ts +3 -2
  81. package/src/store.ts +81 -8
  82. package/src/tools.ts +196 -65
  83. package/src/trigger-runtime.ts +164 -127
  84. package/src/triggers.ts +39 -7
  85. package/src/trust.ts +46 -2
  86. package/src/ui-prefs.ts +22 -7
  87. package/src/watch-plan.ts +174 -0
  88. package/src/web.d.mts +3 -0
  89. package/src/web.mjs +2261 -2340
  90. package/src/window-executor.ts +459 -0
  91. package/src/window-guard.ts +158 -0
  92. package/docs/agents/issue-tracker.md +0 -34
@@ -20,8 +20,83 @@ weaker, and worth converting whenever one of them breaks.
20
20
  - [x] Submitting while a turn is running queues instead of racing it, the way the TUI does — and a
21
21
  slash command typed into it runs while the turn runs, as it does in the terminal.
22
22
  `test/web.test.ts`: *a command typed while a turn is running reaches pi as one it can run*.
23
- - [x] The queue is visible and can be cleared.
24
- - [x] Abort stops the current turn.
23
+ - [x] The queue is visible and can be cleared. `test/web-page.test.ts`: *the queue badge shows what is
24
+ queued and clears it*.
25
+ - [x] "queued N ✕" clears all N: every row it counts, pi-loops' included, is cancelled as its own ✕
26
+ would cancel it, and pi's own queue is emptied, as 0.22.8's clear emptied pi's whole queue.
27
+ `test/web.test.ts`: *clear cancels every row the badge counts, pi-loops' included, and empties
28
+ pi's own queue*.
29
+ - [x] What pi-loops addressed to this conversation is in the same queue as your messages, with its
30
+ source ("you", "cron nightly", "rule 3f2a…"), in the order it runs, cancellable one at a time.
31
+ A chat message is a request row in `requests/<session>.jsonl`, the file the pi holding the
32
+ session drains; the page reads the file for the list and the count. What pi itself still holds
33
+ (a slash command typed while busy) is listed after them as "pi", counted with them, and cleared
34
+ with them. `test/web-page.test.ts`: *the queue lists requests from every source, in order*.
35
+ - [x] A message you sent survives pi stopping and is offered, never sent on its own, by the next
36
+ page: "not sent before pi stopped", with send and discard. `test/web.test.ts`: *a message typed
37
+ before pi exits is offered, not sent, by the next page*.
38
+ - [x] A send pi refuses (compaction in progress, no credentials) comes back to the composer with the
39
+ error, images included. `test/web.test.ts`: *a send pi refuses comes back to the composer with
40
+ the error*.
41
+ - [x] A session also open in a terminal is answered by that terminal, and the page says so: "open in
42
+ a terminal: answered there", with no pid. `test/web.test.ts`: *a page's message for
43
+ a session a terminal holds is answered there*; `test/web-page.test.ts`: *a send answered
44
+ elsewhere says so, and one pi refused comes back to the composer with the error*.
45
+ - [x] It says so before you send, too: "Your terminal has this conversation. What you send here is
46
+ answered there." above the composer, and the composer's placeholder says where the answer
47
+ goes. Read from `/state`'s `holder`, which follows the rule `/prompt` uses for the line after a
48
+ send, so the two never disagree, and which does not depend on the list. Not shown for a
49
+ terminal that stands aside or a `pi -p` run, which do not answer. `test/web-page.test.ts`: *a
50
+ conversation a terminal holds shows the held line above the composer; one that stands aside,
51
+ or this page's own, does not*, *the held line comes from /state, so a list that fails still
52
+ shows it, and the chat still works*; `test/page-server.test.ts`: */state says who holds the
53
+ conversation, by the rule /prompt follows*.
54
+ - [x] The line promises only what happens. A message reaches the terminal only from a page pi with
55
+ pi-loops in it; one without (its launch did not load pi-loops) answers the message itself, and
56
+ the line then says so: answered here, beside the terminal. A conversation opened from the list
57
+ starts with the pi arguments its project's latest conversation was opened with, so a launch
58
+ that loads pi-loops with `-e` loads it there too; they are never taken from the request. A
59
+ row's `terminal` tag has a title only when that terminal answers nothing sent to it: the one
60
+ holder tag with a title, because its label cannot say that.
61
+ `test/web-page.test.ts`: *a terminal's conversation in a page whose pi has no pi-loops says it
62
+ is answered here, not there*, *a terminal that answers nothing is not said to answer, and that
63
+ is the one holder tag with a title*;
64
+ `test/page-server.test.ts`: */state says whether this page's pi has pi-loops, which is what
65
+ sends a message to the terminal*, *a conversation opened with no arguments of its own starts
66
+ with those of its project's latest conversation*.
67
+ - [x] A terminal that holds the session and cannot answer (its window stands aside, because its copy
68
+ of the conversation is behind the file; a one-shot `pi -p` run) is passed over: it says so on
69
+ its lease, the page's own pi answers your message, and the page does not call it "answered
70
+ there". `test/person-rows.test.ts`: *a browser message for a conversation whose terminal stands
71
+ aside, or runs pi -p, is answered once, by the page's own pi*; `test/web.test.ts`: *a message
72
+ nobody took says why after fifteen seconds, and can be sent here instead*.
73
+ - [x] A message of yours nobody took in fifteen seconds while pi is idle says so on its queue row,
74
+ "not taken (why)", and offers "send it here instead": the row leaves the queue, under
75
+ its lock and only while no holder has stamped it, and the text goes to the page's pi over rpc,
76
+ as every message went before 0.24; a message pi already has is refused, never answered twice.
77
+ When the page's own pi is the one standing aside there is no send-here, since anything written
78
+ then branches the conversation; the row and the send both say /resume. A terminal that holds
79
+ the session and delivers is not called unanswering: "another pi holds this conversation and has not
80
+ taken it yet (it may be busy)", and its offer reads "answer here instead (a branch the other pi will not
81
+ see)". `test/web.test.ts`: *a message nobody took says why after fifteen seconds, and can be
82
+ sent here instead*, *a page whose own pi stands aside says so and does not offer to send around
83
+ it*, *a holder that delivers but has not taken the message is not called unanswering, and the
84
+ offer says it forks*; `test/web-page.test.ts`: *a message nobody took says why and offers to send
85
+ it here, unless this page's pi is the one standing aside*, *a send into a page whose own pi
86
+ stands aside says so, and that pi reads it again*; `test/engine.test.ts`: *a row removed between
87
+ the drain's read and its hand-over is not handed over*. A page's own pi that stands aside (it
88
+ took the conversation back from a terminal that wrote after it read the file) reads the
89
+ conversation again from disk once it is idle, so its messages are answered without a /resume:
90
+ `test/page-server.test.ts`: *a page's pi that took its conversation back standing aside reads
91
+ it again from disk, once per wait*.
92
+ - [x] A page whose pi has no pi-loops in it still chats: the text goes to pi over rpc, no row is
93
+ written, and the queue shown is pi's own. The command list this is decided on is asked for
94
+ again after every slash command and when it is older than thirty seconds, so `/reload` is seen.
95
+ `test/web.test.ts`: *without pi-loops in the page's pi, chat goes over rpc as before, and no row
96
+ is written*, *a command list that changes is asked for again before a message is routed on it*.
97
+ - [x] Abort stops the current turn. `test/web-page.test.ts`: *the stop button asks the server to abort
98
+ the turn*; `test/web.test.ts`: *the run button sends pi the job's run command, and refuses an id
99
+ it cannot use* (which also posts the abort route).
25
100
  - [x] Busy, queued count, and "pi exited" are all visible without opening a terminal.
26
101
  `test/web-page.test.ts`: *a tool call and a dead pi both reach the page*.
27
102
  - [x] Prompt history: ArrowUp and ArrowDown, but only while the caret is on the first or last line,
@@ -55,8 +130,10 @@ weaker, and worth converting whenever one of them breaks.
55
130
  `test/web-page.test.ts`: *a phone's soft keyboard cannot steal the tap on send*.
56
131
  - [x] What is in the composer survives a reload, and belongs to the tab that typed it: the header's
57
132
  reload button, the stale-page banner and a phone that discards a backgrounded tab all come
58
- back to the same text, and a second tab of the same address keeps its own.
59
- `test/web-page.test.ts`: *a reload keeps what is in the composer, in that tab only*.
133
+ back to the same text, and a second tab of the same address keeps its own. A draft belongs to
134
+ its conversation too: a row of the list loads another one in the same tab, and the box there
135
+ starts empty. `test/web-page.test.ts`: *a reload keeps what is in the composer, in that tab
136
+ only*, *a draft typed in another conversation of this tab stays with that conversation*.
60
137
  - [x] The composer grows with what is typed into it, up to the ceiling the stylesheet declares, and
61
138
  comes back down when the text goes or is sent — a multi-line prompt is written and read at its
62
139
  own height rather than through a one-line slot. `test/web-page.test.ts`: *the composer grows
@@ -64,6 +141,28 @@ weaker, and worth converting whenever one of them breaks.
64
141
 
65
142
  ## What the feed shows
66
143
 
144
+ - [x] A conversation a terminal holds shows what the terminal wrote. The page reads it from the
145
+ file, never by switching its own pi to it. The terminal says on its lease when a turn starts
146
+ and ends, and the held line says "answering since 12:03" meanwhile; when the turn is over and
147
+ the file holds a new message, the page reads it again once, with "your terminal answered" as
148
+ the reason. A reload that starts while another one replays what it held draws each message
149
+ once. The run cards saved in the conversation are drawn where they were written, after a
150
+ reload too. A file over 32 MB is not read by the page; the page's pi answers it.
151
+ `test/page-server.test.ts`: *a terminal-held conversation is read from its file, never
152
+ by switching the page's pi, and read again once when the terminal's turn ends*; *a
153
+ terminal-held conversation's run cards come back with its messages, in file order*; *a held
154
+ conversation's file over 32 MB is not read in the always-on process*; *what a terminal added
155
+ is found to hold a message when a large result comes after the message line starts*; *a
156
+ pi-loops view is redacted and capped on the server before the page sees it*; *the page's pi's
157
+ conversation comes back with its run cards in place, asked fresh each time*; *the page's pi's
158
+ conversation over 32 MB is answered with its messages alone*. `test/web-page.test.ts`:
159
+ *a held conversation read again shows its three run cards, and a card saved while the page
160
+ looks is drawn as it lands*; *a run card in the transcript and replayed as it landed is drawn
161
+ once, by its entry id*.
162
+ `test/web-page.test.ts`: *a reload that starts while another replays what it held draws each
163
+ message once, in order*; *the terminal's answer arrives as one numbered reread*; *while the
164
+ terminal answers, the held line says since when*. `test/lease.test.ts`: *shouldReadAgain*.
165
+
67
166
  - [x] Assistant text, thinking, tool calls, tool results, errors, and the end of a turn.
68
167
  - [x] The conversation is what the conversation is for. A whole stretch of work — thinking and tool
69
168
  calls, interleaved as they arrive — is **one** row, closed: what is happening while it happens,
@@ -72,7 +171,8 @@ weaker, and worth converting whenever one of them breaks.
72
171
  two — and the stretch continues past the reply rather than being cut in two. One button in
73
172
  the header opens or closes every one of them.
74
173
  `test/web-page.test.ts`: *a stretch of work is one row, and one button opens every one of
75
- them*, *a reply without work keeps its own label row*.
174
+ them*, *a reply without work keeps its own label row*, *the work row's live line is cut in
175
+ characters, not the units a string is counted in*.
76
176
  - [x] Replies render as Markdown — headings, lists, quotes, rules, tables, inline code, fenced
77
177
  code, and http(s) links. Everything is escaped first: a reply is not trusted input, and a
78
178
  tool result quoted inside one is whatever some web page said. `test/web-page.test.ts`: *a
@@ -92,7 +192,10 @@ weaker, and worth converting whenever one of them breaks.
92
192
  never arrives silently either: there is a way back to the newest.
93
193
  `test/web-page.test.ts`: *an update while you are reading back does not yank the page, and the
94
194
  pill is the way back*.
95
- - [x] Every block says when it happened.
195
+ - [x] Every block says when it happened: in a tooltip on a mouse, and written under the block on a
196
+ touch screen, which has no hover to show a tooltip with. `test/web-page.test.ts`: *every block
197
+ keeps its time where a touch screen can show it, not only in a tooltip* (the data; the
198
+ coarse-pointer rule that draws it is checked by reading).
96
199
  - [x] Every message, tool call and result can be copied, including over plain http where the
97
200
  clipboard API is unavailable. The button waits for a hover on a mouse and is simply always
98
201
  there on a touch screen, which has no hover to wait for.
@@ -122,21 +225,25 @@ weaker, and worth converting whenever one of them breaks.
122
225
  speaking before a word is read. Status lines are small, monospace and quiet — context, not
123
226
  conversation.
124
227
  - [x] The conversation has a reading width. A line the width of a 27-inch monitor is not readable.
125
- - [x] An empty session says what it is and what to type, rather than being a blank rectangle —
126
- which is also the first thing a newly paired phone shows, and what is left when the last
127
- message is undone: a line the page wrote about itself is not a conversation.
128
- `test/web-page.test.ts`: *an empty session says what it is*, *undoing the only message shows
129
- the empty state*.
228
+ - [x] An empty conversation is a blank feed above a focused composer, whose placeholder says what
229
+ to type; the first message is its first row. `test/web-page.test.ts`: *an empty conversation
230
+ is a blank feed, and the first message is its first row*.
231
+ - [x] The conversation's whole id is the last line of the ⋯ sheet, one click to select and copy;
232
+ ⋯ is there at every width. `test/web-page.test.ts`: *the ⋯ sheet ends with the conversation's
233
+ whole id, to copy*.
130
234
 
131
235
  ## Automation, which is the reason this project exists
132
236
 
133
237
  - [x] Jobs, loops and rules: what they are, whether they are enabled, and what the last error was.
238
+ A disabled card says `disabled` in words, as `/cron` does, as well as being drawn faint.
239
+ `test/web-page.test.ts`: *a disabled job or rule says so in words, not only by being faint*.
134
240
  - [x] **What this project has is what this project has, and the rest is counted rather than
135
241
  dropped.** The store is machine-wide and this list is not, so a job made in another directory
136
242
  says so as a count — without it, a job somewhere else is indistinguishable from a job that is
137
- gone. Jobs and rules are counted and labelled apart, each line naming the command that lists
138
- them (`/cron all`, `/triggers rules --all`): one number covering both agrees with neither
139
- command, and the line exists to send you to the command.
243
+ gone. Loops and rules are counted and labelled apart, each line counted as its command counts
244
+ it: one number covering both agrees with neither command. The project switcher above is the
245
+ way there; the terminal's `/cron all` and `/triggers rules --all` list them. The inbox counts
246
+ other projects' new findings the same way, with no command.
140
247
  What counts as "this project" is what the extension says it is — symlinks resolved, `$HOME`
141
248
  too broad to be one — because a panel that disagrees with the command is worse than either.
142
249
  `test/web.test.ts`: *the panel and /cron agree about what this project is*.
@@ -144,11 +251,14 @@ weaker, and worth converting whenever one of them breaks.
144
251
  - [x] **When each job runs next, whatever its schedule.** The page used to work this out itself and
145
252
  understood only `every <interval>`, so a job on `0 9 * * *` — the first example in the
146
253
  README — showed nothing. A second cron parser in a page with no dependencies was the wrong
147
- fix: the process that owns the clock has the evaluator, and writes the answers to
148
- `next-runs.json` beside the store when one of them changes. A time that has already passed is
254
+ fix: pi-loops has the evaluator, and writes the answers to
255
+ `next-runs.json` beside the store when one of them changes; when pi-loops is not running, a window
256
+ writes the same file, so a person with only plain jobs still sees their next run. Another
257
+ conversation's plain job is marked as `/cron` marks it, from the same function: open in a
258
+ terminal, in the page, in the background, or resume it to run. A time that has already passed is
149
259
  shown as no next run rather than as a past one, a job that is disabled or belongs to another
150
260
  machine is not promised one, and a next run that is not today says which day it is.
151
- `test/scheduler.test.ts`: *the leader writes when each job runs next*.
261
+ `test/scheduler.test.ts`: *pi-loops writes when each job runs next*.
152
262
  `test/web.test.ts`: *the panel shows the next run of a cron-expression job*.
153
263
  `test/web-page.test.ts`: *a next run in April does not render as a time of day*.
154
264
  - [x] The name of a job is never abbreviated: it is what `/cron set <name> …` takes, so a clipped
@@ -165,26 +275,343 @@ weaker, and worth converting whenever one of them breaks.
165
275
  redraw*; *a rule's condition and action are shown in full*; *a job with a last error says so
166
276
  before it is opened*.
167
277
  `test/web.test.ts`: *a key in a job's prompt does not reach the page*.
168
- - [x] Run a job now.
169
- - [x] Inbox count.
170
- - [x] Runtime: scheduler state and whether this pi owns the clock, MCP servers and their state,
171
- hook count and events, tool count, last poll, snapshot age and version.
278
+ - [x] Run a job now. `test/web.test.ts`: *the run button sends pi the job's run command, and refuses an
279
+ id it cannot use*.
280
+ - [x] **Stop this run** on a loop row that is running, with an inline confirm in the row (*Stop this
281
+ run of pr-watch? Its notes are not updated. The next run happens on schedule.*, or *It will not
282
+ run again by itself.* for a one-shot) and two buttons, **Stop it** and **Keep running**. No
283
+ dialog and no key handler: closing the confirm by any means sends nothing (Keep running, or a
284
+ click anywhere outside the row), and the 8-second redraw keeps it open. Stop this run is shown
285
+ only for a run pi-loops in the background holds. Stop it posts the run's whole id to
286
+ `POST /loops/stop`, which starts no pi; the row says *stopping…*, then *Stopped by you.* when
287
+ the run's record says a person stopped it, *The run ended.* when it ended some other way first,
288
+ *stopping is taking longer than usual* after 8 seconds, or *It finished before the stop reached
289
+ it.*; a run that ends while the confirm is open says *This run has finished.* The waiting
290
+ page's Stop it takes the same path. At 390 px the buttons stack at full width.
291
+ `test/web-page.test.ts`: *a running loop row has Stop this run; the confirm opens inline,
292
+ survives a redraw, and Keep running closes it with nothing sent*; *Stop it posts the whole run id
293
+ to /loops/stop; the row says stopping, then stopped by you once the run is gone*; *a run that
294
+ ends while its confirm is open says so; a stop that came too late says the run finished first*;
295
+ *the loop row's confirm is built around clicks only, and at 390 px its buttons stack at full
296
+ width and at least 32 px tall*; *a click anywhere outside the loop row closes its confirm and sends
297
+ nothing; a running row says running once*; `test/page-server.test.ts`: *POST /loops/stop: the whole run id
298
+ goes to pi-loops, which answers at once; a run that already ended says finished*; *the waiting page's Stop it goes through the same stop as a loop row's, as
299
+ the page's*; *a loop row carries its run, its signal and one suggestion; apply writes one patch
300
+ and a cron_control_plane row, Keep only the kept key, a stale key is 409* (Stop only for a run
301
+ pi-loops holds); *the loop
302
+ routes take the checks every POST takes, a 1 KB body, exactly their fields, and whole ids;
303
+ nothing refused writes or starts a pi*. *Stop it posts the whole run id to /loops/stop…* also
304
+ checks *The run ended.* when the record does not say a person stopped it.
305
+ - [x] A loop row shows its worth when it is not zero, only the parts that are not (*6 findings in 30
306
+ days · 6 dismissed · quiet ×12*), and at
307
+ most one suggestion, **Run hourly instead** or **Pause**, with **Keep**. Nothing applies itself.
308
+ Applying posts the loop's id and the suggestion's key to `POST /loops/tune`; the server works
309
+ the suggestion out again and refuses a key it would not suggest now, and applying writes a
310
+ `cron_control_plane` audit row. Keep changes nothing but the loop's memory of the answer, and the
311
+ suggestion stays dismissed. `test/web-page.test.ts`: *a loop row shows its 30-day signal and
312
+ one suggestion; Keep and the suggestion post only the job, the key and the choice*; *right
313
+ after Apply the row's next run and the note say one time, in one format, and the note is set
314
+ apart from the signal*; the row never shows a next time earlier than now: once the applied
315
+ time has passed, it shows the store's (*an applied next run that has passed gives way to the
316
+ store's, so a row never shows a next time earlier than now*); *a
317
+ suggestion that changed under the page says so instead of acting; nothing applies itself*;
318
+ `test/page-server.test.ts`: *a loop row carries its run, its signal and one suggestion; apply
319
+ writes one patch and a cron_control_plane row, Keep only the kept key, a stale key is 409*; *a
320
+ loop whose findings were all dismissed is offered Pause, and applying it turns the loop off*;
321
+ `test/job-tune.test.ts`.
322
+ - [x] A rule that waits for a path says so on its row: *waits for ~/build.done · next look 09:22 · no
323
+ model call until the file exists*. `test/web-page.test.ts`: *a rule that waits for a path says
324
+ what it waits for and when it looks next*; `test/page-server.test.ts`: *a rule that waits for a
325
+ path says so on its row, with the next look from the process that checks rules*.
326
+ - [x] The inbox as cards above the conversations: decisions first under **Needs you**, each with
327
+ **Waiting on** and **If you don't** when the loop said them, then news. A card names its loop
328
+ only when the list holds more than one loop's findings, and says where a claim runs only when
329
+ that is not the conversation on screen (another conversation's or project's finding, or a
330
+ terminal that takes no messages). **See the run** is there only when the finding has a run.
331
+ Every card carries its finding's whole id; no button names a position. `test/web-page.test.ts`:
332
+ *the inbox shows decisions before news, each row only with a value, and where a claim runs
333
+ only when that is not here*. `test/page-server.test.ts`: *GET /inbox: one project's cards, decisions first,
334
+ fifty at most, a torn line left out, and starts no pi*.
335
+ - [x] **Yes, do it** on a decision and **Look into it** on news claim the finding into the
336
+ conversation that created its loop, and the card says where it runs: queued for your
337
+ terminal, running in a conversation open in the page, starting one, or waiting on pi-loops'
338
+ scheduled turn. `test/web-page.test.ts`: *Yes posts the finding's id and this conversation,
339
+ nothing else, and shows where it runs*. `test/page-server.test.ts`: *a claim from the page
340
+ queues the born-left inbox: row for the terminal that holds the conversation, once*; *a claim
341
+ for a conversation nobody holds starts its pi and rings it with the row's fingerprint*; *a
342
+ claim for a conversation whose pi has no pi-loops sends that pi nothing*.
343
+ - [x] A finding whose loop belongs to its project (a starter added before its conversation was
344
+ saved) runs in the conversation you claim it from: **Yes, do it here** claims it into the one
345
+ open here. `test/web-page.test.ts`: *a project-owned finding runs in the conversation it is
346
+ claimed from: Yes, do it here, and no line to say so*. `test/page-server.test.ts`: *a
347
+ project-owned finding (a starter's loop installed before its conversation was saved) says it
348
+ runs where it is claimed from, and is claimed into the asking conversation*.
349
+ `test/scoping.test.ts`: *a recipe installed in a conversation pi has not saved makes a
350
+ project-owned loop; in a saved one the loop keeps its conversation*. Live: J7 checks the
351
+ starter's job has no creating conversation.
352
+ - [x] A finding whose conversation is gone is claimed into the one open here, when it is in the
353
+ finding's project (**Yes, do it here**). `test/page-server.test.ts`: *a claim whose
354
+ conversation is gone runs in the page's own when it is in the project, and is refused
355
+ otherwise*. Listed for another project, it says "its conversation is gone; runs in a new
356
+ conversation in <project>", and **Yes, do it** starts that conversation (as + New does
357
+ there), claims the finding into it and goes to it; one that cannot start claims nothing and
358
+ says why. `test/web-page.test.ts`: *a card whose conversation is gone runs in this one in its
359
+ own project, and in a new conversation there in another project*.
360
+ - [x] A project-owned finding listed for another project says "runs in a new conversation in
361
+ <project>", and **Yes, do it** starts that conversation, claims the finding into it and goes
362
+ to it, as a gone one does; in the open conversation's project it stays as above.
363
+ `test/web-page.test.ts`: *a project-owned card listed for another project runs in a new
364
+ conversation there: started, claimed into, and opened*.
365
+ - [x] The Loops pane lists loops and rules and nothing else; the list's cards and the tab title
366
+ count the inbox. The desktop rail and the phone's tab bar call the list
367
+ **Inbox**. `test/web-page.test.ts`: *the Loops pane lists loops: no inbox count and no summary
368
+ line, and its rail button says Inbox for the list*.
369
+ - [x] Every loop row is ▸, its name and schedule, and **run** on its first line; a long name moves
370
+ the schedule under it, never ▸ or run onto a line of their own. In a pane of loops, the plain
371
+ job is the one marked: its schedule ends `· in its conversation`.
372
+ `test/web-page.test.ts`: *a loop row is its name, schedule and run on one line; the 30-day
373
+ line only when it is not zero*.
374
+ - [x] **No…** and **Dismiss…** take a reason chip (`Not a bug`, `Already fixed`, `Duplicate`, `Too
375
+ noisy`, `Not now`), a typed reason of at most 500 characters, or none; the card then says
376
+ whether the loop's next run hears it. `test/web-page.test.ts`: *No then Not a bug posts that
377
+ reason and says it goes to the loop's next run*; *a typed reason is sent as typed, and the
378
+ field holds at most 500 characters*. `test/page-server.test.ts`: *a dismiss from the page
379
+ stores the reason for the loop's next run and says so; past 500 characters it is refused*.
380
+ - [x] What was found while you were away, above the cards: "Since yesterday 23:10 · 3 findings, 1
381
+ from this conversation · $0.41", counted per project by the function the
382
+ terminal's session-start line uses, each part kept whole when the line wraps; "N from this
383
+ conversation" only when that is not every finding (`test/web-page.test.ts`: *the away card
384
+ names the findings from this conversation only when they are not all of them, as src/seen.ts
385
+ does*). Runs are the
386
+ activity strip's to count, so a card shows only when something was found. The time is the browser's local time measured from the
387
+ server's clock. The first card after a load is scrolled into view, ahead of the open
388
+ conversation's row (measured at 390 px by J6, test/journey/journeys/j06-away.mjs, step *the
389
+ away card on a phone*). `test/web-page.test.ts`: *the away card says what was found since the cursor,
390
+ leaves runs to the strip, and has Mark all seen*; *the page's
391
+ since words are src/seen.ts's, across midnight, in two time zones*; *the away card wraps
392
+ between its parts and keeps the inbox card's buttons*. `test/away-agree.test.ts`: *the
393
+ terminal's helper and the page's GET /inbox give the same away summary from the same files*.
394
+ `test/page-server.test.ts`: *GET /inbox carries the project's away summary, counted as the
395
+ terminal counts it*.
396
+ - [x] **Mark all seen** moves the project's cursor to what the card counted and clears the
397
+ terminal's next session-start line too; the findings stay new and the cards stay. A refused
398
+ mark keeps the card and says why. `test/web-page.test.ts`: *the away card: the first window's
399
+ words, nothing without a finding, and a refused mark keeps the card*. `test/page-server.test.ts`: *POST /inbox/seen takes the checks every POST takes, a
400
+ 512-byte body, a known project and a time not in the future, and writes only seen.json*.
401
+ - [x] A project with no loop and no rule offers three starters above the cards (daily-digest,
402
+ pr-watch, changelog-draft), each its name, a `≈ $N/day` chip when it costs something, its
403
+ recipe's `line` (one sentence of at most twelve words; its summary when it has none), whole, and
404
+ **Add**. Only for the open conversation's project, since Add installs there.
405
+ `test/web-page.test.ts`: *a starter's line shows whole, never cut, and a starter that costs
406
+ nothing shows no cost*. `test/recipe.test.ts`: *every packaged recipe says what it does in one
407
+ sentence of at most twelve words*; *a starter carries its line whole, and a recipe without one
408
+ carries its summary*. `test/page-server.test.ts`: *GET /inbox offers three starters, in the design's
409
+ order, only for the open conversation's project and only while it has no loop and no rule*.
410
+ - [x] **Add** sends `/recipe add <name> --level report` to this conversation's pi, exactly what a
411
+ person may type in the composer, and the wizard's questions are this page's dialogs; closing
412
+ one installs nothing and Add is live again. A refusal says why. In a conversation a terminal
413
+ holds, Add is replaced by the terminal command, with **Copy**. `test/web-page.test.ts`: *an
414
+ empty project shows three starters, each its name, one line and Add, and Add runs the wizard
415
+ with the constant command*; *a starter the pi refuses says why; a terminal-held conversation shows
416
+ the terminal command instead of Add*.
417
+ - [x] An open reason box keeps its text, its selection and its focus when the list is drawn again
418
+ (the 15-second poll, or an action on another card), and closing it with **Cancel** or **No…**
419
+ keeps the text for when it is opened again. `test/web-page.test.ts`: *an open reason box
420
+ keeps its text, selection and focus across the list's redraw and a button's*.
421
+ - [x] A finding another page or a terminal handled first says so, and a post that failed keeps its
422
+ buttons. `test/web-page.test.ts`: *a finding another page already handled says so; a failed
423
+ post keeps the buttons*.
424
+ - [x] A finding's text is shown as text: HTML in it makes no element. `test/web-page.test.ts`: *a
425
+ finding's HTML is shown as text and creates no element*.
426
+ - [x] **See the run** opens the run's transcript read-only, folded and redacted, and its close
427
+ button closes it.
428
+ `test/web-page.test.ts`: *See the run opens the run's lines read-only, as text*.
429
+ `test/page-server.test.ts`: *See the run reads a run's transcript under sessions/ and nothing
430
+ else*.
431
+ - [x] On a phone, inbox buttons are at least 32 px tall (40 px on a touch screen) and a card wraps
432
+ rather than scroll sideways. Measured in a real browser at 390 px by the J3 journey, in its
433
+ step *the inbox pane on a phone* (test/journey/journeys/j03-decide.mjs: every visible card
434
+ button's `offsetHeight`, and `scrollWidth` against `innerWidth`); the rules themselves are
435
+ pinned by `test/web-page.test.ts`: *inbox card buttons are at least 32 px tall and the card
436
+ wraps rather than scroll sideways*.
437
+ - [x] A claim's card follows the server: queued while its row waits, then "Handed to that
438
+ conversation." once the row is gone, also when it is gone before the page looks again; the
439
+ list's and the panel's counts are asked for again with it. A disabled button looks disabled
440
+ and does not change on hover. `test/web-page.test.ts`: *a claim's card follows the server*;
441
+ *a claim whose row is taken before the page looks again says it was handed over, at once*;
442
+ a claim whose row is cancelled from this page's queue says "Cancelled; nothing was sent. The
443
+ finding stays claimed." instead (*a claim cancelled from this page's queue says nothing was
444
+ sent and the finding stays claimed, not that it was handed over*);
445
+ *a disabled card button looks disabled, and a disabled button does not change on hover*.
446
+ - [x] **Loop activity** between the conversation and the composer: closed, one line ("loop
447
+ activity · 5 runs today · 1 failed · 1 rule result"); open, a row for each loop run and each
448
+ matched or failed rule check of this project over the last day, newest first, at most 100. A
449
+ run that carded in the conversation on the screen says `in chat`, and no other row does; a removed loop says `(removed)`; a rule result names its
450
+ condition and points at its card. Only a loop's first three runs and its failures write into
451
+ the conversation (docs/loops.md "Run cards"), so the strip is where every other run is seen.
452
+ The rule rows come from the last 256 KB of the trigger audit, which can hold less than a day
453
+ when checks write long summaries. All text is drawn as text. `test/web-page.test.ts`: *the
454
+ activity strip is one line until opened, then lists every run and rule result as text*; *the
455
+ strip draws 96 items open within 200 ms*; *the strip says in chat only for a carded run of the
456
+ conversation on the screen*. `test/page-server.test.ts`: *GET /activity: one
457
+ project's runs and rule results*. `test/activity.test.ts`: *five runs of a loop are five items*.
458
+ - [x] On a phone the strip stays one line closed and opens as a sheet that scrolls inside itself;
459
+ nothing in it scrolls sideways. The open strip takes its own room below the conversation, and a
460
+ conversation read to its end stays at its end. `test/web-page.test.ts`: *the strip stays one
461
+ line on a phone and nothing in it scrolls sideways*; *opening the strip keeps the end of the
462
+ conversation in view*.
463
+ - [x] The tab title counts this conversation's project: new decisions, then other new findings,
464
+ each part left out at zero: `(1⚑ 2) pi-loops`, `(2) pi-loops`, `pi-loops`. A finding with no
465
+ project counts in every project, as the inbox lists it; a conversation in no project known
466
+ counts nothing and gets no strip rows. An installed page shows the decisions as its badge too.
467
+ `test/web-page.test.ts`: *the tab title counts this project's new decisions, then its news,
468
+ each left out at zero*. `test/activity.test.ts`: *a finding with no cwd is counted in the title
469
+ of every project, as the inbox lists it*; *a conversation in no project known is told nothing
470
+ of any project*.
471
+ - [x] Notifications, off until switched on in the session menu, fire only while the tab is hidden:
472
+ a new decision anywhere on the machine, a loop entering a streak of three failures, the daily
473
+ budget reached. Never for a run, news, a rule result, a visible tab or what was there when the
474
+ page loaded; each fires once. Permission is asked only from the switch. On plain http (a phone
475
+ over a tailnet without https) the switch says it needs https or localhost. The page asks on its
476
+ eight-second poll, which a browser slows to about once a minute in a hidden tab, so a
477
+ notification can be up to a minute late. `test/web-page.test.ts`: *a notification fires only
478
+ while the tab is hidden*; *on plain http the notification switch says it needs https or
479
+ localhost and does nothing*.
480
+ - [x] A rule result's card names the rule's condition on its origin line, and its headline is what
481
+ the check said after its `matched <rule-id>`. `test/web-page.test.ts`: *a rule result's card
482
+ names its rule's condition on the origin line*. `test/inbox-card.test.ts`: *a rule result's
483
+ card names the rule's condition, redacted and clipped*; *a rule result's headline is what the
484
+ check said after its `matched <rule-id>` token*.
485
+ - [x] While nothing runs loops, a loop's card promises no next run and says `paused: <why>`, and
486
+ the status bar names the reason. `test/web.test.ts`: *with nothing running loops, the panel
487
+ promises no next run, says paused, and still shows the last check*.
488
+ - [x] Every time on the page is in one format, the away card's 24-hour words ("15:05", "yesterday
489
+ 15:05", "tomorrow 09:00", "Apr 3 2027 09:00"), the same as the terminal's.
490
+ `test/web-page.test.ts`: *every time on the page is in one format, the away card's 24-hour
491
+ words, ahead as well as behind*.
492
+ - [x] Runtime: MCP servers and their state, hook count and events, tool count, last poll, snapshot
493
+ age and version. Whether pi-loops is running is the status bar's.
172
494
  - [x] A count leads to the list behind it — "24 tools" answers the wrong question on its own.
173
495
  `test/web-page.test.ts`: *a count in the panel leads to the list behind it*.
174
- - [x] Goal state and the session's own metadata.
496
+ - [x] Goal state when a goal is set, and pi-loops for the runtime, each one click open.
497
+ `test/web-page.test.ts`: *the Loops pane shows Goal only while a goal is set, and has no This
498
+ conversation section*.
175
499
  - [x] A malformed job in a store file does not blank the panel. `test/web-page.test.ts`: *a job
176
500
  with the wrong types in it does not blank the sidebar*.
177
- - [x] On a narrow screen the panel is a drawer, not something that disappears: what a loop is doing
178
- is the reason to open this on a phone.
179
- - [x] Thirteen controls do not fit across a phone. The ones you reach for mid-conversation stay in
180
- the header; the rest move — not copy — into a sheet behind one button.
181
- `test/web-page.test.ts`: *the header's secondary actions move into a sheet and back*.
501
+ - [x] The panel is the loops rail, and it never disappears: what a loop is doing is the reason to
502
+ open this on a phone. Wide, it is a column beside the conversation, which the rail's Loops
503
+ button gives back to the conversation, remembered; up to 1100 px it is a drawer from that
504
+ button; on a phone (760 px and less) it is the Loops tab.
505
+ - [x] Thirteen controls do not fit across a narrow conversation. Below 1000 px of conversation,
506
+ whatever is beside it, the ones you reach for mid-conversation (undo, find, compact and resume)
507
+ stay in the header on one row with ⋯, and the header stays two rows at a 1280 or 1440 px
508
+ window; reload, save, share, clear, add device, theme and notifications go behind ⋯. The
509
+ sheet lists all of them, moved — not copied — into it. On a phone (760 px and less) every one is behind ⋯.
510
+ `test/web-page.test.ts`: *the header's secondary actions move into a sheet and back*, *below
511
+ 1000 px of conversation, undo, find, compact and resume stay in the header and the rest go
512
+ behind ⋯*.
513
+
514
+ - [x] **A watch from one sentence.** `+ Watch for…` beside `+ New` opens the box *Watch for…*: one
515
+ sentence field, named for a screen reader, and three example sentences (English and Chinese);
516
+ an example fills the box. `Read it` sends the
517
+ sentence as the ordinary chat message `Watch for: <sentence>`, queued and cancellable like
518
+ any other; `cron_create` and `new_trigger` answer it by asking the person, through pi's own
519
+ select, before they create anything.
520
+ `test/web-page.test.ts`: *the Watch for… box opens with three examples and no caption, and an
521
+ example fills it*.
522
+ - [x] A sentence with no time word and nothing to wait for gets the card "This reads like something
523
+ to do now, not something to watch." from the page itself: nothing is sent and no model is
524
+ called. `test/web-page.test.ts`: *a sentence with no time and nothing to wait for gets the now
525
+ card, and nothing is sent*.
526
+ - [x] The question is a card, in the box and in the transcript, drawn from the select's title rows
527
+ and options alone: the kind (*On a schedule* / *When something happens*), when it runs or
528
+ waits for, how often the background looks for a rule, what it does, for a schedule whose
529
+ result goes to the inbox what it remembers and whether a checker reviews each finding, the
530
+ policy's reason when the action is one the unattended guard would refuse, and the choices:
531
+ Result (this conversation or the inbox; a rule's inbox chip reads *Put it in my inbox*, since
532
+ a matched rule's result reaches the inbox and the loop activity strip), for a rule Repeats
533
+ (once or every time), and for a schedule in the inbox Checks (none or each finding). The chips start on the first option, which is the model's own choice. A select that
534
+ is not a watch question stays a dialog. Nothing is written until the person answers.
535
+ `test/web-page.test.ts`: *a watch question draws the card from its title and options, in the
536
+ box and in the transcript, and no dialog*, *a select that is not a watch question stays a
537
+ dialog*; `test/scoping.test.ts`: *with a person in their own turn, new_trigger asks through
538
+ pi's select and Set it up creates the rule in the same call*.
539
+ - [x] `Set it up` answers the select with the one option the chips name, and `Cancel` with
540
+ *Cancel*, through `/ui-response` like any dialog; one answer per question. Cancel writes
541
+ nothing to `jobs.json` or `triggers.json`. Cancel comes first so Enter hits it.
542
+ `test/web-page.test.ts`: *the card's chips name the option Set it up sends, and Cancel sends
543
+ Cancel*; `test/page-server.test.ts`: *a watch question is a select like any dialog:
544
+ /ui-response carries the chosen option to pi, and the /watch route is gone*;
545
+ `test/scoping.test.ts`: *Cancel, Esc, a timeout and an abort all return cancelled in the same
546
+ call and leave jobs.json and triggers.json as they were*.
547
+ - [x] The tool's result in the same turn turns every card of the question into a receipt (*Set up
548
+ dyn-… · shows here · once*, *Not set up*, or *Not set up:* and why the create failed).
549
+ `test/web-page.test.ts`: *the tool's result in the same turn turns every card of the question
550
+ into a receipt*.
551
+ - [x] A card in the box that is set up or cancelled closes the box, the way its close button does,
552
+ and gives the focus back to the composer; the transcript's card keeps the receipt. A create
553
+ that failed keeps the box open, with the sentence in it to change. A watch that is set up
554
+ refreshes the page's state and the inbox at once, so the starters and the inbox count do not
555
+ wait for the next timer. `test/web-page.test.ts`: *Set it up closes the Watch for… box, gives
556
+ the composer back and refreshes the state and the inbox; a failure keeps the box*.
557
+ - [x] The card shows the whole action and condition, redacted, never cut; an action's further lines
558
+ stay in its own row. `test/watch-plan.test.ts`: *the card shows the whole action and
559
+ condition, redacted, and says when a loop is checked*, *askTitle indents a value's further
560
+ lines, so no line of an action can pass for a row of its own*.
561
+ - [x] On a short screen the box's body scrolls, Read it stays in the box's own menu, a card's
562
+ Cancel and Set it up stick to the bottom of what is in view, and a card is scrolled to the top
563
+ of the box when it is drawn, with its status and buttons as one footer so the bar never
564
+ covers the status. Closing the box keeps a waiting card; it is there when you reopen it, and a
565
+ settled one goes. `test/web-page.test.ts`: *closing the box keeps a waiting card; it is there
566
+ when you reopen it*.
567
+ - [x] The now-check knows weekdays (plural too), *nightly*, *noon*, *midnight*, *daily*, *weekly*,
568
+ *at 9am*, *in an hour*, *twice a day*, *weeknights*, *later today* and the Chinese time
569
+ words, and a sentence queued behind a busy turn waits for its own turn's question.
570
+ `test/web-page.test.ts`: *a sentence with no time and nothing to wait for gets the now card,
571
+ and nothing is sent*, *a sentence queued behind a busy turn waits for its own turn's question,
572
+ not the earlier turn's reply*.
573
+ - [x] A question waits thirty minutes, then pi cancels it and the tool says so: the card says *Not
574
+ set up: nobody answered in time*. A question pi answered on its own (an abort, the turn's end)
575
+ says *Not set up* and sends nothing. One still waiting when a tab opens is drawn once, below
576
+ the conversation; one a tool asked whose run ended is not offered again, and one a slash
577
+ command opened during the run (`/session-share`) still is. `test/web-page.test.ts`: *a
578
+ question pi answered on its own is not left waiting: the turn's end, or the question's own
579
+ time, says it was not set up*, *a question still waiting when the page opens is drawn once,
580
+ after the transcript*; `test/page-children.test.ts`: *a dialog a tool asked is dropped
581
+ when the run ends: pi answered it already, and a later tab is not offered it*.
582
+ - [x] A turn that ends with no question shows pi's reply in the box (a question of pi's own, for
583
+ instance) and keeps the sentence for editing; ninety seconds with neither a card nor the end
584
+ of the turn says pi did not answer. `test/web-page.test.ts`: *a turn that ends with no plan
585
+ shows pi's reply and keeps the box*.
586
+ - [x] A conversation a terminal holds is answered there (owner decision O2): both tools ask in that
587
+ terminal before they set anything up, and the box says so.
588
+ `test/web-page.test.ts`: *a terminal that holds the conversation answers the sentence there*.
589
+ - [x] **The terminal asks the same question.** In a terminal, in the person's own turn, both tools
590
+ show pi's selector with the card's rows as its title and the same options, the model's choice
591
+ first; it replaces `new_trigger`'s confirm, so there is one question, never two. A scheduled
592
+ turn, a sub-agent and pi-loops in the background ask nobody. `test/scoping.test.ts`: *the answer
593
+ is promote_to_chat and fire_once for a rule, and stateful and verify for a schedule*, *a
594
+ sub-agent, a scheduled turn and a pi with no dialog keep today's gate: new_trigger asks the
595
+ host's confirm once and cron_create creates*; J8 in `test/journey/journeys/j08-watch.mjs`
596
+ drives the selector in tmux.
597
+ - [x] At 390 px the box and the card fit: nothing scrolls sideways, the chips and buttons wrap, and
598
+ every button is at least 32 px tall (J8 in `test/journey/journeys/j08-watch.mjs`).
182
599
 
183
600
  ## How it looks
184
601
 
185
602
  - [x] Light and dark are both designed: every surface is a named colour, not a translucent grey
186
603
  over whatever the browser paints, so the page looks the same on a machine whose default
187
- background is not white.
604
+ background is not white. The colours are Inspire's, in both schemes: three paper levels
605
+ instead of borders (light `#f2f2f7`, `#ffffff`, `#efeff4`; dark `#000000`, `#1c1c1e`,
606
+ `#2c2c2e`), one amber for what you can act on (`#9a5500` light, `#ffb340` dark), and colour
607
+ for state only, so a hue other than amber is a fault. No colour is written outside the token
608
+ block, except the QR code's black on white, which a scanner needs. The Background view is
609
+ drawn from the same block. `test/web-page.test.ts`: *the stylesheet carries Inspire's paper
610
+ and amber in both schemes, and no colour outside its tokens*; `test/page-server.test.ts`: *the
611
+ Background view is drawn in the page's palette, both schemes, and links back to the page*.
612
+ - [x] Focus is visible wherever the keyboard puts it: an amber ring on `:focus-visible`, and no
613
+ rule hides it. `test/web-page.test.ts`: *the stylesheet carries Inspire's paper and amber in
614
+ both schemes, and no colour outside its tokens*.
188
615
  - [x] The stored theme is applied before the first paint. Applied later, a dark page renders light
189
616
  and then blinks. `test/web-page.test.ts`: *the stored theme is applied before the first
190
617
  paint*.
@@ -203,6 +630,13 @@ weaker, and worth converting whenever one of them breaks.
203
630
  - [x] A confirmation shows what is about to happen apart from the reasoning about it, so it cannot
204
631
  be read as prose and waved through. `test/web-page.test.ts`: *a confirmation shows what is
205
632
  about to run*.
633
+ - [x] A question with a countdown (the scheduled-turn guard, a control-plane confirm) closes when
634
+ its time is up and says `timed out with no answer: refused`, since pi refuses it then and tells
635
+ the page nothing; an approve after that is neither sent nor reported as approved, and the next
636
+ question gets the dialog. A browser that attaches later is not offered it, and one offered again
637
+ carries only the time it has left. `test/web-page.test.ts`: *a question pi times out closes
638
+ itself, says it was refused, and a late approve is not reported*; `test/web.test.ts`: *a dialog
639
+ pi times out is not offered again, and one replayed carries the time it has left*.
206
640
  - [x] Enter does not approve: the focus starts on cancel, which is written before approve so the
207
641
  first menu button is the one Enter hits. `test/web-page.test.ts`: *Enter in a confirmation
208
642
  dialog hits cancel, not approve*.
@@ -244,30 +678,91 @@ weaker, and worth converting whenever one of them breaks.
244
678
  than a list of its own.
245
679
  `test/web-page.test.ts`: *the thinking-level picker offers every level pi knows, not a list of its own*.
246
680
  - [x] Attaching an image is refused, with the reason, by a model that cannot see one.
681
+ `test/web-page.test.ts`: *a model that cannot see images has attach disabled and a pasted image
682
+ refused*.
247
683
  - [x] Cost and token counts.
248
684
  - [x] Compact, undo (fork from your last message), find across the whole session including
249
685
  abandoned branches, export to HTML, and share as a redacted gist. Undo reloads the
250
686
  conversation through the one path every other thing that empties the feed uses, so an undone
251
- session is as empty as a new one rather than a blank rectangle with a notice on it.
252
- `test/web-page.test.ts`: *undoing the only message shows the empty state*.
687
+ session is as empty as a new one. `test/web-page.test.ts`: *undoing the only message empties
688
+ the conversation and puts the message back in the composer*.
253
689
  - [x] Compaction says what it did — what the context was and what it became, and what the summary
254
690
  cost — and what it keeps can be steered: `/compact keep the API shapes`, as in the terminal.
255
691
  A line that reports neither is a line asking to be taken on faith.
256
692
  `test/web.test.ts`: *the compact button can steer what the summary keeps*.
257
693
  `test/web-page.test.ts`: *clear, resume and compact are typed as well as clicked*.
694
+ - [x] **This project's conversations are listed beside the conversation, and reading the list starts
695
+ no pi.** Each row is the conversation's title (its `/name`, else the first thing said), when it
696
+ last changed, and who holds it: `terminal`, `open` (the page), `pi-loops` (in the background), or
697
+ no tag; `· open here` when the page also has it. Grouped Today and Earlier when both are there,
698
+ the open one marked.
699
+ A row loads that conversation's own page, so Back, a new tab and a bookmark work as links do.
700
+ Conversations from before 0.23, or from a pi without pi-loops, are listed and open too: pi's
701
+ own session files are what is read. `test/web-page.test.ts`: *the list shows this project's
702
+ conversations, grouped Today and Earlier when both are there, with the open one marked, and
703
+ each row is its address*, *a row says who holds it: terminal, open, pi-loops, or nothing at all*, *showing the
704
+ list asks for no other conversation, and rides on the page's own poll at most every fifteen
705
+ seconds*, *a title cannot carry markup or reorder its line*; `test/page-server.test.ts`: *GET
706
+ /conversations lists a project's conversations from pi's files, behind the token, and starts no
707
+ pi*, *a conversation no lease names opens from pi's own session file, and a made-up id is still
708
+ 404*; `test/conversation-rows.test.ts`.
709
+ - [x] A row says what needs you (`⚑ N`, a loop it made is waiting on a decision) and has a dot when
710
+ it changed since this browser last looked; a terminal's conversation never has the dot. The
711
+ conversation on the screen is read as of each poll, so the one you watched never gets it back.
712
+ `test/web-page.test.ts`: *the list has no tabs: every row is shown, the dot marks what changed
713
+ since this browser last looked*, *the conversation on the screen is read as of now, not as of
714
+ the list's last draw*.
715
+ - [x] Another project is one choice away: the switcher lists every project pi has conversations in,
716
+ and those with only a loop or a lease, each by its folder's name with the whole path as its
717
+ title. It is the one place the page names the project. `test/web-page.test.ts`: *choosing
718
+ another project asks for that project's list by its cwd*.
719
+ - [x] + New starts the conversation in the project the list shows. In the open conversation's
720
+ project it is a new session of this page's pi, as before; in another project the server starts
721
+ a pi there (`POST /conversations/new`, a project the list offers and nothing else) and the
722
+ page goes to it, or says why not. `test/web-page.test.ts`: *+ New starts the conversation in
723
+ the project the list shows: a new session here for the open one's, POST /conversations/new and
724
+ its address for another*; `test/page-server.test.ts`: *POST /conversations/new starts one pi in
725
+ a project the page lists, with that project's arguments, and refuses any other cwd without
726
+ starting one*. A project with no arguments of its own still gets pi-loops when the launch
727
+ loaded it with `-e`, and nothing else of another project's: *a new conversation in a project
728
+ with no arguments of its own loads pi-loops with the -e another page's pi was given, and
729
+ nothing else of it*.
730
+ - [x] The list, the composer and send are reachable by keyboard: one row is in the Tab order, the
731
+ arrow keys, Home and End move between rows, Enter opens one, and focus is shown.
732
+ `test/web-page.test.ts`: *the keyboard moves through the list one row at a time, and Enter opens
733
+ the row*.
734
+ - [x] A phone shows one pane at a time, Inbox (the list), Chat and Loops, from tabs at the bottom;
735
+ the pane is in the address, so Back returns to the one before and a row opens on Chat.
736
+ `test/web-page.test.ts`: *on a phone one pane shows at a time, picked by the Inbox, Chat and
737
+ Loops tabs, and a row opens on Chat*.
738
+ - [x] The status bar says whether pi-loops is running in the background (or why loops and checks
739
+ are paused). It is the one place that says pi-loops runs, and its link to the Background view.
740
+ `test/web-page.test.ts`: *the status bar follows the host file the server reads, not the
741
+ snapshot the session last wrote, and the Loops pane does not repeat it*, *the status bar says
742
+ whether pi-loops runs*.
258
743
  - [x] **Starting over does not mean going back to a terminal.** The context is finished with far
259
744
  more often than the window is: a new session and going back to an earlier one are both here,
260
- by button and by `/clear`, `/new` and `/resume` typed in the composer — the habit comes from a
745
+ by button (the list's + New is the header's clear) and by `/clear`, `/new` and `/resume` typed
746
+ in the composer — the habit comes from a
261
747
  terminal and the muscle memory arrives with it. Neither deletes anything: the session being
262
748
  left is a file that `resume` lists, labelled by what was said in it rather than by a filename.
263
749
  `test/web.test.ts`: *a new session is a path pi has not written yet, and going back is one it
264
750
  has*. `test/web-page.test.ts`: *the session you are in is not offered as one to go back to*.
751
+ - [x] Going back to a conversation that is already open in the page, in another tab, goes to it:
752
+ the server answers with that conversation's address and the page opens it, as 0.22.8
753
+ switched to it. `test/web-page.test.ts`: *resuming a conversation that is already open in the
754
+ page goes to it*.
265
755
  - [x] **The session commands are two words here.** `/sessions` prints this project's sessions into
266
756
  the feed — short id, when, what was first said — and `/session export [path]` / `/session
267
757
  import <path>` are the archive commands, sent as `/session-export` / `/session-import`. pi's
268
758
  own `/session` is a terminal command that does not exist over rpc, which is why the extension
269
759
  had to hyphenate its names in the terminal and why the page is free to spell them plainly.
270
760
  `test/web-page.test.ts`: */sessions and /session export are typed here*.
761
+ - [x] A session an extension switches to inside pi is followed: `/inbox open` (a finding's run,
762
+ forked into a new conversation) and `/session import <path> --resume` reload the page onto
763
+ it, as a switch made with the buttons does. A rule's result, or a finding from another
764
+ project, is refused with the reason. `test/web.test.ts`: *a session an extension switched to
765
+ inside pi is followed: the page is told to reload*.
271
766
  - [x] A session started here is a session pi-loops recognises. pi names the sessions it starts after
272
767
  their id and the front end cannot know that id in time, so anything identifying a session by
273
768
  its file name is wrong about this one — which parked every inject-and-run cron job made in a
@@ -286,24 +781,52 @@ weaker, and worth converting whenever one of them breaks.
286
781
  a model it was never had with. A model whose credentials are gone is reported rather than
287
782
  forced. `test/web.test.ts`: *going back to an earlier session puts it back on the model it was
288
783
  last using*.
289
- - [x] `--continue`, `--resume` and `--session` reach pi unchanged, so a session moves between the
290
- two windows.
291
- - [x] **Ending the window ends the session, properly.** Ctrl-C in the terminal that started it means
292
- `/quit`, not a kill: pi is started in a process group of its own so the terminal's SIGINT
293
- cannot reach it, is asked to shut down with the signal it does handle, and is waited for — which
294
- is what hands the clock to the headless host and what puts the line saying so in front of you
295
- before the window closes.
296
- `test/web.test.ts`: *the pi behind the page runs in a process group of its own*, *Ctrl-C in that
297
- terminal ends the session the way /quit does, and waits for it*.
298
- - [x] **And the terminal is told what became of the automation.** pi-loops announces the hand-off
299
- through `ctx.ui.notify`, and `ctx.hasUI` is true in rpc mode — so the note is an event addressed
300
- to a page whose server is the process on its way out, and the person who pressed Ctrl-C saw only
301
- `pi exited (143)` while their loops kept running somewhere nobody had told them about. That note is
302
- relayed to the terminal when it arrives; when it does not, the loops directory is read the way
303
- `pi-loops host status` reads it — the live host and how to see or stop it, or that none started
304
- and which log says why, or nothing at all when nothing was going to run.
305
- `test/web.test.ts`: *Ctrl-C says where the automation went, when pi's own note never arrives*,
306
- *pi's own word on the hand-off reaches the terminal when it arrives in time*.
784
+ - [x] `--continue` and `--session <id|prefix>` open that conversation in the page, so a session
785
+ moves between the two windows. `--resume` is pi's picker, which the page has as **resume**;
786
+ `pi-loops --resume` says so rather than open a picker nobody can see.
787
+ `test/cli.test.ts`: *--continue and --session name a conversation pi already has, and leave
788
+ pi's other arguments*, *the page's address flags are refused with the config.toml lines that
789
+ replace them*.
790
+ - [x] **The page outlives the terminal.** pi-loops in the background serves it, and `pi-loops`
791
+ opens it and exits 0, so closing that terminal ends nothing: the page still answers a message,
792
+ and `pi-loops status` counts it as an open conversation. `test/journey/journeys/j01-close-terminal.mjs`
793
+ (J1); `test/cli.test.ts`: *pi-loops asks the running pi-loops to open the conversation, prints
794
+ its page and exits 0*.
795
+ - [x] **One pi per conversation.** Each conversation open in the page has its own `pi --mode rpc`,
796
+ and two opens of one share one start. A conversation with no tab, no turn, no question waiting
797
+ and nothing unsent stops after 15 minutes; at most six are open, and opening a seventh stops the
798
+ least recently used idle one, or says, on a page with a way back, that none is idle.
799
+ `test/page-children.test.ts`: *two opens of one conversation share one start*, *an idle child is
800
+ stopped after the idle time, and one somebody needs never is*, *opening one more than the cap
801
+ stops the least recently used idle child, and none idle is full*; `test/page-server.test.ts`:
802
+ *a conversation that cannot open because the page is full answers a page with the reason and a
803
+ way back*.
804
+ - [x] **Opening a conversation pi-loops is running a scheduled turn in waits for that turn**, on a
805
+ page that names it and has **Stop it**; opening it then would abort the turn. A conversation
806
+ whose pi keeps stopping shows pi's last words and **Start again** instead of starting it a
807
+ fourth time. `test/page-children.test.ts`: *opening reuses, waits for a turn pi-loops is
808
+ running, refuses after the crash budget, else starts*; `test/web.test.ts`: *a pi that refuses
809
+ to start leaves the server answering, and its page says why*.
810
+ - [x] **The Background view.** What pi-loops in the background is doing, in the page's words: every
811
+ conversation with where it is open (a terminal, this page, the background), its loops, rules
812
+ and what waits for it, and what waits in `pi-loops status`'s words, with no pid, every time in
813
+ the page's own format, at `/background`, behind the page's token or a paired browser's device
814
+ cookie. `/background.json` keeps every field. `test/page-server.test.ts`: *GET / with no
815
+ conversation to open is the Background view; /background and /background.json answer to the
816
+ token or a paired browser*, *the Background view shows what waits in the words pi-loops status
817
+ uses, where it is open left to the table*, *the Background view uses the page's words and the
818
+ page's times*, *the Background view's times are written by the page's own clock, from the
819
+ page*. The status bar's "pi-loops running in the background" is the one link to it, and a phone
820
+ shows the status bar under its Loops pane; it links back to the page,
821
+ follows the theme chosen on the page before its first paint, and draws a conversation's title
822
+ as text, as the list does. `test/page-server.test.ts`: *the Background view draws a
823
+ conversation's title as text: no escape sequence or bidi override*; `test/web-page.test.ts`:
824
+ *the status bar is the one way to the Background view, and a phone shows it under the Loops
825
+ pane*.
826
+ - [x] The bare address does not land on the Background view because the conversation looked at
827
+ last was empty and never saved: it opens a new conversation in that one's project.
828
+ `test/page-server.test.ts`: *GET / after the last conversation was an empty one pi never saved
829
+ opens a new conversation in its project, not the Background view*.
307
830
  - [x] **A window older than what is installed says so, across the top, with the button that fixes
308
831
  it.** A tab left open across an upgrade looks exactly like a current one — the panel even
309
832
  shows a version, but that is the server's, read live — and three rounds of "no reply appears"
@@ -312,21 +835,121 @@ weaker, and worth converting whenever one of them breaks.
312
835
 
313
836
  ### Held, with the reason
314
837
 
838
+ - **A watch card posts only the plan's id, the verdict and the two choices to `POST /c/<sid>/watch`.**
839
+ The route, the desk that kept the plan and the `/pi-loops watch` line pi read from its stdin are
840
+ gone: the card answers pi's own select through `/ui-response`, as every dialog does, with one
841
+ option of a fixed vocabulary that the tool checks against what it offered.
842
+ - **The `pi-loops:watch` note as the receipt, and Read it again on a card that failed or was gone.**
843
+ The tool result in the same turn is the receipt now, and the model reads that result; nothing
844
+ else is written into the conversation. A failure keeps the box open with the sentence in it, so
845
+ the box's own Read it does what Read it again did; a plan cannot be gone while its question is open.
846
+ - **A Set it up with no note in a minute gives the buttons back.** The answer goes to the select pi
847
+ is waiting on, in the same process, so there is no second step that can stall; a question pi
848
+ stopped waiting for says *Not set up* at the turn's end or its own timeout instead.
849
+
850
+ - **An empty conversation that says what it is ("A pi session, in a browser.").** Owner decision
851
+ for 0.24.0, the layout explains: a blank conversation with a focused composer explains itself,
852
+ and the composer's placeholder says what to type. The undo that empties a conversation still goes
853
+ through the one reload path (*undoing the only message empties the conversation and puts the
854
+ message back in the composer*).
855
+ - **The hint row under the composer ("Enter send · Shift+Enter newline · paste images").** It
856
+ repeated the placeholder. Enter sends and Shift+Enter inserts a newline as before (the first line
857
+ of this list), and pasting an image still attaches it. The conversation's id it showed is the last
858
+ line of the ⋯ sheet.
859
+ - **A starter's meta line (`reads only`, the budget in words, "asks you to describe your tracker
860
+ first") and the "or describe a loop" link.** `reads only` was true of every starter; the cost is
861
+ a chip by the name; the wizard asks for the tracker when it needs it; and `+ Watch for…` above the
862
+ list is the one way to describe a loop.
863
+ - **A dim `not said` under a decision, the meta line on every card, and a greyed See the run.** A row
864
+ shows only when the loop gave it a value, a card says where a claim runs only when that is not the
865
+ conversation on screen, and See the run shows only when there is a run to see. The claim's own
866
+ answer still says where it runs (*Yes posts the finding's id and this conversation, nothing else,
867
+ and shows where it runs*).
868
+ - **"pi-loops running in the background (pid N): loops and checks run there" in the Loops pane, its
869
+ link "What pi-loops is doing in the background", and the rail's Background icon.** The status bar
870
+ says it and is the link to the Background view, on a phone under the Loops pane; a paused reason
871
+ is said there too.
872
+ - **The Loops pane's summary line ("inbox 3 new (1 decision) · 2 job(s) · 0 rule(s)") and its "last
873
+ run" line.** The inbox's cards and the tab title count the inbox; the pane lists the loops; the
874
+ activity strip lists every run.
875
+ - **The away card's run count and its Show what needs me button.** The activity strip counts runs
876
+ and their failures; the **Needs you** heading leads to the decisions.
877
+ - **The Loops pane's This conversation section (its name, message count and model), and Goal's
878
+ `none`.** The conversation is on screen; the model is in the header. Goal shows only while a goal
879
+ is set.
880
+ - **A loop row's `runs N`.** The activity strip counts runs.
881
+ - **The status bar's loop count ("2 loops") and its terminal count ("1 conversation open in a
882
+ terminal").** The pane lists them; the row's `terminal` tag says it.
883
+ - **The All, Needs you and Unread tabs above the list, and their counts.** They counted and filtered
884
+ the conversation rows only, yet sat above the finding cards and shared their **Needs you**
885
+ heading. The cards are the view of what needs you, and each row keeps its `⚑ N` and its unread
886
+ dot (*the list has no tabs: every row is shown, the dot marks what changed since this browser last
887
+ looked*).
888
+ - **The Watch card's terminal line (`/new-trigger …`, `/cron add …`), its *Model: only once the path
889
+ exists* row, and the `WATCH` tag over it in the transcript.** The rows are the plan; the
890
+ terminal's question keeps the line. Which model runs, and when, is a cost detail the person does
891
+ not choose. The card's heading says what the card is.
892
+ - **The Watch for… box's label, its "For example" caption, its project in the title, and the plan
893
+ card's "Not right?" line.** The title and the placeholder say what the box takes, the examples
894
+ are examples, and *Read it* reads the edited sentence again. The project is the one the list
895
+ shows above the button.
896
+
897
+ - **`/inbox open` (start a new conversation from the run that filed a finding).** A fork writes a
898
+ conversation, and the page's **See the run** covers looking at the run. The terminal keeps it.
899
+
900
+ - **"After Stop, nothing queued starts a turn until you send again", and the "send queued (n)"
901
+ button.** Owner decision, 2026-11-02: Esc in the terminal and Stop in the browser end the current
902
+ turn only, as in 0.22.8, and what is queued runs once pi is idle, as messages queued in any chat
903
+ do. In exchange the queue is visible and cancellable everywhere: the page keeps its per-row ✕ and
904
+ "queued N ✕", and the terminal has `/cron queue [cancel <n|id>|clear]`. `test/web.test.ts`: *after
905
+ Stop, the queue is untouched and runs on its own: Stop is pi's abort and nothing else*;
906
+ `test/engine.test.ts`: *after Stop, the person's queued rows run once pi is idle, oldest first, one
907
+ lane*; `test/web-page.test.ts`: *the queue lists requests from every source, in order* (no
908
+ send-queued button after Stop).
315
909
  - **pi's own built-in slash commands.** They do not exist in `pi --mode rpc`; there is nothing to
316
910
  call. The extension's own commands work.
317
911
  - **`/login`.** Its OAuth flow has no rpc equivalent. Log in once with `pi` and the rest follows.
912
+ - **`--no-auth`, and loopback only under it.** The page is now an always-on chat with a shell
913
+ behind it, so an unauthenticated page is refused in 0.24: there is no flag that serves one, and
914
+ `pi-loops --no-auth` exits 2 saying so. The token is required on every address.
915
+ - **`--host`, `--allow-host` and `--port` on the command line.** The page outlives the command, so
916
+ a flag on it would last until the next restart; its address is `[page]` in `config.toml`, and
917
+ each flag exits 2 with the line to write there. A `--port` that is not a number is refused the
918
+ same way, and a `[page] port` that is not one is reported at startup and leaves 4173.
919
+ `test/cli.test.ts`: *the page's address flags are refused with the config.toml lines that replace
920
+ them*.
921
+ - **`PI_WEB_TOKEN`.** pi-loops in the background serves the page, and it is not started from the
922
+ shell that would set the variable, so an override there would hold only until the next restart.
923
+ The token is `web-token` in the loops directory and nothing else: write your own there (letters,
924
+ digits, `-` and `_`, at least 8 of them) and run `pi-loops restart`. A new token signs every
925
+ paired device out (`test/page-server.test.ts`: *a new token signs every paired browser out*).
926
+ - **Ctrl-C in the terminal ends the session the way `/quit` does.** No terminal holds the page any
927
+ more: `pi-loops` has exited before there is anything to press Ctrl-C on. `pi-loops stop` is what
928
+ ends every conversation in the page, and each pi is still asked to quit with the signal it
929
+ handles (SIGTERM, which pi takes as `/quit`) and waited for.
930
+ - **The terminal is told what became of the automation on the way out.** There is no way out from
931
+ the terminal now: `pi-loops` prints the page's address and exits, and pi-loops keeps running
932
+ loops whatever the page does. `pi-loops status` shows the page and what is open in it.
318
933
 
319
934
  ## Transport
320
935
 
321
- - [x] Loopback by default. `--host` binds elsewhere, and refuses `--no-auth` when it does.
322
- `test/web.test.ts`: *--no-auth is refused when the front end is put on the network*.
936
+ - [x] Loopback by default. `[page] host` binds elsewhere, and only then does a bare address in Host get
937
+ in. `test/page-server.test.ts`: *a foreign Host is refused, whatever it carries*.
938
+ - [x] Every write is checked for where it came from before its body is read. A POST carries the
939
+ token in the `x-pi-web-token` header, never in its URL and never in a cookie; it is JSON or it
940
+ is refused. A form post that the browser attached the device cookie to writes nothing.
941
+ `test/page-server.test.ts`: *a POST takes the token from the header, never from its URL*, *a
942
+ refused POST writes nothing under the loops directory*.
943
+ - [x] The token never rides in a cookie, which every port on this machine is sent. A reload gets
944
+ it by trading the device cookie and the secret the page keeps for this origin; the event
945
+ stream, a file and a preview open with a key minted for that address.
946
+ `test/page-server.test.ts`: *a sniffed device cookie: a server on another port receives it,
947
+ and nothing it does with it reaches /state, /prompt or the token*, *the event stream, a file
948
+ and a preview open with a key minted for them, and not with the cookie or ?token=*.
323
949
  - [x] A name you put in front of this — a reverse proxy, a hostname on your own network — is named
324
- with `--allow-host`, nothing else is accepted under it, and the flag is in `--help`: a flag
325
- that relaxes a security check and is not documented is a flag nobody can audit.
326
- `test/web.test.ts`: *--allow-host admits the name you put in front of it, and no other*.
327
- - [x] A `--port` that is not a port is refused out loud rather than served somewhere else.
328
- `test/web.test.ts`: *a --port that is not a number is refused rather than quietly served
329
- somewhere else*.
950
+ in `[page] allow_host`, and nothing else is accepted under it.
951
+ `test/page-server.test.ts`: *an allow-listed name passes, in any case*, *a foreign Host is
952
+ refused, whatever it carries*.
330
953
  - [x] **Every route keeps its own guards, the escape hatch included.** `/rpc` carries anything in
331
954
  pi's protocol this front end has not grown a button for, and refuses the commands that do have
332
955
  a route here — reaching pi through it skipped the mid-turn refusal, the "one of this project's
@@ -336,26 +959,40 @@ weaker, and worth converting whenever one of them breaks.
336
959
  `test/web.test.ts`: *the escape hatch cannot be used to skip a route's own guards*.
337
960
  - [x] The routes that only read are only read from: `/state`, `/history` and `/stats` answer GET and
338
961
  nothing else. `test/web.test.ts`: *the routes that only read are only read from*.
339
- - [x] A request another site started is refused on every route, whatever cookie the browser
340
- attached. `test/web.test.ts`: *a request another site started is refused*.
962
+ - [x] A request another site started is refused on every route, whatever the browser attached. `test/web.test.ts`: *a request another site started is refused*.
341
963
  - [x] One address that does not change between launches, and a device is added by pointing its
342
964
  camera at a QR — with the six digits as the fallback, never a curl command.
343
- `test/web.test.ts`: *the token outlives the process*, *a phone gets in with the six-digit
965
+ `test/web.test.ts`: *the token outlives the server*, *a phone gets in with the six-digit
344
966
  code*. `test/web-page.test.ts`: *adding a device shows a code and something to point a camera
345
967
  at*, *the QR encoder still produces the matrix a scanner was shown*. A guess spends one of
346
968
  twenty tries, so a browser that is already signed in is recognised as signed in first —
347
969
  otherwise reloading a bookmark with a stale code on it burns the budget the phone needs.
348
970
  `test/web.test.ts`: *a signed-in browser reloading a stale pairing code does not spend a
349
971
  guess*.
350
- - [x] Pairing survives an upgrade: the token is a file outside the package, so a device stays signed
351
- in across restarts and new versions. `test/web.test.ts`: *the token outlives the process*.
972
+ - [x] The first browser on this machine gets in with one action. `pi-loops` prints one link with a
973
+ one-use key and opens the browser on it, or says it could not, so a paste of that link works
974
+ on the first try; on WSL the Windows browser is opened through `rundll32.exe`, then
975
+ `cmd.exe`, and "opened" is said only once a browser has spent the key. `pi-loops open` prints a fresh link, and the door names it.
976
+ `test/browser-open.test.ts`: *opened means the opener exited with a success code or is still
977
+ running*. `test/cli.test.ts`: *what pi-loops prints once the page has the conversation*,
978
+ *pi-loops open is a word of its own*. `test/web-page.test.ts`: *the door is a heading, one
979
+ line per way in, and the code field*.
980
+ - [x] `/pi-loops web` opens the conversation you are in from inside pi, with no `pi-loops` on your
981
+ PATH: the same link and opener, and the link copied to the clipboard. Checked by reading
982
+ (`src/pi-loops.ts` cannot be imported by a test).
983
+ - [x] Pairing survives an upgrade: the token and the paired devices are files outside the package,
984
+ so a device stays signed in across restarts and new versions. A browser paired before 0.24.0
985
+ pairs once more, because its cookie held the token, which no cookie may hold now.
986
+ `test/web.test.ts`: *the token outlives the server*. `test/page-server.test.ts`: *a new token
987
+ signs every paired browser out*.
352
988
  - [x] Events are incremental. The backlog a late-joining browser replays is bounded.
353
989
  `test/web.test.ts`: *the event backlog a late-joining browser replays is bounded*.
354
990
  - [x] Events carry no credential and no oversized tool payload; the image bytes a message already
355
991
  contains travel with it, and the page renders them rather than fetching them. The page itself
356
- is handed the token — in the cookie, and in the URL on a first visit — and nothing else is. The
992
+ is handed the token — in a first visit's HTML, or for the device secret at `/token` — and
993
+ nothing else is. The
357
994
  one event that could have carried a credential was pi's stderr, broadcast when it exits to say
358
995
  why: a provider that refuses to authenticate prints the key it was refused with, so that tail
359
- is capped and redacted before it leaves this process. The terminal that started the session
360
- still has the whole of it.
996
+ is capped and redacted before it leaves this process. `host.log`, in the owner's 0600 loops
997
+ directory, has all of it, uncapped but masked by the same `maskSecrets`.
361
998
  `test/web.test.ts`: *a credential in pi's dying words does not reach the page*.