@alphacoder-v0/pi-loops 0.22.8 → 0.24.0

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 (91) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  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 +37 -26
  17. package/docs/web-ui-parity.md +691 -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/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -42
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +595 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
package/docs/design.md CHANGED
@@ -8,8 +8,8 @@ do for you.
8
8
  | the job | what does it |
9
9
  |---|---|
10
10
  | a clock | `setInterval` started in `session_start`, cleared in `session_shutdown` |
11
- | a scheduled job that lands in your chat | `pi.sendUserMessage` (follow-up queue when busy), `[Trigger <id>] ` prefix |
12
- | a scheduled job that must not touch your chat | an in-process `AgentSession` from pi's SDK (`src/sdk-runner.ts`): fresh context, own transcript, the parent's tools, MCP clients, extensions, model and trust |
11
+ | a scheduled job that lands in your chat | a row in `requests/<session>.jsonl`, handed to `pi.sendUserMessage` by the window holding the session once the agent is idle, `[Trigger <id>] ` prefix |
12
+ | a scheduled job that must not touch your chat | an in-process `AgentSession` from pi's SDK (`src/sdk-runner.ts`): fresh context, own transcript, and the tools, MCP clients, extensions and model of the process that runs it (pi-loops, for loops and checks) |
13
13
  | state that outlives a session | `jobs.json`, `triggers.json`, `state/<id>.md` under `~/.pi/agent/loops` |
14
14
  | a record of who changed what | `pi.appendEntry` for cron control operations; `triggers-audit.jsonl` for trigger runs |
15
15
  | something to look at | transcript cards via `pi.appendEntry` + an entry renderer; a widget above the editor; a footer badge via `ctx.ui.setStatus` |
@@ -27,46 +27,77 @@ machine. Everything here follows from that, and each one has a price.
27
27
  1. **Machine-global jobs and rules, each carrying a `cwd`.** `/cron` and
28
28
  `/triggers rules` show the current project by default, and say how many are elsewhere. A plain
29
29
  (inject) job belongs to the session that created it — its result is a message in that
30
- conversation — and everything that lists it agrees with dispatch about that: another session
31
- shows it as `[session <id> — resume it to run]` with no next run, `cron_list` says the same to
32
- the model, `/cron run` refuses it. Asleep is not broken. It is parked as disabled only once its
33
- session's file has been deleted (`/cron gc` removes it). A loop
30
+ conversation — and everything that lists it agrees with dispatch about that. While no window
31
+ holds the conversation pi-loops fires it there, holding the conversation for that one turn
32
+ (`[session <id> — in the background]`); when pi-loops is not running it is paused (`paused: <why>`), and for a
33
+ conversation whose file pi-loops never saw it is asleep (`[session <id> — resume it to run]`, no
34
+ next run, `/cron run` refuses).
35
+ Asleep is not broken. It is parked as disabled only once its session's file has been deleted
36
+ (`/cron gc` removes it). A conversation open in two windows is
37
+ held by one of them: a lease in `leases/<session>.json` names the holding instance, and it changes
38
+ hands when its holder releases it or that process is gone (its pid, or a recycled pid's start
39
+ time), and a window takes it from pi-loops at once; never on a timer.
40
+ The other window is a viewer and fires none of the conversation's plain jobs. A loop
34
41
  whose `cwd` disappeared waits half an hour — a mount can be late at boot — and is disabled after
35
42
  that.
36
43
 
37
44
  The cost: a list has to be scoped, and a job you cannot see reads as a job that is gone. Hence
38
- the counts, the markers, and `/cron all`.
39
-
40
- 2. **Who runs what.** Every process ticks. Loops run in the machine leader
41
- (`scheduler.json`, a pid and a heartbeat). A project's dynamic checks and push
42
- evaluations run in a pi that is *open in that project* — preferring the session that created the
43
- rules, lowest pid otherwise (`presence/`) — so a promotion lands in the conversation it belongs
44
- to. Only a project with no pi open falls to the leader, and its results go to the inbox
45
- (`redirected`). The poll interval is enforced machine-wide (`polls.json`), so a hand-over never
46
- double-checks.
45
+ the counts, the markers, and `/cron all`. And a holder that is alive but frozen (stopped in a
46
+ debugger, a wedged process) keeps its conversation's plain jobs waiting until it exits. The lease
47
+ does not expire on a timer on purpose: a timer cannot tell a frozen window from a suspended
48
+ laptop, and one is what used to move the clock away from a laptop that had only been asleep.
49
+ pi-loops writing into a closed conversation costs one race, stated: a person who opens the
50
+ conversation during the seconds pi-loops spends on a turn there takes it back at once and the
51
+ turn stops, but what pi-loops wrote meanwhile is on disk and not on that screen. Their next
52
+ entry is a sibling branch (a pi session is a tree, so nothing is lost), and the window says so;
53
+ the slot is owed, so that window then runs the job once more, and the conversation shows the
54
+ stopped attempt before the answer. A pi with the conversation open but without pi-loops in it is
55
+ guarded against only by pi-loops leaving a file someone else wrote in the last two minutes alone.
56
+ A held turn is a run like a loop's (a record, its own cost, a failure streak), and its extensions
57
+ stay loaded for that conversation's later turns: up to eight conversations, released after an
58
+ hour idle.
59
+
60
+ 2. **Who runs what.** One always-on process runs stateful loops, dynamic-rule checks and MCP push
61
+ evaluation for a loops directory. A window starts it when there is work to run, including the plain jobs and queued rule results of
62
+ closed conversations. Windows run only
63
+ the plain jobs for conversations whose leases they hold. Rule results go to the conversation
64
+ that created the rule, queued for its holder when needed; when no window holds it, pi-loops
65
+ appends the result to the conversation. A queued row pi-loops cannot run (an attended one, which
66
+ waits for a window) costs it no read of the conversation on a tick where the conversation,
67
+ `jobs.json` and the queue are as they were and no backoff has ended; it remembers the three at
68
+ the last "nothing to do" and reads again when one moves. The inbox gets a result only when the conversation cannot
69
+ be written; shell-created rules go to the inbox. During an upgrade, `presence/` is read only to accommodate
70
+ pi-loops 0.22 windows; pi-loops writes `scheduler.json` for those old windows to read.
71
+
72
+ The cost: loops pause while pi-loops cannot start, whereas they previously ran in an interactive pi.
73
+ A loop no longer shares a window's live MCP connections; it connects its own from `mcp.toml`
74
+ and the project. A pi-loops process that is alive but frozen blocks nothing: a window replaces it after three
75
+ failed probes and a heartbeat it stopped writing. After a reboot, nothing runs until a pi window opens, unless the person installed
76
+ a user-managed service (decision 21).
47
77
 
48
78
  3. **Model and thinking level are recorded when a job is created** and editable afterwards
49
- (`/cron set`, `/triggers set`; `--model -` follows the running session). A loop that was set up
79
+ (`/cron set`, `/triggers set`; `--model -` clears the pin: a loop then uses pi's default model, a
80
+ plain job its conversation's). A loop that was set up
50
81
  against a capable model does not quietly start running on whatever the newest session happens to
51
82
  be using.
52
83
 
53
84
  4. **MCP pushes.** A push that injects into the chat reaches every window that has the server. A
54
- push evaluated against rules is evaluated once per project, by that project's owner. Sub-agents
85
+ push evaluated against rules is evaluated once per project, by pi-loops. Sub-agents
55
86
  ignore pushes; anything that reaches hop ≥ 1 is audited as `cycle_suppressed`.
56
87
 
57
- 5. **Sub-agents are sessions, not processes** (`createAgentSession`). They share the parent's live
58
- MCP client instances — the browser tab or database session opened in the chat is the one a loop
59
- sees — its `-e` extensions, system prompt and skill flags, its model unless the job pins one, the
60
- project's trust when the run is in a project this session or an earlier `/trust` decision
61
- trusted (never by default), and the parent session's id, so plain jobs they schedule bind to it.
62
- The parent's extensions get their `session_start` / `session_shutdown` in every sub-session.
88
+ 5. **Sub-agents are sessions, not processes** (`createAgentSession`). They share the live MCP
89
+ clients, extensions and model of the process that runs them. For loops and checks that process
90
+ is pi-loops: a loop sees pi-loops' own MCP connections and pi's default model unless the job
91
+ pins one, not a window's browser tab, `-e` extensions or flags. A run reads a project's own
92
+ resources only when pi has saved a trust decision for that exact directory (never by default).
93
+ That process's extensions get their `session_start` / `session_shutdown` in every sub-session.
63
94
  Each run gets its own transcript file and a configurable cap (`[triggers] run_timeout_secs`,
64
95
  per-rule `--timeout`). Nothing is re-spawned per run.
65
96
 
66
97
  The cost: a sub-agent has no UI, so a tool that would ask for confirmation is denied
67
98
  fail-closed there. Nobody can say yes, so the answer has to be no.
68
99
 
69
- 6. **Catch-up.** A loop tick missed while no pi was running is fired once at startup, collapsed
100
+ 6. **Catch-up.** A loop tick missed while pi-loops was not running is fired once when pi-loops starts, collapsed
70
101
  rather than replayed (`[cron] catch_up = false` or `--no-catchup` turns it off). Plain jobs do
71
102
  not catch up unless `--catchup`: they land in a conversation, and a conversation that opens to
72
103
  four hours of backfill is worse than one that opens to nothing. A run that died with its process
@@ -83,23 +114,38 @@ machine. Everything here follows from that, and each one has a price.
83
114
  9. **A promotion that arrives while the agent is busy** goes to pi's follow-up queue and runs a
84
115
  turn after the current one; when the agent is idle it is inserted without a model call.
85
116
 
86
- 10. **Nobody around.** When the last interactive pi on the machine quits with loops, rules or MCP
87
- servers configured, it starts a headless host (`src/host.ts`, a plain `node` process using the
88
- same stores, the same in-process runner and its own MCP clients) that keeps the clock: loops,
89
- trigger checks for every project, pushes, catch-up. Chat-bound output goes to the inbox. The
90
- first interactive pi to open takes the clock back — its scheduler preempts a `host` leader —
91
- and the host exits. `/cron host [start|stop]`, `[host] auto = false` to opt out, `host.log` for
92
- its output.
93
-
94
- What triggers it is pi shutting a session down cleanly with reason `quit`: `/quit` in a terminal,
95
- or SIGTERM/SIGHUP, which are the signals pi handles. Nothing else does — so the browser front end
96
- starts its pi in a process group of its own and turns a Ctrl-C into that SIGTERM itself
97
- (`src/web.mjs`). A pi that shares the terminal's group is killed by the group's SIGINT, which pi
98
- has no handler for, and the hand-off never happens.
99
-
100
- The cost: nothing restarts it after a machine reboot until a pi opens. It is a hand-off between
101
- processes, not a system service, and installing one would mean asking for privileges this does
102
- not otherwise need.
117
+ 10. **One always-on process per loops directory.** It is a `node` process recorded in
118
+ `host-service.json`, with output in `host.log`. A window starts it on session start, every
119
+ 30-second tick, and immediately after a loop, rule or relevant MCP server is added, when there is
120
+ enabled work. It runs stateful loops, dynamic-rule checks and MCP pushes, and the plain jobs of
121
+ conversations no window holds (decision 1).
122
+ It does not exit when windows open or close. `/pi-loops status` shows its pid, version, start time,
123
+ work, held conversations and log. `/pi-loops stop` records a manual stop until `/pi-loops start`, or until `pi-loops` opens the
124
+ page and says that loops and rules resume;
125
+ `[host] auto = false` and `PI_LOOPS_HOST=off` prevent automatic starts. When pi-loops cannot start, it is retried after 1, 5 and 30 minutes. pi-loops does not catch an error and carry on from inside the
126
+ process, because its state after an uncaught error cannot be trusted. An uncaught exception is logged first, with the runs in
127
+ flight, then counted and the process exits; a record left naming a dead process (SIGKILL, OOM)
128
+ counts the same, and so does an eviction. Three of those within five minutes pause pi-loops,
129
+ and it stays paused until `/pi-loops start`; a job whose runs die with their process three
130
+ times in a row is parked with the reason. When pi-loops cannot run here at all, it exits with code 78, which a systemd unit configured as in [cli.md](cli.md#running-pi-loops-under-your-own-service-manager) does not restart. A
131
+ window replaces pi-loops (SIGTERM, then SIGKILL after 10 seconds) only after three unanswered
132
+ control-socket probes and a heartbeat pi-loops stopped writing; pi-loops still ticking
133
+ with a broken socket is reported, not killed, and a starting pi-loops process is never killed. A newer
134
+ window replaces an older pi-loops process and starts its own at once; an older window never replaces a newer
135
+ pi-loops process, nor starts an old one while the newer window that asked for the upgrade is open. Under bun
136
+ pi-loops runs on the `node` found on PATH; with none, loops pause and say why. During an
137
+ upgrade, pi-loops 0.22 windows stand by for loops; pi-loops writes `scheduler.json` for them to
138
+ read. Those windows still run their held conversation's plain jobs and must be restarted to pick
139
+ up the new behaviour. After a reboot nothing runs until a pi opens, or under a service manager configured by the user. The costs are decision 2's.
140
+
141
+ The claim on a job is a read-modify-write of `jobs.json` under `jobs.lock`, a directory lock
142
+ that is broken as stale after 10 seconds (`src/lock.ts`). It has no fencing token. A process
143
+ stopped (SIGSTOP, a debugger) for more than 10 seconds between reading a job as idle and
144
+ writing its claim, and long enough for another process to take the clock and claim the same
145
+ job, writes its claim over that one when it resumes, and the job runs twice. That is outside
146
+ the guarantee: it needs a stop inside a synchronous section that takes microseconds, and a
147
+ laptop that sleeps stops every process at once, so the resumed holder finishes its write before
148
+ any other process's timer fires.
103
149
 
104
150
  11. **Bounded everywhere, and no expiry.** Loop state ≤ 2000 characters, a finding ≤ 500, at most
105
151
  16 per run, prompts ≤ 8 KB. A job disappears when you remove it, when a one-shot has fired, or
@@ -114,7 +160,7 @@ machine. Everything here follows from that, and each one has a price.
114
160
  13. **Stdio MCP servers reconnect** with backoff (20 attempts by default), each distinct error
115
161
  reported once rather than on every retry.
116
162
 
117
- 14. **A bill, and a cap on it.** A headless host can run for days, so cost cannot be something you
163
+ 14. **A bill, and a cap on it.** pi-loops can run for days, so cost cannot be something you
118
164
  discover at the end of the month: `[limits] daily_budget_usd` gates every dispatch, `/cron cost`
119
165
  adds up the run log, and what log rotation drops is folded into `spend.json` first so the cap
120
166
  does not quietly stop capping. A run stopped by the budget is recorded as aborted rather than
@@ -151,8 +197,8 @@ machine. Everything here follows from that, and each one has a price.
151
197
  17. **The wizard is deterministic; one step is not.** Choosing a recipe, its autonomy level, the
152
198
  copy and the `/cron add` lines run with no model and end in a confirmation that shows exactly
153
199
  what will be created. The one thing a template cannot do — describe *this* project's tracker —
154
- is handed to the running session with `sendUserMessage`, the way `/inbox claim` hands over a
155
- finding: explore the remote, propose, confirm, write the file — and when that turn settles with
200
+ is a request addressed to the running session (`requests/<session>.jsonl`), handed to the agent
201
+ once it is idle, the way `/inbox claim` hands over a finding: explore the remote, propose, confirm, write the file — and when that turn settles with
156
202
  the file there, the wizard resumes by itself, so the command is typed once. `pi-loops recipe`
157
203
  in the terminal does the deterministic part only.
158
204
 
@@ -165,13 +211,14 @@ machine. Everything here follows from that, and each one has a price.
165
211
  protection on the remote) are the ones that hold.
166
212
 
167
213
  19. **One machine.** Until 0.19.0 every job and rule carried the hostname that created it, another
168
- machine sharing the `$HOME` ignored it and listed it as `[other host]`, `/cron set --host`
169
- re-homed it, and the leader file was named per host. None of it matched the way pi-loops is
214
+ machine sharing the `$HOME` ignored it and listed it as belonging to another machine, `/cron set --host`
215
+ re-homed it, and the leader file was named per machine. None of it matched the way pi-loops is
170
216
  used: pi runs on one machine, the loops run there, and a phone or a
171
217
  laptop reaches the browser front end over a tailnet (`tailscale serve`) without moving the
172
- execution anywhere. So the concept is gone: no `host` on a job or a rule (a stamp an older
173
- build left is dropped when the file is read), no `--host`, no marker, `scheduler.json` and
174
- `next-runs.json` plain. "Host" now means one thing here, the headless process.
218
+ execution anywhere. So machine ownership fields are gone from jobs and rules (an old field is
219
+ dropped when the file is read). `--host`, the marker, `scheduler.json` and `next-runs.json` are
220
+ gone too. Host is the code’s name for the background process; user-facing text says
221
+ pi-loops or in the background.
175
222
 
176
223
  The cost: two machines syncing one `$HOME` both run every job. That is not supported, and it
177
224
  is said rather than half-handled.
@@ -197,9 +244,292 @@ machine. Everything here follows from that, and each one has a price.
197
244
  [../src/cli.ts](../src/cli.ts), beside the one function that renders the script, so it is
198
245
  testable without pi; the extension is the caller.
199
246
 
200
- The cost: a write outside the loops directory, which the Non-invasive invariant now names as its
201
- one exception, and a question on somebody's first start — an interruption this project otherwise
202
- does not make.
247
+ The cost: a write outside the loops directory, which the Non-invasive invariant names as one of
248
+ its two exceptions, and a question on somebody's first start — an interruption this project
249
+ otherwise does not make.
250
+
251
+ 21. **The background process serves the one page.** pi-loops in the background owns the browser
252
+ page: `127.0.0.1:4173` for the default loops directory (`[page] port`), a free port for a moved
253
+ one, recorded in `host-service.json`. `pi-loops` with no arguments asks it over the control
254
+ socket (`page-open`) to open a conversation and answers the address and a one-shot open key,
255
+ then opens the browser and exits. Neither the token nor the key enters argv, which any account
256
+ on the machine can read in `/proc/<pid>/cmdline`: the background process writes the keyed
257
+ address into `pages/open/<random>.html` (0600, in a 0700 directory) and answers its path, the
258
+ browser is opened on that `file:` address, and the file sends it on. The file is removed when
259
+ the key is spent or after 60 seconds, whichever comes first, and every one left is removed
260
+ when the page starts. The cost: a process that runs as you can read the file in those 60
261
+ seconds, as it could read `web-token` itself. A browser confined by snap or flatpak cannot read
262
+ a file under a hidden directory, so for it the hand-off fails; the launcher also prints the
263
+ conversation's address with a second one-use key (10 minutes), and never the token, because a
264
+ terminal's scrollback, recordings and shared screens keep what it prints. It asks as soon as
265
+ the control socket is open: the conversation's pi starts while the page is still binding, and only
266
+ the answer, which carries the address, waits for the bind. A pairing code is
267
+ made only when asked for, by `pi-loops pair` or the page's add device button. Each
268
+ open conversation is one `pi --mode rpc` child of the background process under `/c/<session>/`
269
+ (`src/page-children.ts`), stopped after 15 idle minutes, six at most. What 4174 used to show is
270
+ the Background view of the same page, at `/background`.
271
+
272
+ The page keeps `web-token` and checks the Host header against DNS rebinding. The token never
273
+ travels in a cookie, because a cookie ignores ports: the browser sends it to every server on
274
+ 127.0.0.1, so any local process that binds a port and gets one request from the browser would
275
+ hold the token. Every route that needs the token takes it from the `x-pi-web-token` header (or,
276
+ on a GET, `?token=`); a POST that carries it anywhere else is refused with 403 before its body is
277
+ read and before anything is written to `requests/`. The page keeps the token in memory. Three
278
+ addresses a browser fetches without that header — the event stream, a file, a preview — take a
279
+ key minted for that address at `POST /c/<session>/key`: 16 random bytes, bound to the
280
+ conversation and the kind, a file's key to its exact path and a preview's to its id, held in
281
+ memory (256 at most). A file or preview key opens one load within 60 seconds; an event-stream
282
+ key lasts 10 minutes, because an EventSource reconnects with the address it was given.
283
+
284
+ A reload does not need the open key or the pairing code again. The first visit (the open key,
285
+ the six digits, or the token itself) sets `pi_web_device`, a device id (`HttpOnly`,
286
+ `SameSite=Strict`, one year), records it in `web-devices.json` (0600, keyed by a hash of the
287
+ token, so a new token starts with no devices), and hands the page a device secret once, which
288
+ the page keeps in `localStorage`. A later load with a known device cookie gets the page without
289
+ the token or the secret in it, and the page trades the cookie and the secret, in the
290
+ `x-pi-web-device-secret` header, for the token at `POST /c/<session>/token`. A wrong secret is
291
+ 403, and twenty of them forget the device. `localStorage` belongs to this origin, port included,
292
+ so a server on another port that receives the cookie fetches what has no token in it (the page and the
293
+ Background view) and nothing else. A browser paired before 0.24.0 pairs once more. 0.24.0 keeps
294
+ 0.22.8's `web-token`, and 0.22.8 kept it in a `pi_web_token` cookie; any answer to a request that
295
+ still carries that cookie clears it (`Max-Age=0`), so the live token stops travelling to every
296
+ port. Abort, run, stop,
297
+ upgrade and ring (the doorbell: `request:<session>` or `lease:<session>`, answered before the
298
+ delivery runs) stay on the control socket.
299
+
300
+ A page child is a window: it holds its conversation's lease as a terminal in `rpc` mode, and
301
+ marks the lease `page: true`, with `turnSince` from `agent_start` until `agent_settled` (pi
302
+ retries, compacts and continues after `agent_end`) and while a compaction runs. A `tui` window takes a
303
+ conversation from an idle page child at once; while the child's turn runs, the terminal is a
304
+ viewer and takes it at its next refresh after the turn, and the page then shows that the
305
+ conversation is answered in the terminal. An `rpc`, `print` or `json` window does not take it.
306
+
307
+ The cost: a stolen token reaches every existing conversation for as long as pi-loops runs, not
308
+ one session for as long as one terminal stays open, and there is no unauthenticated page any
309
+ more (`--no-auth` is gone). Rotate it by deleting `web-token` and running `pi-loops restart`;
310
+ every browser then signs in again. A device that pairs is trusted until the token is rotated.
311
+ The token is a shell. `/rpc` passes pi's `bash` command, and a prompt reaches pi's `bash` tool,
312
+ so whoever holds the token runs commands as you, in the project of every conversation.
313
+ `pi-loops status --url` prints the address with the token in it, so that output is a
314
+ credential: keep it out of issues, chats and shared terminal logs.
315
+ A script that runs on this origin (an XSS in the page) still reads the token from memory and the
316
+ device secret from `localStorage`. A process that holds a sniffed device cookie can load a
317
+ conversation's page, which starts that conversation's pi as a person's visit does, read the
318
+ masked stderr tail a crash-looping conversation's page shows and start it again, and spend the
319
+ twenty tries, which signs that browser out. After `[page] port` changes, a browser pairs
320
+ again: its secret stays with the old origin. A conversation no lease names opens from one of
321
+ pi's own session files under its sessions root whose header id is the one in the address
322
+ (`ConversationIndex.byId`, `src/conversation-rows.ts`), which is what `pi --session <id>`
323
+ accepts, and which widens what `/c/<id>/` opens from the conversations pi-loops has seen to
324
+ every one pi has written. `+ New` in a project other than the open conversation's posts
325
+ `/conversations/new`, which starts a pi in that project with that project's arguments, never
326
+ the request's; the cost is that a page request can start a pi in any project the page already
327
+ lists. A project with no arguments of its own gets only `-e <this package>`, and only when a
328
+ page pi or this process was started with it, so a launch that loads pi-loops with `-e` has
329
+ pi-loops in every project's conversations without carrying a model or another extension
330
+ across projects.
331
+
332
+ 22. **A scheduled prompt runs under its own cap, whichever process runs it.** Every request row
333
+ carries the `mode` its source gets: `attended` for what you typed, a `/goal` continuation, a
334
+ claim, a recipe hand-off and `/new-trigger`; `unattended` for the clock and an `inject_and_run`
335
+ push; `none` for a result. The process that runs a row recomputes it and refuses, audited, one
336
+ that says otherwise, and a window believes an attended source only on a row it wrote itself,
337
+ so a line in the file cannot widen a turn. An unattended turn runs under
338
+ the job's declared tools and the dangerous-command policy in a window exactly as in the
339
+ background; what differs is who answers. The background refuses; a window asks you, with a
340
+ 60-second countdown, and refuses when nobody does, telling the model which of the two it was.
341
+ pi-loops' own tools get no question from the guard, because each carries its own: `cron_create`
342
+ makes a job capped to the turn's tools (the window's `toolHost` reads the current turn, as the
343
+ background scopes a run's), and the tools that confirm through `confirmTool` do so once, where
344
+ the background's preflight refuses. The cost is a dialog in the middle of a turn
345
+ you did not start, and `[engine] window_guard = false` buys the uncapped window back. The
346
+ background process runs no attended row at all; one waits for a window, and a `/cron run` row
347
+ left in a closed conversation does not hold the clock's slots, which run as if it were not there.
348
+ `/cron run` is typed: its row goes before the clock's rows.
349
+
350
+ 23. **A chat message from the browser is a request too.** The page posts `/prompt` as before; for
351
+ chat text `src/web.mjs` appends a `person` row (`kind: input`, `wake: turn`, `mode: attended`,
352
+ the text and images as the payload, the page's UUID as the id, `created_by` the web.mjs that
353
+ wrote it) to `requests/<session>.jsonl` and answers `{queued: <id>}` at once. Text that starts
354
+ with `/`, a steer and Stop stay rpc: they act on the process or on the running turn, not on the
355
+ queue. The pi holding the session drains the row when it is idle, so a chat message runs in one
356
+ order with what pi-loops addressed to the conversation, survives Esc and pi exiting, and the
357
+ page lists the whole queue, each row with its source and a cancel (a `cancel` row), with what pi
358
+ itself still holds (a command typed while busy) after them, labelled "pi". Two things follow
359
+ from a row with no `[Trigger]` prefix. Its proof of delivery is a user message whose text starts
360
+ with the row's (pi appends a note when it resizes or converts an image; a row with no text of its
361
+ own is proved only by a message with none once those notes are stripped) written at or after
362
+ `handed_at`, the stamp the drain writes on the row under the lock just before each hand-over, at
363
+ the moment of the stamp; a turn pi holds and has not written keeps the lane, so one row is in
364
+ flight at a time, an earlier message with the same words proves nothing, and a row never handed
365
+ over is never done. Read from the file at every drain and never from an event. In the window, a
366
+ user message takes the cap of the handed-over row with the longest text that is a prefix of it
367
+ (a tie goes to the most recently handed over), so a short row's kept cap is not claimed by a
368
+ longer message. And its attended mode has to be believed: after the append the page sends
369
+ `/pi-loops ring <id> <fingerprint>` over its pi's rpc stdin, a channel no model tool can write
370
+ to; the extension records the row as produced by this window only when the row now in the file
371
+ has that fingerprint (a hash of kind, source, wake, text, the images and the declared tools),
372
+ then drains; a ring that arrives after the row was handed over upgrades its cap, started or not.
373
+ A person row is handed over only once rung for, or two seconds after it was written, so the bell
374
+ normally wins the race with the file watcher, and the drain that waits schedules one for when the
375
+ wait ends; one nobody rang for (a terminal answering a page's row, owner decision O2; a line
376
+ written into the file by hand) runs capped as unattended, and the window asks before a dangerous
377
+ command. `/pi-loops ring` with no arguments only drains; it is plumbing, and not in
378
+ `/pi-loops help`. A person row is sent only while its web.mjs lives: one left behind when pi
379
+ exited is offered by the next page as "not sent before pi stopped", with send (which rewrites
380
+ `created_by` to that page under the lock, then rings) and discard (a `cancel` row). A send pi
381
+ refuses (`prompt()` throws before any run starts: compaction in progress, no model, no
382
+ credentials) is found by the holder's tick, or by the drain the page's follow-up ring causes (a
383
+ command handler, outside pi's event cycle), never by the drain `agent_settled` runs, since pi
384
+ defers a prompt sent while it emits that event: a row handed to an idle pi with no `agent_start`
385
+ since, ten seconds after pi's last `input` or `message_start` (its pre-run phase after `input`,
386
+ the other input handlers and the credential check, is what the grace covers), is refused. A
387
+ person row gets `last_error` and is never handed over again (its cap is kept, so a send pi runs
388
+ late still runs under it); the page, which reads the file for `/state`, removes it, audits
389
+ `failed`, and puts the text back in the composer with the error. A pi-loops row, proved by its
390
+ prefix, counts an attempt and is offered again after the backoff, a dead letter at the third;
391
+ a slot due while it waits replaces it rather than collapsing onto it. After Stop (rpc
392
+ `abort`), as after Esc in a terminal, what is queued runs once pi is idle: the owner decided
393
+ (2026-11-02) that Esc and Stop end the current turn only, as in 0.22.8, and that the queue is
394
+ visible and cancellable everywhere instead (the page's per-row ✕, the terminal's `/cron queue`,
395
+ `queued N` on the status line). The post-abort hold this design first had (a minute for
396
+ pi-loops' rows, the person's next input for theirs, `released_at` and "send queued (n)" to lift
397
+ it) is gone with it: a timer that sends words a person stopped was the cost it was meant to avoid,
398
+ and a queue the person can see and cancel is the plainer answer. Send on an offered row makes the
399
+ row this page's under the lock, then rings. `handed_at` is not part of the fingerprint. An offered row is listed
400
+ with its whole text, since send vouches for all of it. The row's image bytes are a file beside
401
+ the queue, one per row (`requests/<session>.<digest of the id>.images.json`), removed with the
402
+ row; the row carries each image's type and SHA-256, the fingerprint covers those, and the holder
403
+ refuses bytes that do not match them, so three screenshots are a line in the queue and not
404
+ thirty megabytes rewritten under the lock at every update; an append that fails takes the file
405
+ back, and the background process sweeps files no row names once they are a minute old. Each
406
+ session's queue has its own lock (`requests/<session>.lock`): one `requests.lock` left by a
407
+ process killed mid-rewrite held every writer of every session for the stale wait, and now holds
408
+ that session's alone. A holder that will not deliver a person row says so on its lease: a window standing
409
+ aside writes `aside` on its holder (`print` and `json` mode are there already; `holderDelivers`
410
+ in src/lease.ts reads all three), the page's own pi then runs the page's rows it was rung for in
411
+ that holder's place, and the page does not say "answered there"; when the page's own pi is the
412
+ one standing aside, the page says so with `/resume` and nothing writes into the conversation. A
413
+ row of the page's nobody took in fifteen seconds with pi idle (past the ten-second refusal grace,
414
+ so a refused send comes back as "not sent" and never as both; short of the thirty-second lease
415
+ tick) is listed as "not taken (why)" with "send it here instead", which removes the row
416
+ (audited `cancelled`) and sends the text over rpc `prompt`, as 0.22 did; a holder that delivers
417
+ and is another process is not called unanswering, since the page sees only its own pi's idle
418
+ state: the row reads "another pi holds this conversation and has not taken it yet (it may be busy)" and
419
+ the offer "answer here instead (a branch the other pi will not see)". The removal and the check that no
420
+ holder has stamped the row are one critical section under the queue's lock, and a drain whose
421
+ stamp finds no row hands nothing over, so a message is answered once. A pi with no pi-loops in
422
+ it gets chat text over rpc `prompt` and no row is written: nobody would drain it, and the ring
423
+ would reach the model as the person's message; the command list is asked for again after every
424
+ slash command (`/reload` changes it) and when it is older than thirty seconds. The
425
+ cost is a second writer of the request file and a bell on every send; what it buys is a queue
426
+ that Esc cannot clear and a message that outlives the process it was typed into.
427
+
428
+ A claim from the page's inbox (**Yes, do it**, **Look into it**) is not a `person` row: it is
429
+ the `inbox:<id>` row `/inbox claim` writes, with the same collapse key, so a page and a terminal
430
+ claiming one finding collapse on each other and the file lock settles which one wrote it. The
431
+ page server signs it and lives on, and a row its producer outlives would wait for it for ever,
432
+ so the row is born left (`created_by.left` set at birth, `pageClaimRow` in src/engine.ts): the
433
+ holder runs it, as it runs a terminal's claim after that window moved on, and nobody else does,
434
+ since a viewer runs only its own rows and a left row is nobody's, and pi-loops in the background
435
+ runs no attended row. Under the inbox's lock, once `Inbox.decide` has found the finding still
436
+ new, the server writes the row and then marks the finding claimed, so a claim that loses writes
437
+ nothing (a terminal's `/inbox claim` does the same in `handOver`); then it delivers: it
438
+ rings the page's pi that holds the conversation, leaves the row to a terminal that holds it, waits
439
+ out pi-loops' scheduled turn there, or starts the conversation's pi when nobody holds it, asking
440
+ for its command list first and never ringing, or starting again, a pi with no pi-loops in it. The
441
+ same step runs at start and every ten seconds, one at a time (a claim made while one runs says
442
+ where it will run and is delivered when that one ends), so a claim tapped on a phone and left there runs
443
+ when the conversation is free, and a claim cut short between the row and the status is finished
444
+ from the files. The cost is the one this decision already pays for a page's message: in a
445
+ terminal the claim's turn runs capped as unattended, since no ring reaches a terminal, and the
446
+ danger check asks in that terminal before a dangerous command.
447
+
448
+ 24. **A watch is a question the tool asks.** With a person present, in the person's own turn
449
+ (hop 0, `ctx.hasUI`, not a scheduled turn), `cron_create` and `new_trigger` run every check a
450
+ create would, then ask through pi's own `ctx.ui.select` and wait for the answer in the same
451
+ call. The title is `Set up this watch?` and then the card's rows as `Label: value` lines
452
+ (`Runs` or `Waits for`, `Looks`, `Does` or `Then`, `Careful`, `Command`; a value's
453
+ further lines are indented two spaces). The options are a fixed vocabulary, one string per
454
+ combination of the card's choices, the plan's defaults first: for a schedule
455
+ `Set it up · inbox`, `Set it up · inbox · with checks`, `Set it up · this conversation`; for a
456
+ rule `Set it up · this conversation|inbox · once|every time`; and `Cancel`.
457
+ `watchChoice` in `src/watch-plan.ts` is the one place an answer becomes the stores' fields: a
458
+ schedule's `inbox` is `stateful` and `with checks` is `verify`; a rule's
459
+ `this conversation` is `promote_to_chat` and `once` is `fire_once`. An answer that was not
460
+ offered is a cancel. The tool returns `created cron job …` or `created dynamic trigger …`, or
461
+ `cancelled: the person did not set up this watch (Cancel|aborted|timed out). Nothing was
462
+ created.`, and the model reads that one rule on every surface. The question replaces
463
+ `new_trigger`'s confirm: one question, never two. A terminal shows pi's selector; the page draws
464
+ the same select as its card (chips are the axes of the options, Set it up sends the option they
465
+ name through `/ui-response`), and the tool result in the same turn turns the card into a
466
+ receipt. The page's child drops every dialog asked since the last `agent_start` when the run
467
+ ends, since pi has answered it by then and tells nobody: replayed, it would be a card whose
468
+ answer goes nowhere. A sub-agent, pi-loops in the background, a pi with no dialog and a
469
+ scheduled turn in an open window ask nobody and keep their gate: `cron_create` creates at once
470
+ and `new_trigger` asks the window guard's confirm or is refused. The sentence itself is an
471
+ ordinary chat message, `Watch for: <sentence>`; a sentence with no time and nothing to wait
472
+ for is answered by the page and reaches no model, and both tool descriptions say at hop 0 that
473
+ such a message is never acted on now. The question shows no directory, so a `cwd` outside the
474
+ project is refused before it is asked.
475
+ What it costs: the turn waits on the person, up to thirty minutes, and then the question is
476
+ cancelled and nothing is created; tool calls in a turn are sequential, so the turn's other work
477
+ waits too, and a plain job that comes due in that window queues behind it. The card lives in
478
+ the dialog, not in the transcript: a conversation read later shows the tool result and no card,
479
+ and a question still open when its pi stops is gone with it. A second identical call in one
480
+ turn is a second question; the result tells the model not to call again, and nothing enforces it.
481
+
482
+
483
+ 25. **A run writes into the conversation only when there is something to see.** A loop's first three
484
+ runs and every failed run are one compact card each, saved in the conversation that created the
485
+ loop; every other run is a line in the terminal's panel and the page's activity strip, read from
486
+ `runs.jsonl` when asked, with no store of its own. Deciding needs to know which run of its loop a
487
+ run was, and the run log is halved as it grows, so the record carries it (`ordinal`), and a
488
+ stopped run carries `aborted`, which is neither a failure nor a card. A 15-minute loop writes
489
+ three entries into its conversation, not 96 a day. The cost: a loop that has worked for a week
490
+ and then fails at night shows that failure, and nothing of the runs before it, in the
491
+ conversation; the strip and `/cron runs` have those.
492
+
493
+ Every matched rule result is one finding in the inbox, whatever `promote_to_chat` says; with
494
+ `promote_to_chat` it is also in the conversation. `Inbox.appendOnce` is the one rule that keeps
495
+ it one finding when the promotion falls back to the inbox or its row dies, the three writers that
496
+ used to file it. A rule result is filed as news: its text comes from outside, and a `· waits:`
497
+ in it would otherwise make a "Needs you" card and a notification on every page. A repeating
498
+ rule that says what its latest new finding says files nothing, or it would fill the inbox one
499
+ poll at a time. The cost: a result read in the chat waits in the inbox too, until someone
500
+ dismisses it.
501
+
502
+ A terminal writes `turnSince` on its lease while a turn runs, as the page's pi does, and `turns`
503
+ says it will. The page reads a terminal-held conversation from the file with pi's exported
504
+ parse, migration and projection functions instead of switching its own pi to it, and reads it
505
+ again once the turn is over and a new message is there. The read is synchronous in the process
506
+ every page shares, so a file over 32 MB is left to the page's pi. A switch changed the epoch under the
507
+ page and answered `get_messages` from either session; a guess at the end of a turn from 8 s of
508
+ quiet read half a turn. The cost: two small lease writes per terminal turn, and a terminal
509
+ older than 0.24 is never read again by the page.
510
+
511
+ 26. **A condition that is only a path is looked at with `stat` first.** A rule such as "when
512
+ ~/build.done appears" used to cost a model call every poll, for hours, to learn that one file was
513
+ not there yet. `src/path-precheck.ts` decides when the rule is created whether its condition is
514
+ only "a path exists or appears", resolves `~` and `$HOME` in the creating process, and records
515
+ the result on the rule (`precheck`). Each poll, after the poll ledger's claim, `stat`s the path
516
+ (2 s timeout, one in flight per rule); while it is absent the rule stays out of the check's
517
+ prompt and no `running` row is written, and the audit says `waiting` once. Anything the stat
518
+ cannot answer goes to the model, so a refusal or a doubt costs what it cost before and never a
519
+ match. The costs: a condition worded outside the grammar still bills; the grammar knows English
520
+ and Chinese only; a rule created before 0.24.0 keeps billing until it is created again, because
521
+ the path was never resolved by the person's own process; and a repeating rule still matches
522
+ every poll while the file stays, as it always did.
523
+
524
+ 27. **What happened while you were away is counted per project.** One cursor per project in
525
+ `seen.json`, moved by `/inbox` in any of its conversations and by the page's **Mark all seen**;
526
+ the terminal's session-start line and the page's away card are one function over the same files
527
+ (`src/seen.ts` `awayFor`). Every loop of the project counts, a recipe's included, and a finding
528
+ from this conversation is marked as such. The cost: a look in one conversation clears the
529
+ count for all of them, and the page's mark clears the terminal's next line. A cursor per
530
+ conversation was the alternative, and it is what left a recipe's findings out: they belonged
531
+ to the one conversation that installed it. With no cursor the first count is the last day,
532
+ never the whole run log.
203
533
 
204
534
  ## Where the browser front end came from
205
535
 
@@ -208,7 +538,7 @@ goes through the door pi already provides: `pi --mode rpc` — pi with no termin
208
538
  and events out as JSON lines — with [src/web.mjs](../src/web.mjs), a browser front end in one
209
539
  dependency-free file, passing that protocol through to a page. The session is a real pi session,
210
540
  and `pi --resume` picks it up afterwards. What only the process knows — which MCP servers connected,
211
- what they exposed, the active tools, who owns the clock — reaches the page as the
541
+ what they exposed, the active tools, whether pi-loops is running and its pid — reaches the page as the
212
542
  `pi_loops_snapshot` session entry.
213
543
 
214
544
  `pi-loops` with no arguments starts a session and picks the window: the browser at a local
@@ -224,13 +554,22 @@ pretending. What the page owes you is kept as a gate rather than a wish list in
224
554
 
225
555
  Reaching that page from a phone is solved without putting a broker in the middle: `tailscale serve`
226
556
  terminates TLS on your tailnet and proxies to this server on loopback, so the page is on your phone
227
- without anything of yours passing through a third party, and `--host` does the same over a local
557
+ without anything of yours passing through a third party, and `[page] host` does the same over a local
228
558
  network for people who would rather not run a tailnet. What both need is a way in that a phone can
229
559
  manage — the pairing code and the QR ([cli.md](cli.md)). A phone on neither network is the case
230
560
  this does not cover; that one would need a relay.
231
561
 
232
- While nobody is at the terminal, the host's control channel is what answers "what is it doing":
233
- `pi-loops host status|abort|stop` ([cli.md](cli.md)).
562
+ While nobody is at the terminal, pi-loops’ control channel is what answers "what is it doing":
563
+ `pi-loops status|abort|stop` ([cli.md](cli.md)), and the Background view of the page, which
564
+ `tailscale serve` puts on a phone the same way.
565
+
566
+ In 0.24 the page moved out of the terminal. Until then `pi-loops` ran the page's server and its one
567
+ pi itself, so closing that terminal took the page off the phone, and pi-loops in the background
568
+ served a separate read-only status page on 4174. Now pi-loops in the background serves the one page
569
+ on 4173, with a pi for each open conversation, and `pi-loops` only asks it to open one and exits
570
+ (decision 21). `src/web.mjs` keeps the page strings; the server is `src/page-server.ts`. The
571
+ listener flags became `[page]` in `config.toml`, because a page that outlives the command cannot
572
+ take its address from one.
234
573
 
235
574
  ## What this is not
236
575
 
@@ -4,7 +4,7 @@ Three things, and this page is the whole list. A program that is not pi-loops an
4
4
  its source may rely on these and on nothing else.
5
5
 
6
6
  **Any file under `~/.pi/agent/loops/` is not an interface.** `jobs.json`, `inbox.jsonl`,
7
- `runs.jsonl`, `state/`, `host.json`, the logs: private, and they change without notice. The one
7
+ `runs.jsonl`, `state/`, `host-service.json`, the logs: private, and they change without notice. The one
8
8
  file there a program writes is `hooks.toml`, and only for the two events below. The same goes for
9
9
  every command, field and path this page does not name, the wording of the slash commands, and the
10
10
  browser page.
@@ -23,7 +23,7 @@ breaks a rule is refused before anything is copied.
23
23
  ## 2. `run_start` and `run_end` in `hooks.toml`
24
24
 
25
25
  A `[[hook]]` rule with `event = "run_start"` or `event = "run_end"` fires around every scheduled
26
- run, in a pi window and in the headless host alike; what the payload carries is
26
+ run, in the background, where every loop runs; what the payload carries is
27
27
  [CONTRACT.md](../CONTRACT.md) §2.2, and the file, the rule keys and when the two fire are in
28
28
  [hooks.md](hooks.md#exactly-when-hooks-fire). Each field reaches the rule's command as a `PI_*`
29
29
  variable, unset where the payload has `null`, and all of them together as JSON in the file at
package/docs/goal.md CHANGED
@@ -34,9 +34,14 @@ is told to answer `{"ok": false, "reason": "insufficient evidence in transcript"
34
34
  | Interruption | Esc on a turn pauses the goal — it stays paused across `--resume` until `/goal resume` — and Esc during an evaluation stops that evaluation the same way |
35
35
  | Its own timeout | 2 minutes; it is one read, not a piece of work |
36
36
 
37
- `/goal resume` after a budget limit starts the allowance again, and after an Esc starts
37
+ `/goal resume` after a budget limit starts the allowance again (also when you paused it in
38
+ between), and after an Esc starts
38
39
  evaluating again: stopping a turn is taken as stopping the goal, since the next message you sent
39
- would otherwise end with the evaluator sending the agent back to work. `pause`, `resume` and `clear` are
40
+ would otherwise end with the evaluator sending the agent back to work. A `/goal` command typed
41
+ while an evaluation runs stops that evaluation, and what it would have decided is discarded: the
42
+ command has already set the goal you asked for. A turn that settles while the evaluator is still reading (you
43
+ asked something meanwhile) is judged as soon as that evaluation returns, so a continuation held for
44
+ it does not wait for you to type again. `pause`, `resume` and `clear` are
40
45
  matched as whole words, so `/goal clear the type errors first` sets that as the condition rather
41
46
  than dropping the goal.
42
47
 
@@ -53,7 +58,11 @@ than dropping the goal.
53
58
  ## Where it lives
54
59
 
55
60
  Each state change is appended to the session as a `goal_state` entry, so `--resume` picks the goal
56
- up where it left off. The status line shows `goal: <status>`; the
61
+ up where it left off. A continuation is a request addressed to the session
62
+ (`requests/<session>.jsonl`), handed to the agent once it is idle: a window that quits leaves it for the next one while the goal is
63
+ pursued, and a continuation on its way is not judged again, so it is charged once. Esc pauses the
64
+ goal, and a continuation still queued for it is dropped with it; `/goal resume` judges the next turn
65
+ afresh. The status line shows `goal: <status>`; the
57
66
  statuses are `pursuing`, `paused`, `achieved`, `budget_limited` and `cleared` (what `/goal clear`
58
67
  leaves behind).
59
68