@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
package/docs/cli.md CHANGED
@@ -1,49 +1,136 @@
1
1
  # The `pi-loops` command line
2
2
 
3
- `pi-loops` starts a session. With no arguments it opens the browser front end when you are at a
3
+ `pi-loops` opens a conversation. With no arguments it opens the browser page when you are at a
4
4
  local terminal, and pi itself when you are not — over ssh, or with no terminal at all, where a
5
5
  browser on this machine would help nobody. `--web` and `--tui` say which when the guess is wrong.
6
+ With no terminal at all (stdin or stdout piped) and nothing that tells pi to run without one (`-p`,
7
+ `--mode`, `--help` and the like after `--`), there is nowhere for pi to draw: `pi-loops` exits 1 and
8
+ says so, with `pi-loops: there is no terminal here to open pi in; run pi-loops --web to open the
9
+ page, or pi-loops -- -p "<prompt>" for one answer`.
6
10
 
7
11
  ```
8
- pi-loops # start a session, in whichever window makes sense here
12
+ pi-loops # open a conversation, in whichever window makes sense here
9
13
  pi-loops --tui # the terminal one
10
- pi-loops --web --port 4200 # the browser one, on a port you chose
14
+ pi-loops --web # the browser one, also over ssh (it prints the address)
15
+ pi-loops tui | pi-loops web # the same two, as words
16
+ pi-loops --loops-dir <dir> # the page uses that loops directory, as PI_LOOPS_DIR does
11
17
  pi-loops --model anthropic/claude-opus-5 -e . # anything it does not recognise goes to pi
12
18
  ```
13
19
 
14
- Both are complete pi sessions: the browser one runs `pi --mode rpc` behind a page ([src/web.mjs](../src/web.mjs)),
15
- so the session file, `--resume`, your models, tools and extensions are the same either way. What
16
- the browser cannot do is pi's own built-in slash commands, which do not exist in that mode, and
17
- `/login`, whose OAuth flow has no equivalent — log in once with `pi` and the rest follows.
20
+ The page is served by pi-loops in the background, not by this command. `pi-loops` starts pi-loops
21
+ in the background if it is not running, asks it to open the conversation, prints a link, opens your
22
+ browser and exits:
18
23
 
19
- Ctrl-C in the terminal you started it from ends the session the way `/quit` does: pi is asked to
20
- shut down rather than killed, so it still hands the clock to a headless host when there are loops,
21
- rules or MCP pushes to keep running, and the line saying so is printed before the window closes. That
22
- line is pi's own when it arrives in time — pi addresses it to the window rather than to the terminal,
23
- so when it does not, the front end reads `host.json` itself and says either `automation handed to a
24
- background host (pid N); pi-loops host status | stop` or `no background host started; automation is
25
- not running — see <loops dir>/host.log`.
24
+ ```
25
+ $ pi-loops
26
+ started pi-loops in the background
27
+ pi-loops: http://127.0.0.1:4173/c/424aba43-…/?open=c0ff…
28
+ opened in your browser
29
+ ```
30
+
31
+ The first line appears only when this command started pi-loops. The link holds a one-use key that
32
+ works for 10 minutes, never the token, because what a terminal prints stays in its scrollback. It is
33
+ printed before the browser is tried, so a browser on this machine that did not open gets in with a
34
+ paste. The last line says what happened:
35
+
36
+ - ` opened in your browser`: a browser loaded the page and spent the launch key within 8 s,
37
+ watched while the opener runs. The opener is `open` on macOS,
38
+ `rundll32 url.dll,FileProtocolHandler` on Windows, `rundll32.exe url.dll,FileProtocolHandler` on
39
+ WSL (`WSL_DISTRO_NAME` set, or a kernel release that names Microsoft; then `cmd.exe /c start`
40
+ when `rundll32.exe` cannot start), and `xdg-open` on other Linux, which gets 10 s because it takes
41
+ about 5 s to give up when there is no browser. An opener's exit code is not enough: `rundll32`
42
+ exits 0 for a target it cannot open.
43
+ - ` if no browser opened, open the link above (it works once, for 10 minutes)`: the opener ran, but
44
+ no browser spent the key within 8 s.
45
+ - ` could not open a browser here; open the link above (it works once, for 10 minutes)`: every
46
+ opener failed, or Linux has no `DISPLAY` or `WAYLAND_DISPLAY`.
47
+ - ` open the link above in a browser on this machine`: over ssh or mosh, where nothing is opened.
48
+ - ` open the link above in a browser (it works once, for 10 minutes)`: with `--no-open`.
49
+
50
+ The browser is opened on a file that only you can read, under the loops directory's `pages/open/`,
51
+ which sends it to the conversation with a second one-use key; that key is in no command line, and
52
+ the file is removed once the browser has used it, or after a minute. On WSL the Windows browser
53
+ reads that file as `file://wsl.localhost/<distro>/…`, which it cannot do when the loops directory's
54
+ path holds a character Windows does not allow in a name (`\ : * ? " < > |`); then the last line says
55
+ it could not open a browser. A browser confined by snap or flatpak
56
+ (Ubuntu's default Firefox is one) cannot read a hidden directory such as `~/.pi`, so it shows an
57
+ error instead: use the printed link. With no terminal (stdout piped or redirected), no key is
58
+ printed, because a file would keep it: the link is the page's address, and the next line is
59
+ ` pi-loops open prints a link that signs a browser in`. A conversation the page already has open
60
+ is not started again, so pi arguments given for it are not applied, and the last line says which:
61
+ ` this conversation was already open; --model was not applied`. Opening can wait up to 15 s for
62
+ an older pi in that conversation to stop; after 3 s the command says
63
+ `waiting for pi-loops to open the conversation…` on stderr, and it waits up to 30 s in all.
64
+
65
+ ### A fresh link: `pi-loops open` and `/pi-loops web`
66
+
67
+ ```
68
+ pi-loops open [--cwd <dir>] [--loops-dir <dir>]
69
+ ```
70
+
71
+ opens the page on the project's newest conversation (a new one when the project has none), with
72
+ the same output as `pi-loops`, except that it prints the link with its key with no terminal too,
73
+ because that link is what it is for; it opens no browser then. `--loops-dir` names the loops
74
+ directory, as it does for `pi-loops` and as `PI_LOOPS_DIR` does. Run it when a browser shows the door ("This browser has not been
75
+ here before."), or when the printed link has been used or is older than 10 minutes. Inside pi,
76
+ `/pi-loops web` does the same for the conversation you are in (a new one in the project when pi
77
+ has saved nothing of it yet, before its first answer or under `--no-session`), shows the same
78
+ lines, and copies the link to the clipboard (on WSL, the Windows clipboard). It adds
79
+ ` copied to the clipboard` only when `pbcopy`, `clip`, `clip.exe`, `wl-copy`, `xclip` or `xsel`
80
+ said it took the link; a copy through the terminal (OSC 52), which no terminal confirms, is tried
81
+ without a line. It needs no `pi-loops` on your PATH.
82
+
83
+ Both windows are complete pi sessions: each conversation in the page is a `pi --mode rpc` that
84
+ pi-loops runs for it, so the session file, your models, tools and extensions are the same either
85
+ way. What the browser cannot do is pi's own built-in slash commands, which do not exist in that
86
+ mode, and `/login`, whose OAuth flow has no equivalent — log in once with `pi` and the rest follows.
87
+
88
+ The page outlives the terminal. Closing the terminal, or Ctrl-C in it, ends nothing: the command
89
+ has already exited. A conversation nobody looks at stops after 15 minutes with no tab open and no
90
+ turn running, and the page keeps at most six open at once (the one used least recently and idle
91
+ goes first). Opening it again starts it again, with the same transcript. `pi-loops stop` closes the
92
+ page and every conversation in it.
26
93
 
27
94
  ### The address
28
95
 
29
- Always `http://127.0.0.1:4173/`. Bookmark it. The port is fixed rather than "whatever was free",
30
- and the token that guards it lives in `~/.pi/agent/loops/web-token` (mode 0600) rather than being
31
- made fresh every launch — so the address is the same one tomorrow, and the first visit leaves a
32
- cookie that means you never see the token again. `--port <n>` moves it if 4173 is spoken for.
96
+ `http://127.0.0.1:4173/` for the default loops directory. Bookmark it. A loops directory moved with
97
+ `PI_LOOPS_DIR` takes a free port, and `pi-loops status` names it. The token that guards the page
98
+ lives in `~/.pi/agent/loops/web-token` (mode 0600) and is kept between runs, so the address is the
99
+ same one tomorrow, and the first visit pairs the browser, so you never see the token again.
100
+ `GET /` opens the conversation you looked at last; `/c/<session id>/` opens a particular one. When
101
+ the conversation you looked at last was empty and pi never saved it, `GET /` opens a new conversation
102
+ in its project instead.
33
103
 
34
- There is a token at all because anything that reaches this server gets your whole session, and
35
- "anything" includes a website you have open in another tab: it cannot read the answers, but
36
- without a check it could still tell your agent what to do. The cookie is `SameSite=Strict`, which
37
- is a browser's promise not to send it on anything another site started, so the cost of this to you
38
- is one visit and then nothing.
104
+ The address belongs to pi-loops in the background, so it is set in `config.toml`, not on the command
105
+ line ([configuration.md](configuration.md#page)):
39
106
 
40
- Running `pi-loops` a second time while one is up does not fail on the busy port — it opens the
41
- window that is already there and leaves.
107
+ ```toml
108
+ [page]
109
+ port = 4200
110
+ ```
111
+
112
+ then `pi-loops restart`. `pi-loops --port`, `--host`, `--allow-host` and `--no-auth` exit 2 and print
113
+ the lines to write.
114
+
115
+ There is a token at all because anything that reaches this server gets every conversation in it,
116
+ and "anything" includes a website you have open in another tab: it cannot read the answers, but
117
+ without a check it could still tell your agent what to do. The page sends the token in a header
118
+ that no other site can set, never in a cookie: a cookie goes to every port on this machine. What the
119
+ first visit leaves is a device cookie (`SameSite=Strict`, which is a browser's promise not to send
120
+ it on anything another site started) and a secret the page keeps for this address; together they
121
+ get a reloaded page its token, and the cookie alone opens nothing. So the cost of this to you is one
122
+ visit and then nothing.
123
+
124
+ When 4173 is held, `pi-loops` opens nothing and exits 1. An older pi-loops page (0.23 or before,
125
+ which ran in its terminal) gets
126
+ `pi-loops: an older pi-loops page is still open on 127.0.0.1:4173; quit it (Ctrl-C in its terminal) and this page takes its place within 30 seconds`.
127
+ Any other program gets
128
+ `pi-loops: 127.0.0.1:4173 is used by another program; set [page] port in config.toml, then pi-loops restart`.
129
+ Loops and rule checks run either way.
42
130
 
43
131
  ### From a phone
44
132
 
45
- The front end binds loopback, which a phone cannot reach. Two ways round it, and the first is
46
- better:
133
+ The page binds loopback, which a phone cannot reach. Two ways round it, and the first is better:
47
134
 
48
135
  **Tailscale.** Leave the server where it is and put Tailscale in front:
49
136
 
@@ -55,19 +142,28 @@ Your phone then opens `https://<machine>.<tailnet>.ts.net/` — TLS terminated o
55
142
  your own devices, and this server still bound to 127.0.0.1. It arrives here looking local and
56
143
  carrying the tailnet name as its `Host`, which is accepted for that reason.
57
144
 
58
- **The local network.** `pi-loops --host 0.0.0.0` binds every interface and prints the addresses a
59
- phone on the same wifi can use. Anything that can open the port is then talking to your session, so
60
- the token is not optional here — `--no-auth` is refused outright in this mode.
145
+ **The local network.** `[page] host = "0.0.0.0"` in `config.toml`, then `pi-loops restart`, binds
146
+ every interface; `host.log` names the address a phone on the same wifi can use. Anything that can
147
+ open the port is then talking to your conversations, which is why the token is never optional.
61
148
 
62
149
  `tailscale funnel` is the same shape and puts it on the open internet; the token is what stands
63
- between that and anyone, so `--no-auth` refuses to serve anything but loopback no matter which
64
- route a request took to arrive.
150
+ between that and anyone.
65
151
 
66
152
  Either way the phone has to get in once, and the token is 32 hex characters, which is fine to click
67
153
  and miserable to type. So: press **add device** in the browser you are already signed in on. It
68
154
  shows a **QR code** — point the phone at it and it is in, having typed nothing — and the same six
69
- digits underneath for when a camera is not the thing you want to use. The terminal prints a code at
70
- startup too.
155
+ digits underneath for when a camera is not the thing you want to use. From a terminal, `pi-loops pair`
156
+ makes the same code and names the addresses that device can open:
157
+
158
+ ```
159
+ $ pi-loops pair
160
+ pairing code for another device: 482913 (10 minutes)
161
+ on that device, open http://192.168.1.20:4173/ and type it
162
+ ```
163
+
164
+ A code exists only when you ask for one, with the button or with `pi-loops pair`. Opening a
165
+ conversation does not make one, so no other program on the machine can spend its twenty guesses
166
+ before you need it.
71
167
 
72
168
  The QR encodes the address *this browser reached the server on*, which under `tailscale serve` is
73
169
  the tailnet name and is the one that works from anywhere on your tailnet. If the window you press it
@@ -77,44 +173,51 @@ so instead of showing you one that does not.
77
173
  A code is good for one use and lasts ten minutes; twenty wrong guesses disable it until you ask
78
174
  for another.
79
175
 
80
- On a network you do not own, remember that `--host` is plain http: the cookie it hands out carries
81
- a token that outlives the process, and anyone on the wire can read it. That is the case
82
- `tailscale serve` exists for, and the startup output says so.
176
+ On a network you do not own, remember that `[page] host` is plain http: every request the page
177
+ makes carries a token that outlives the process, and anyone on the wire can read it. That is the
178
+ case `tailscale serve` exists for.
179
+
180
+ The phone has a page only while pi-loops runs in the background. Until something runs `pi-loops`
181
+ (or a pi window, or the service unit in
182
+ [Running pi-loops under your own service manager](#running-pi-loops-under-your-own-service-manager)),
183
+ there is nothing on 4173 to answer it.
83
184
 
84
185
  Add it to the home screen and it opens without browser chrome: there is a web app manifest, and no
85
186
  service worker — nothing here is worth caching, and a stale copy of a live front end is worse than
86
187
  no copy.
87
188
 
88
- ### Dropping the token
89
-
90
- `pi-loops --no-auth` drops the token entirely: no cookie, no query string, nothing to carry, and
91
- anything on this machine that can open port 4173 has your session. What is left is the check that
92
- the request did not come from another site — a browser tells the truth about that, and it is what
93
- keeps a page on `http://localhost:5173` from posting into your agent — but any *program* running as
94
- any user on this machine is then in. It is the right trade on a machine only you use, and the wrong
95
- one on a shared host.
96
-
97
189
  A browser that has never been here (a different one, or after clearing cookies) gets a page saying
98
- so. Start a session from a terminal on this machine and it will open a window that works from then
99
- on, or use the `?token=…` address the terminal printed.
190
+ so. Run `pi-loops` from a terminal on this machine and it opens a window that works from then on,
191
+ or use the `?token=…` address it printed.
192
+
193
+ ### No token, no longer
100
194
 
101
- ### It starts a session; it does not attach to one
195
+ `--no-auth` is gone in 0.24: the page is always on and always behind its token. A page that runs
196
+ whether or not a terminal is open, with a shell behind every conversation, is not one to leave open
197
+ to every program on the machine.
102
198
 
103
- The browser front end starts its own `pi --mode rpc`. It does not join a pi you already have open
104
- in a terminal — two front ends driving one agent is not something pi offers, and pretending
105
- otherwise would mean two windows disagreeing about whose turn it is.
199
+ ### Opening a conversation you already have
106
200
 
107
- Picking up where you left off is a different thing, and pi already has it. The flags reach pi
108
- unchanged, so:
201
+ The page starts a `pi --mode rpc` for each conversation it opens. Picking up where you left off
202
+ uses pi's own words for it:
109
203
 
110
204
  ```
111
- pi-loops --continue # the newest session in this directory, in the browser
112
- pi-loops --resume # pick one
113
- pi-loops --session <id-or-path> # a particular one
205
+ pi-loops --continue # the newest conversation in this directory, in the page
206
+ pi-loops --session <id-or-prefix> # a particular one
114
207
  ```
115
208
 
116
- That also means you can hand a session between windows: quit the terminal one, `pi-loops --continue`,
117
- and carry on in the browser with the same transcript.
209
+ `pi-loops` finds the conversation the way `pi-loops export` does and asks the page for it. An id it
210
+ cannot find is refused, with exit 1. `--resume` is pi's picker, which the page has as **resume**
211
+ (`/resume`), so `pi-loops --resume` exits 2 and says so.
212
+
213
+ A flag for pi goes after `--` when there is one: `pi-loops --web -- --session <id>`. A flag that is
214
+ not this command's placed before `--` is refused with the command line to use, rather than dropped.
215
+ The page keeps a conversation's pi arguments under `pages/`, so it reopens the same way after an
216
+ idle stop or a restart; `--api-key` is refused for that reason (exit 1), and the line says to set the
217
+ provider's environment variable instead.
218
+
219
+ A conversation a terminal holds can be open in the page at the same time. A message sent from the
220
+ page is then answered in the terminal, and the page says so.
118
221
 
119
222
  ### Starting over, without leaving the window
120
223
 
@@ -134,16 +237,15 @@ filename. Compaction reports what the context was, what it became, and what the
134
237
  Underneath, **clear** and **resume** are pi's `switch_session`: the session is replaced inside the
135
238
  process that is already running, so the model, the MCP connections and the extensions are the same
136
239
  ones, rebuilt against the new session. That is what `/new` and `/resume` do in the terminal, and
137
- automation follows it the same way — the scheduler stops and starts with the session rather than
138
- handing the clock to the headless host, and a scheduled run the swap interrupts gives its slot back
139
- and fires again on the next tick.
240
+ automation follows it the same way: pi-loops keeps running through the swap, and the conversation's
241
+ plain jobs move with it to whichever window now holds it.
140
242
 
141
243
  One thing the front end adds on top of the swap: **resume** puts the session back on the model it
142
244
  was last using. pi re-resolves the launch command line's `--model` on every swap — and the launcher
143
245
  supplies one from the model you last chose ([configuration.md](configuration.md#what-is-remembered-between-sessions))
144
246
  — so without this, going back to a conversation had with one model landed it on a different one. If
145
247
  the model it used is not available any more (credentials gone), the session stays on the one it has
146
- and the terminal says which model it could not go back to.
248
+ and `host.log` says which model it could not go back to.
147
249
 
148
250
  Both are refused while a turn is running, because the swap would abort it: stop the turn first. A
149
251
  session started this way is named by the front end rather than by pi, which is invisible everywhere
@@ -154,16 +256,49 @@ by the id in its header.
154
256
 
155
257
  `pi install` puts this package under pi's managed directory rather than on your `PATH`, so the
156
258
  command that is meant to start your sessions is at first reachable only by absolute path. The
157
- extension does that step for you: the first pi session that finds no `pi-loops` anywhere on your
158
- `PATH` asks once whether to write one, and a no is remembered — nothing is written and you are not
159
- asked again. Afterwards it keeps that launcher current: if the copy it runs is not there any more
259
+ extension does that step for you, with no question: the first interactive pi session after
260
+ `pi install` writes `pi-loops` into the first of `~/.local/bin` and `/usr/local/bin` that is on your
261
+ `PATH`, and says so in one line:
262
+
263
+ ```text
264
+ [pi-loops] added pi-loops to ~/.local/bin; /pi-loops uninstall-launcher removes it
265
+ ```
266
+
267
+ It writes only where nothing named `pi-loops` is there yet. A link, even one that points nowhere, a
268
+ directory, or a file it cannot read counts as something there, and the file is created only if it
269
+ does not exist, so the write never goes through a link at that name. The directory itself may be a
270
+ link to a directory, as a dotfiles `~/.local/bin` often is. A `pi-loops` this project did not write,
271
+ such as your own wrapper, is left alone, and so is one that runs another copy of this package still
272
+ on disk, which is that copy's to keep. With neither directory on your `PATH`, or with
273
+ the one on your `PATH` not made yet, nothing is written and nothing is said: a session start never
274
+ makes a directory. When the write fails, for example because `/usr/local/bin` belongs
275
+ to root, one warning line says why, and no later session tries that directory again; a `PATH` that
276
+ names another directory is a new try. A checkout never writes a launcher that is not there.
277
+
278
+ Afterwards the extension keeps that launcher current: if the copy it runs is not there any more
160
279
  (pi installed the package by the other route), or that copy is the one now running and the node has
161
- changed, the next session rewrites it and says so in one line. A `pi-loops` this project did not
162
- write — your own wrapper — is left alone, and so is one that runs another copy of this package still
163
- on disk, which is that copy's to keep. A checkout is never asked to write a launcher that is not
164
- there, though one written from a checkout is still kept working.
280
+ changed, the next session rewrites it and says so in one line.
165
281
 
166
- To write one yourself, or after a no, run it from inside pi, where the extension is already loaded:
282
+ To remove it, and stop every later session from writing it again:
283
+
284
+ ```text
285
+ /pi-loops uninstall-launcher
286
+ ```
287
+
288
+ ```bash
289
+ pi-loops uninstall-launcher
290
+ pi-loops uninstall-launcher --dir ~/bin
291
+ ```
292
+
293
+ `--dir` needs a directory after it, here and on `install-launcher`: a `--dir` with nothing after it,
294
+ or with a flag after it, is a usage error (exit 2), not the default directory.
295
+
296
+ It removes only a launcher of ours that this copy keeps. For any other file named `pi-loops` it
297
+ says whose file it is, leaves it as it is, and exits 1. Either way it remembers the choice in
298
+ `ui.json`.
299
+
300
+ To write one yourself, after `uninstall-launcher` or into another directory, run it from inside pi,
301
+ where the extension is already loaded:
167
302
 
168
303
  ```text
169
304
  /pi-loops install-launcher
@@ -178,6 +313,8 @@ node src/cli-entry.mjs install-launcher
178
313
  node src/cli-entry.mjs install-launcher --dir ~/bin
179
314
  ```
180
315
 
316
+ `install-launcher` clears the choice `uninstall-launcher` remembered.
317
+
181
318
  It writes a small `sh` script that names the node you ran it with and the package it lives in — a
182
319
  launcher rather than a symlink. Neither path is trusted for ever: if the package is not where it
183
320
  was, the launcher looks in both of the directories above (the ones pi installs into, under the agent
@@ -215,18 +352,25 @@ the newest release, and `upgrade` and it then agree.
215
352
  ## The tools
216
353
 
217
354
  The rest of the command line does not need a pi session and has no build step; it resolves pi's
218
- packages the way the headless host does. `PI_LOOPS_DIR` selects the loops directory, as everywhere.
355
+ packages the way pi-loops does. `PI_LOOPS_DIR` selects the loops directory, as everywhere.
219
356
 
220
357
  ```
221
358
  pi-loops sessions [--all] [--limit <n>]
222
359
  pi-loops inspect <file>
223
360
  pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]
224
361
  pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
225
- pi-loops host status | abort <run-id|trace-id> | stop
362
+ pi-loops start [--foreground] | stop | restart | status [--url] | abort <run-id|trace-id|session-id>
226
363
  pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]
227
364
  pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>] [--json]
228
365
  ```
229
366
 
367
+ `-h` or `--help` anywhere in a subcommand's arguments before a `--` prints that subcommand's usage
368
+ and exits 0 without doing anything else; `pi-loops -h` is `pi-loops --help`. After `--` they are pi's.
369
+ A command typed wrong, such as a missing archive path, a flag the command does not read
370
+ (`status does not take --json`) or a word it does not read (`export: unexpected argument "out.pisession"`),
371
+ prints `pi-loops: <what is wrong>` and that command's usage, and exits 2. Any other error prints only its line and exits 1. An unknown command
372
+ names itself and the version you run, and exits 2.
373
+
230
374
  The command line reads, triages one finding at a time and moves sessions. It never creates or
231
375
  removes: there is no `cron remove`, no `recipe remove` and no `inbox clear`. Creating and removing
232
376
  jobs, rules and recipes are slash commands, because each asks a person first and records what it did
@@ -254,7 +398,7 @@ taken. `--session` accepts a full id or a unique prefix. `--exclude-triggers` dr
254
398
  sidecar.
255
399
 
256
400
  ```
257
- pi-loops export --session 01a084a0 --output ~/backups/api.pisession
401
+ pi-loops export --session 01a084a0-1c2d --output ~/backups/api.pisession
258
402
  ```
259
403
 
260
404
  ## import
@@ -268,28 +412,142 @@ disabled and then asks once, where there is a terminal or a UI to ask in, whethe
268
412
  was enabled in the source. `off` never asks; `on` activates it as part of the import.
269
413
  Importing the same archive twice adds nothing the second time.
270
414
 
271
- ## host
415
+ ## pi-loops in the background
272
416
 
273
- The headless host has no chat, so `host status` is how you see what it is doing while no pi is
274
- open: the loop runs and trigger checks in flight, what is enabled, the inbox count, and each MCP
275
- server's state.
417
+ `pi-loops status` prints the process status, including its pid, version, start time, current work,
418
+ conversations held by windows, and log path. If it is stopped, the command says why. The older
419
+ spellings `pi-loops host status|abort|stop|start` and `/cron host` still work.
276
420
 
277
421
  ```
278
- $ pi-loops host status
279
- background host
280
- pid 2949521 on box, started 2026-09-09T13:33:16.652+08:00, model openai-codex/gpt-5.5
281
- owns the clock · 1/1 loop(s), 0/0 rule(s) enabled · inbox: 0 new
282
- running nightly (run-97ffb250) since 2026-09-09T13:34:16.676+08:00: check the repo issues
422
+ $ pi-loops status
423
+ pi-loops
424
+ pid 2949521 on box, started 2026-09-09 13:33, model openai-codex/gpt-5.5
425
+ pi-loops 0.24.1 · 1/1 loops, 0/0 rules enabled · inbox: 0 new
426
+ started by the shell that ran pi-loops
427
+ conversation 424aba43-0c1f "fix the flaky login test" open in pid 939915 (tui)
428
+ running nightly (run-97ffb250) since 2026-09-09 13:34: check the repo issues
429
+ 3 messages waiting for 2 conversations, 1 could not be delivered
430
+ 424aba43-0c1f: 2 waiting · oldest 5m · 1 could not be delivered · open in pid 939915
431
+ 9e1f3b07-77a0: 1 waiting · oldest 30s · closed
283
432
  ```
284
433
 
285
- The stamps carry the offset of the machine the host runs on ([loops.md](loops.md#time-and-which-clock-it-is));
286
- these are a `+08:00` machine's.
434
+ The waiting lines appear when something waits to be delivered to a conversation (in
435
+ `requests/<session>.jsonl`): how many, how long the oldest has waited, how many failed three times
436
+ and go to the inbox next, and where the conversation is (`open in pid <pid>`, `in the background`,
437
+ or `closed`, one pi-loops is not running a turn in). They are read from the files, so
438
+ `pi-loops status` prints them with pi-loops down too, after the line that says it is not running.
439
+
440
+ `pi-loops stop` stops it until `pi-loops start`, `/pi-loops start` or `pi-loops` opening the page
441
+ (which prints `pi-loops was stopped by hand; it runs again now (loops and rules resume)` before the
442
+ address), and waits up to 15 seconds for
443
+ it to exit; a process that has not exited by then (its event loop blocked) gets SIGKILL. The page
444
+ stops with it, and so does every conversation open in it:
445
+ ` the page and its 2 open conversations closed with it; pi-loops start opens it again`. `pi-loops
446
+ start` starts it now, even with nothing to run yet. `pi-loops restart` stops it, waits until the old
447
+ process has exited (SIGKILL after 180 seconds), and starts it. A restart, and an upgrade that a pi
448
+ window with a newer pi-loops asks for, first wait up to two minutes for a turn in the browser page to
449
+ finish, so a conversation open there is not cut off mid-answer; a restart first prints `waiting up
450
+ to 2 min for a turn in the browser page to finish…`. A plain `pi-loops stop` ends it at once, and one
451
+ run during that wait ends the wait. A restart or an upgrade asked for while it already waits is
452
+ answered `already leaving` and does not start the wait again. With `--help`, each of these prints its
453
+ usage and does nothing else.
454
+
455
+ `pi-loops abort` takes three kinds of id. `abort <run-id>` interrupts one run and `abort <trace-id>`
456
+ one rule check, both through pi-loops in the background (short trace ids are accepted). `abort
457
+ <session-id>`, a full id or a unique prefix of one pi-loops knows (a conversation with a lease or a
458
+ queue), stops the turn running in that conversation in a terminal's pi, in the browser's pi, or in
459
+ pi-loops in the background. It does not reach a `pi -p` or `--mode json` run: such a run reads no
460
+ requests, and nothing else may stop a turn in a conversation a live process holds. That stop is a
461
+ request in `requests/<session>.jsonl`, read by whichever process holds the conversation, so it needs
462
+ no socket; a run id whose process does not answer goes the same way when a lease names it. It stops
463
+ only a turn that began before you asked, so a stop that arrives late never stops the next turn, and a
464
+ held turn it stops is done: pi-loops does not ask that prompt again. `abort` waits up to 5 seconds
465
+ and ends with one of three lines: `stopped the turn in session <id>`; `nothing was running in session
466
+ <id>` (exit code 1); or `stop queued for session <id>; it takes effect when that conversation's pi or
467
+ pi-loops reads it`. A prefix shorter than 8 characters is read as a trace id. A prefix that fits more
468
+ than one session, or a run id prefix that fits more than one running turn, lists them and exits 1.
469
+
470
+ The times are the local time of the machine pi-loops runs on, with no offset
471
+ ([loops.md](loops.md#time-and-which-clock-it-is)).
472
+
473
+ ### The Background view
474
+
475
+ What pi-loops in the background is doing is a view of the page, at `<page address>background`
476
+ (`http://127.0.0.1:4173/background` for the default loops directory). With no conversation to open
477
+ and no project to start a new one in, `GET /` shows it too. `pi-loops status --url` prints the page's address with its token, for a
478
+ browser that has not been there yet:
287
479
 
288
- `host abort <id>` interrupts one run or trigger check (the shortened ids the status prints are
289
- accepted). `host stop` ends the host; the next pi to open would have taken the clock back anyway.
480
+ ```
481
+ $ pi-loops status --url
482
+ http://127.0.0.1:4173/?token=3f9c…
483
+ ```
484
+
485
+ The view lists every conversation that is open somewhere or has a plain job, a rule or something
486
+ waiting, with where it is (`a terminal`, `this page`, `the background`, `a pi-loops 0.22 window`,
487
+ `closed`), then what is running, what waits in the words `pi-loops status` uses, the budget, failing
488
+ loops (three failures in a row, as `/cron` marks them), recent runs and MCP servers, every time in the browser's own format and no pid. It refreshes
489
+ every 15 seconds. `/background.json` is the same as JSON, every field as before.
490
+ It is behind the page's token, like every other address of the page.
491
+
492
+ `pi-loops status` has a `page:` line for it: ` page: http://127.0.0.1:4173/ · 2 open conversations`,
493
+ or why there is none (` page: waiting for 127.0.0.1:4173 (an older pi-loops page holds it)`). A
494
+ conversation open in the page reads ` conversation 424aba43-0c1f open in the page (pid 939915, 1 tab,
495
+ idle 3m)`. A conversation is named by the first 13 characters of its id, which only conversations
496
+ started in the same millisecond share, followed by its name or first message (at most 40 characters)
497
+ when its session file has one. The terminal, the page and the Background view name it the same way,
498
+ and `--session` takes it.
499
+
500
+ The token is kept in `web-token` (0600) in the loops directory, so a bookmarked address and a paired
501
+ phone keep working after pi-loops restarts. A stolen token reaches every conversation for as long as
502
+ pi-loops runs. To retire one that was shared, remove `web-token` and run `pi-loops restart`: the new
503
+ process makes a new token, and every browser signs in again. A token file other users can read or
504
+ write is set back to 0600 when pi-loops starts, and one that another user owns is refused.
505
+
506
+ ### Running pi-loops under your own service manager
507
+
508
+ `pi-loops start --foreground` runs pi-loops in the terminal until it is stopped, for a service
509
+ manager that you set up. pi-loops writes no unit itself. A systemd user unit, with the path to
510
+ `pi-loops` filled in, at `~/.config/systemd/user/pi-loops.service`:
511
+
512
+ ```ini
513
+ [Unit]
514
+ Description=pi-loops
515
+
516
+ [Service]
517
+ ExecStart=<path to pi-loops> start --foreground
518
+ Restart=on-failure
519
+ RestartPreventExitStatus=78
520
+
521
+ [Install]
522
+ WantedBy=default.target
523
+ ```
290
524
 
291
- The channel is a unix socket at `host.sock` in the loops directory, created 0600, and it is
292
- read-mostly on purpose: a host you could prompt would be a second chat.
525
+ Then `systemctl --user enable --now pi-loops`. Keys saved with pi's `/login` reach the process; keys
526
+ exported only in a shell do not. The unit is also what keeps the page up: until something runs
527
+ `pi-loops`, a pi window or the unit, a phone has no page to open.
528
+
529
+ The exit status tells systemd what happened:
530
+
531
+ - 0: stopped by hand (`pi-loops stop`, SIGTERM, SIGHUP), or asked to make way for a newer
532
+ pi-loops. `Restart=on-failure` leaves it stopped.
533
+ - 78: it cannot run here (the package or pi has moved). `RestartPreventExitStatus=78` leaves it
534
+ stopped.
535
+ - Anything else: a crash, which systemd restarts.
536
+
537
+ After `pi-loops stop`, `systemctl --user start pi-loops` starts it again, and so does the next login,
538
+ as with any systemd service. Started during a crash pause, the unit's process waits it out (half an
539
+ hour, longer for each pause in a row) and then runs; `/pi-loops start` ends the pause at once. `pi-loops start` and
540
+ `pi-loops restart` start a process of their own that the unit does not manage, and so does a pi
541
+ window when it upgrades pi-loops. While one of those runs, the unit's process finds it and exits 0.
542
+ To hand it back to the unit, run `pi-loops stop`, then `systemctl --user start pi-loops`. A LaunchAgent restarts every non-zero exit when `KeepAlive`
543
+ has `SuccessfulExit` false, so under launchd an exit of 78 is restarted too.
544
+
545
+ When pi-loops is stopped, `status` says why: stopped by hand, `PI_LOOPS_HOST=off`, `[host] auto =
546
+ false`, paused after repeated crashes, or a start that failed and when it is retried. The channel is a
547
+ unix socket at `host-service.sock` in the loops directory, created 0600, and it is read-mostly on
548
+ purpose: a background process you could prompt would be a second chat. Besides status, abort and stop
549
+ it accepts only `run` (a window's `/cron run` of a loop that already exists) and `upgrade` (a window
550
+ running a newer pi-loops asking the old process to make way).
293
551
 
294
552
  ## recipe
295
553
 
@@ -316,6 +574,9 @@ pi-loops inbox dismiss inb-3f2a… --reason "that file is generated" --json
316
574
 
317
575
  `/inbox` with no pi open ([loops.md](loops.md#the-inbox)). `claim` and `dismiss` take an id or a
318
576
  unique prefix of one and change the entry the way the slash command does; a claim here cannot start
319
- a turn, so it marks the finding and leaves the acting to whoever asked. Without `--json` the same,
320
- one line per finding, for a person. The JSON is fixed in [CONTRACT.md](../CONTRACT.md) §2.2 and shown in [downstream.md](downstream.md), which is
577
+ a turn, so it marks the finding and leaves the acting to whoever asked. A finding of a loop that
578
+ belongs to its project, not a conversation ([loops.md](loops.md)), is marked claimed the same way,
579
+ and no conversation acts on it: the caller handles it. Without `--json` the same,
580
+ one line per finding, for a person: the full id first, then the finding, with a decision's clauses
581
+ on the same line as `· waiting on: …` and `· if you don't: …` (the words the page uses). The JSON is fixed in [CONTRACT.md](../CONTRACT.md) §2.2 and shown in [downstream.md](downstream.md), which is
321
582
  the page a program should be written against.