@dumbmice/pi-todo-loop 0.2.1 → 0.3.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.
package/AGENTS.md CHANGED
@@ -102,14 +102,17 @@ adding a `CHANGELOG.md` line — all in the same commit.
102
102
  - **Only a settle spends a round.** `turn_end` fires for every assistant response, including one that
103
103
  ended in a tool call; those already cause pi's next request, so the hook returns before deciding. A
104
104
  round, a marker and a stall are therefore always about a turn that would have ended the run.
105
- - **A tool turn clears the stall and spends no round** — except a tool turn that ends `error`/`aborted`,
106
- which records the stop and leaves the counter alone. Tool turns already cause pi's next request, so
107
- they must not consume a round; but they are work, so they reset `stalled` and re-arm the block hint.
108
- - **Nothing stops the loop for being quiet.** There is no strike limit: `stalled` decides only when the
109
- nudge offers `todo_loop_block`, once per stall, and tool work re-arms it. A blocked agent and a lazy
105
+ - **A tool turn spends no round and resets `settleRun`, whatever its outcome** — an errored or aborted
106
+ one also records the stop. Tool turns already cause pi's next request, so they must not consume a
107
+ round; because they are work, the stop right after one is not asked about, and the stop after that is.
108
+ - **Nothing stops the loop for being quiet.** There is no strike limit: `settleRun === 2` decides when
109
+ the nudge names `todo_loop_block` — the second consecutive stop with no work between them and no list
110
+ change, once per run of settles, the one shape the question answers. A blocked agent and a lazy
110
111
  one are indistinguishable from here, so the agent is asked rather than judged; the only stops are a
111
- clear list, the round cap, an error, and the human's own `todo_loop_block` call.
112
- - **A stop keeps the counters; only a new human message resets them.** Clearing `rounds` / `stalled` on
112
+ clear list, a configured round cap, an error, the agent's `todo_loop_block`, and your own pause or
113
+ kill switch. There is no cap by default, so a stall that never resolves warns every tenth settle
114
+ instead of ending the run.
115
+ - **A stop keeps the counters; only a new human message resets them.** Clearing `rounds` / `staleSettles` on
113
116
  stop re-arms a full budget on the next `turn_end` under the same anchor (another extension's
114
117
  continuation, a provider retry). The anchor comparison is the only *automatic* reset;
115
118
  `/todoloop reset` and process start clear the counters explicitly. A queued human message is a
@@ -144,10 +147,11 @@ introduces an edge.
144
147
  |---|---|
145
148
  | continues only while items are open | `continues while work is open and the turn did work`, `stops when the list is clear`, `stops when there is no list at all` |
146
149
  | error and aborted outcomes never continue | `never continues on an error or aborted turn` |
147
- | a no-progress settle never stops the loop; the block hint is offered once per stall | `a no-progress settle continues, and offers the block hint once` |
150
+ | the hint asks on the second consecutive stop, once per run of settles | `the hint asks exactly one stop after the last one`, `state machine: the block clause asks on the second consecutive stop, and once per run` |
151
+ | a tool turn restarts the hint run, so a tool-heavy stall is still asked | `state machine: a tool turn restarts the clause run, so a tool-heavy stall is still asked` |
148
152
  | the first sighting of a list is progress, not a stall | `the first sighting of a list counts as progress, not a stall` |
149
- | a list change clears the stall and re-arms the hint | `a list change clears the stall and re-arms the hint` |
150
- | the round cap stops the loop | `round cap stops the loop`, `config caps are honoured` |
153
+ | a list change is progress, so it is never asked about | `a list change is progress, so it is not asked about` |
154
+ | no cap by default; a configured cap stops the loop | `no round cap by default`, `a configured round cap stops the loop`, `config caps are honoured` |
151
155
  | a new human message resets the budget | `a new human message resets the budget` |
152
156
  | a stop keeps the counters for the anchor, and a new message clears them | `a stop keeps the counters, so a cap stays shut for the same anchor`, `a new human message after a stop starts a fresh budget`, `the round cap is sticky for one human message` |
153
157
  | the newest custom entry wins over older ones | `reads the newest rpiv-todo custom entry` |
@@ -165,8 +169,8 @@ introduces an edge.
165
169
  | a malformed context payload is not rewritten | `context leaves a malformed payload alone instead of rewriting it` |
166
170
  | either kill switch disables both hooks | `the global kill switch disables both hooks`, `the project-local kill switch disables both hooks` |
167
171
  | only a settle spends a round: tool turns cost nothing | `a turn that ran tools spends no round and appends no marker` |
168
- | a tool turn clears the stall and re-arms the hint | `tool turns clear the stall but never spend a round` |
169
- | the block hint is offered once per stall, on the repeat stop | `the nudge offers todo_loop_block once, after a repeat stop` |
172
+ | a tool turn restarts the hint run, so a tool-heavy stall is still asked | `tool turns spend no round, clear the stall, and restart the hint's run` |
173
+ | only the first nudge that did not move the list names the block tool | `only the first nudge that did not move the list names todo_loop_block` |
170
174
  | `todo_loop_block` stops the loop and reports the reason | `todo_loop_block stops the loop for this message and reports the reason` |
171
175
  | a block without a reason is refused | `a block without a reason is refused and does not stop the loop` |
172
176
  | the human's next message clears the block | `the human's next message clears the block` |
@@ -177,7 +181,11 @@ introduces an edge.
177
181
  | the kill switch silences the block tool too | `the kill switch also silences todo_loop_block` |
178
182
  | the block tool declares a prompt snippet and a self-naming guideline, so pi lists it in `<tools>`/`<rules>` | `the block tool declares a prompt snippet and a self-naming guideline` |
179
183
  | a paused loop refuses the block tool and stays silent | `a paused loop refuses todo_loop_block and stays silent` |
180
- | a stall clause that lands on the cap round is reported, not dropped | `state machine: cap stays shut across a repeated settle under the same message`, `a stale list stops on the cap with a reason that says so` |
184
+ | an uncapped runaway warns every tenth no-progress settle | `an uncapped runaway warns every tenth settle that changes nothing` |
185
+ | the warning counts replies since the list changed, not replies in a row | `the warning counts replies since the list changed, not replies in a row` |
186
+ | emptying the list by deleting tasks warns; by completing them does not | `a list emptied by deleting its tasks is announced`, `a list emptied by ticking finished tasks is silent`, `an older tombstone does not make a completion look like a deletion` |
187
+ | clearing the list with `clear` is announced, since nothing was completed | `a turn that clears the list with the todo clear action is announced` |
188
+ | a stall clause that lands on the cap round is reported, not dropped | `a cap that lands on the bare stall names it in the reason` |
181
189
  | the next human message clears the block and the stale `last:` | `the human's next message clears the block` |
182
190
  | a continuation appends one hidden `custom_message` with a constant body and preserves prior proposals | `turn_end requests one turn with a hidden marker when work is open` |
183
191
  | a malformed event payload does not throw | `turn_end tolerates a malformed event payload instead of throwing` |
@@ -203,7 +211,7 @@ introduces an edge.
203
211
  3. Add cases to `test/loop.test.ts`: the new rule, its boundary, and the ordering against the reset
204
212
  and cap rules. Order matters — the anchor reset runs before every other check.
205
213
  4. Add a user-visible line to the `/todoloop` status if an operator needs to see it.
206
- 5. Update the README *Behaviour to expect* list and `CHANGELOG.md` under `[Unreleased]`.
214
+ 5. Update the README *What it looks like* section and `CHANGELOG.md` under `[Unreleased]`.
207
215
 
208
216
  ## Fragile areas
209
217
 
@@ -212,7 +220,7 @@ Ranked by how easily a small change breaks them.
212
220
  1. **Two different tails.** A continuation whose *persisted projection* ends on an assistant message is
213
221
  rejected by pi (`canContinue`); the *request* has no such constraint but must keep role alternation.
214
222
  Conflating the two produced the round-2 marker leak. `test/index.test.ts` pins both shapes.
215
- 2. **Counter lifecycle.** `rounds`, `stalled` and `anchor` are mutated in one place and reset in three
223
+ 2. **Counter lifecycle.** `rounds`, `staleSettles`, `settleRun` and `anchor` are mutated in one place and reset in three
216
224
  (anchor change, `/todoloop reset`, process start). A missing reset either loops forever or refuses to
217
225
  continue after one bad turn; a reset in the wrong place (inside `stop()`) silently re-arms the cap.
218
226
  3. **The anchor.** It must be human-authored and never empty. `#<index>` is the fallback when an entry
@@ -290,7 +298,7 @@ projects, or tightening `canContinue`; the second is how pi renders a custom too
290
298
  prompt, since the harness stub never builds one — a tool without a `promptSnippet` was silently dropped
291
299
  from `<tools>` in pi 1.5.0. Drafts are committed before the continuation is validated, so that change
292
300
  would persist a marker, drop the continuation, and leave `/todoloop` reporting `continuing: round n/12`
293
- until it reports `round cap` with zero continuations — while every test here stays green, because the
301
+ until it reports a cap stop (`stopped: N continuations used, …`) with zero continuations (with the default there is no cap, so the symptom is a rising `continuing: round n` that never stops) — while every test here stays green, because the
294
302
  harness drives a stub `pi`.
295
303
 
296
304
  Before every version bump, on the *new* pi, run the documented live check once:
@@ -318,7 +326,7 @@ the request).
318
326
  the final actionable boundary that can append entries and request one continuation, and at
319
327
  `turn_end` the hook has to reconstruct "the run would otherwise end" from `toolResults.length === 0`.
320
328
  The consequence is a bounded wasted round when a human message is queued mid-turn, and the stall
321
- reset lives in the adapter rather than in `decide()`. Moving the decision is the 0.2.0 change; it must
329
+ reset lives in the adapter rather than in `decide()`. Moving the decision is the next major change; it must
322
330
  ship with a CHANGELOG line because it changes which turns spend a round.
323
331
  - **`getBranch()` is not the model-visible context** (register item 16): after compaction the panel can
324
332
  show work the model has no list for.
@@ -369,7 +377,7 @@ Cutting a release:
369
377
  ```bash
370
378
  npm version patch --no-git-tag-version # or minor/major; the tree must be clean
371
379
  VERSION="$(node -p "require('./package.json').version")"
372
- # move the CHANGELOG entry from [Unreleased] to "## [$VERSION] — YYYY-MM-DD"
380
+ # move the CHANGELOG entry from [Unreleased] to "## [$VERSION] - YYYY-MM-DD"
373
381
  git add -A
374
382
  git commit -m "chore: release v$VERSION"
375
383
  git tag -a "v$VERSION" -m "v$VERSION"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0] - 2026-11-06
8
+
9
+ ### Added
10
+
11
+ - A warning to the human every tenth reply that leaves the todo list unchanged, while the loop keeps
12
+ going: it names the count, the open tasks, and the two brakes (`/todoloop pause`, `maxRounds`). It is
13
+ keyed on `staleSettles`, which tool turns do not clear, so it also catches the model this package
14
+ exists for: tools running, list frozen. With a configured cap it says how many continuations remain.
15
+
16
+ ### Changed
17
+
18
+ - **Breaking:** `maxRounds` defaults to `null`, so there is no continuation cap. The loop now ends when
19
+ the list clears, the agent calls `todo_loop_block`, an error or abort occurs, or you pause it. Set a
20
+ number to bound what one message of yours can cost; the value is still clamped to ≤ 1000, and a value
21
+ below 1 stays unlimited rather than falling back to a default.
22
+ - The "list was cleared by todo alone" warning now counts *calls* rather than snapshot rows: it fires
23
+ when the turn that emptied the list deleted tasks, and names how many. Finishing work and closing
24
+ every task in one turn is the honest ending and no longer warns, and a tombstone from an earlier turn
25
+ is not news. A result with no `action` at all keeps the old, vaguer wording.
26
+ - The user-facing text is plain English now: `/todoloop` says `asked to continue: 3 (no limit)`,
27
+ `replies since the todo list last changed: 0`, `tasks open: 2 of 5`,
28
+ `counting from: your message … — a new message restarts the count`; reasons read
29
+ `continuing: 2 tasks still open, continue 3`, and a cap reads
30
+ `stopped: 12 continuations used, 3 tasks still open`. The words "settles", "stalled", "round cap"
31
+ and "hard bound" are gone from `/todoloop`, the stop reasons and the warnings.
32
+ - The `todo_loop_block` clause now rides only on the bare stall: the model stopped, got one nudge, and
33
+ stopped again without running a tool and without the list changing (`settleRun === 2`). A stop that
34
+ follows real work gets the plain nudge; the stop after it is asked, so a tool-heavy run with a frozen
35
+ list is still asked. One request per stall, never repeated. `hintSent` is gone with it.
36
+
37
+ ### Upgrade notes
38
+
39
+ - A session that relied on the old default of 12 now runs unbounded. An explicit `"maxRounds": 12` in
40
+ the config file is honoured exactly as before, so that is the one-line way to keep the old ceiling.
41
+ - `"maxRounds": 0` used to mean "use 12"; it now means unlimited, like any value below 1.
42
+
7
43
  ## [0.2.1] - 2026-11-05
8
44
 
9
45
  ### Changed
@@ -143,4 +179,7 @@ Initial release.
143
179
 
144
180
  [0.1.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.0
145
181
  [0.1.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.1
146
- [Unreleased]: https://github.com/DumbMice/pi-todo-loop/compare/v0.1.1...HEAD
182
+ [0.2.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.2.0
183
+ [0.2.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.2.1
184
+ [0.3.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.3.0
185
+ [Unreleased]: https://github.com/DumbMice/pi-todo-loop/compare/v0.3.0...HEAD
package/README.md CHANGED
@@ -52,7 +52,8 @@ It stops for one of these reasons, and `/todoloop` always says which:
52
52
  |---|---|
53
53
  | the list is clear | `stopped: list is clear` — the normal ending |
54
54
  | the model said it needs a person | `waiting for you: waiting on Dana's review` — it called `todo_loop_block`; your next message resumes |
55
- | the budget is spent | `stopped: round cap 12 reached, 3 open` — 12 continuations for your one message; if it ends with `the agent stalled and may need a person`, ask it what it is waiting on |
55
+ | a configured cap is reached | `stopped: 12 continuations used, 3 tasks still open` — only when `maxRounds` is set; if it ends with `the agent may need you`, ask what it is waiting on |
56
+ | it never had a list | `stopped: no list` — the model has not called the todo tool in this session |
56
57
  | the turn failed | `stopped: turn outcome was error` — the provider failed, or you aborted it |
57
58
 
58
59
  ## When it helps
@@ -60,25 +61,31 @@ It stops for one of these reasons, and `/todoloop` always says which:
60
61
  - Long, multi-step work where the agent summarises and hands back halfway.
61
62
  - Sessions long enough to be compacted — the list survives on disk; the nudge is what gets the model to
62
63
  read it again.
63
- - A model that runs tools but rarely updates its list. It will still be nudged; it may keep working to
64
- the round cap.
64
+ - A model that runs tools but rarely updates its list. It will still be nudged, and with no cap by
65
+ default it keeps going until the list clears.
65
66
 
66
67
  ## When it does not
67
68
 
68
69
  - **No todo tool installed.** The extension is inert: nothing continues.
69
- - **You want an autonomous agent that invents its own goals.** This drives the list you have; it stops
70
- after 12 continuations per message by default.
70
+ - **You want an autonomous agent that invents its own goals.** This drives the list you have; nothing
71
+ here adds work you did not ask for.
72
+ - **You want a hard ceiling on cost.** There is no continuation cap by default — set `maxRounds` if you
73
+ want one, otherwise a model that keeps stopping will keep costing turns. `/todoloop` says `(no limit)`,
74
+ and every tenth reply that leaves the list unchanged raises a warning.
71
75
  - **You want the continuations visible.** They are silent by design.
72
- - **You want the agent to keep going after you said stop.** Your instruction wins; a model that reads
73
- "stop" as final can say so with `todo_loop_block`, and that stops the loop immediately.
76
+ - **You want your "stop" to be final.** Your instruction reaches the model, not the loop. A model that
77
+ reads "stop" as final can say so with `todo_loop_block`, and that ends it; otherwise the loop keeps
78
+ asking, so use `/todoloop pause` while you are away.
74
79
 
75
80
  It buys *liveness*, not work. A model can satisfy the loop by closing tasks, including deleting
76
- placeholders, and a list that is never updated looks the same as one that is finished. Clearing the list
77
- is the only thing that stops it, so the list has to mean something to you.
81
+ placeholders, and a list that is never updated looks the same as one that is finished. What ends it is
82
+ the list emptying, the agent asking for a person, a turn that fails, the `maxRounds` limit you set, or
83
+ you pausing it — so the list has to mean something to you.
78
84
 
79
85
  The extension never edits your prompt. The only *variable* text it gives the model is a one-line nudge —
80
- *continue, and read or update the list with the todo tool* — plus one constant tool declaration and the
81
- prompt entry pi derives from it (`todo_loop_block`; neither carries task state), so the list stays the
86
+ *continue, and read or update the list with the todo tool* — which adds one sentence offering
87
+ `todo_loop_block` when the agent has stopped twice without doing anything. Plus one constant tool
88
+ declaration and the prompt entry pi derives from it; neither carries task state. The list stays the
82
89
  agent's to maintain and the conversation stays yours.
83
90
 
84
91
  ## Configuration
@@ -87,13 +94,13 @@ Optional, at `~/.pi/agent/todo-loop.json`:
87
94
 
88
95
  ```json
89
96
  {
90
- "maxRounds": 12
97
+ "maxRounds": null
91
98
  }
92
99
  ```
93
100
 
94
101
  | Key | Default | What it does |
95
102
  |---|---|---|
96
- | `maxRounds` | `12` | continuations allowed per message to the agent |
103
+ | `maxRounds` | `null` | continuations allowed per message; `null` means no limit, a number caps it (clamped to 1000) |
97
104
 
98
105
  A missing or broken file falls back to these values; a bad config never fails a turn.
99
106
 
@@ -101,16 +108,16 @@ A missing or broken file falls back to these values; a bad config never fails a
101
108
 
102
109
  | Command | What it does |
103
110
  |---|---|
104
- | `/todoloop` | status: mode, rounds, stalls, open tasks, whether it waits for you, why it last stopped, config path |
111
+ | `/todoloop` | status: mode, how often it has been asked to continue, replies since the todo list last changed, open tasks, whether it waits for you, why it last stopped, config path |
105
112
  | `/todoloop pause` | stop continuing until you resume or restart |
106
113
  | `/todoloop resume` | allow the next turn to continue again |
107
- | `/todoloop reset` | clear the counters, the list signature and any pending `todo_loop_block` |
114
+ | `/todoloop reset` | restart the count for this message and forget any pending `todo_loop_block` |
108
115
 
109
116
  **Kill switch** — checked before every decision:
110
117
 
111
118
  ```bash
112
119
  touch ~/.pi/agent/todo-loop-off # everywhere
113
- touch .pi/todo-loop-off # this project only
120
+ touch .pi/todo-loop-off # this directory only
114
121
  ```
115
122
 
116
123
  ## Is it working?
@@ -119,11 +126,12 @@ After a response that made no tool calls while tasks are open, `/todoloop` shoul
119
126
 
120
127
  ```
121
128
  todo-loop active
122
- rounds 1/12 · stalled 0
123
- open 2/5 tracked
124
- anchor 4f3c1a2e
125
- last: continuing: round 1/12, 2 open
126
- config ~/.pi/agent/todo-loop.json
129
+ asked to continue: 1 (no limit)
130
+ replies since the todo list last changed: 0
131
+ tasks open: 2 of 5
132
+ counting from: your message 4f3c1a2e — a new message restarts the count
133
+ last: continuing: 2 tasks still open, continue 1
134
+ config /home/you/.pi/agent/todo-loop.json
127
135
  ```
128
136
 
129
137
  To count continuations in a session file:
@@ -136,12 +144,13 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl
136
144
 
137
145
  | Symptom | What to do |
138
146
  |---|---|
139
- | Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it. `last: stopped: no list` with `open 0/0 tracked` means the model has not created a list in this session; if the open count is non-zero the tool is writing a shape this extension does not read |
147
+ | Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it. `last: stopped: no list` with `tasks open: 0 of 0` means the model has not created a list in this session. `last:` only changes on a reply that ends a run, so it can lag a turn behind the counts |
140
148
  | The model asked for a person | It called `todo_loop_block` with a reason, so the loop is stopped until your next message. The reason appears in `/todoloop` as `waiting for you: …` |
141
- | It stopped at the round cap | The budget for that message is spent; send another message. `list unchanged for N settles` means the model was working without updating the list; `the agent stalled and may need a person` means it stopped without moving the list on the last round — ask what it is waiting on |
142
- | Rounds jump by two, or continuations appear twice | Two continuation extensions are installed; `pi list` shows them, remove one, then `/reload` |
149
+ | It stopped at your `maxRounds` limit | Only happens when `maxRounds` is set: the continuations for that message are used up; send another message. `no todo change for N replies` means it was working without updating the list; `the agent may need you` means it stopped twice in a row without doing anything — ask what it is waiting on |
150
+ | Continuations jump by two, or the same reply appears twice | Two continuation extensions are installed; `pi list` shows them, remove one, then `/reload` |
143
151
  | The list is stale | The extension nudges, it does not write statuses. Nothing here fabricates progress |
144
- | A warning says the list was cleared by todo alone | Every task closed in a turn with no other tool work. Check the work before trusting it |
152
+ | No status or warning appears | `/todoloop` and the warnings need a UI. In `pi -p` and `--mode json` the loop still runs, but the only controls are the kill-switch file and `maxRounds` |
153
+ | A warning says tasks were deleted | The list was emptied by a turn whose only tool was `todo`, and that turn deleted tasks rather than completing them. Check the work before trusting it |
145
154
 
146
155
  ## Uninstall
147
156
 
@@ -161,11 +170,11 @@ hidden continuation entries.
161
170
  `@juicesharp/rpiv-todo` 2.15.0.
162
171
  - **Node 22.19+**, the floor pi itself requires. The extension has no dependencies.
163
172
 
164
- Last verified 2026-11-04; the exact table and what was checked is in
173
+ Last verified 2026-11-06; the exact table and what was checked is in
165
174
  [docs/pi-api-alignment.md](docs/pi-api-alignment.md).
166
175
 
167
- Install only one continuation extension: a second one that also requests turns makes the counters and
168
- the budget meaningless.
176
+ Install only one continuation extension: a second one that also requests turns doubles every
177
+ continuation and makes the counts meaningless.
169
178
 
170
179
  ## Further reading
171
180
 
package/docs/behaviour.md CHANGED
@@ -42,16 +42,24 @@ the object it already was — not re-built, not extended.
42
42
  ## Stalls, and the block tool
43
43
 
44
44
  Nothing here stops the loop for being quiet. A model that keeps answering without moving the list is
45
- kept alive until the round cap, because "stuck" and "waiting for a person" look identical from the
46
- outside — and one of them must not be punished.
47
-
48
- - A **settle** whose task signature is unchanged increments `stalled`; a settle that changed the list
49
- resets it. The counter has no cap: it decides *when to ask*, not when to give up.
50
- - On the first settlement since progress that finds `stalled >= 1` — one stop that did not move the
51
- list — the nudge is the variant that adds *"If you cannot proceed without a person, call
52
- `todo_loop_block` with the reason."* It is offered **once per stall**; tool work re-arms it, so a
53
- second, separate stall after real progress is a new question.
54
- - A **tool turn** clears `stalled`. Work is progress even when the list was not touched.
45
+ kept alive — until the list clears, the agent asks for a person, you stop it, or a cap you configured
46
+ is reached — because "stuck" and "waiting for a person" look identical from the outside, and one of them
47
+ must not be punished. Without a cap that means it keeps costing turns; every tenth settle since the list
48
+ last changed raises a warning, so a frozen list — the shape this package exists for — does not run
49
+ invisibly either.
50
+
51
+ - A **settle** whose task signature is unchanged increments `staleSettles`; a settle that changed the
52
+ list resets it. `settleRun` counts assistant turns since the last tool turn, and it is that counter —
53
+ not `staleSettles` — that decides *when to ask*.
54
+ - **The nudge has two variants.** The plain one says continue and use the todo tool. The second adds
55
+ *"If you cannot proceed without a person, call `todo_loop_block` with the reason."*, and is used for
56
+ exactly one shape: the model stopped, we sent one request, and it stopped again **without running a
57
+ tool and without the list changing** (`settleRun === 2`). `settleRun` counts assistant turns since the
58
+ last tool turn, so work in between restarts it — that stop gets the plain nudge, and the stop after it
59
+ is asked instead. Because the run keeps growing, one stall asks once rather than on every stop.
60
+ - A **tool turn** resets `settleRun`, so the next stop is not asked about even
61
+ when the list was not touched. A tool turn that ends `error`/`aborted` resets it too: it is still a
62
+ request between two stops.
55
63
  - **`todo_loop_block`** is the agent's own exit. It is advertised in the prompt from the first turn —
56
64
  pi lists it in `<tools>` and carries its guideline in `<rules>`, because the tool declares a
57
65
  `promptSnippet` — and the nudge is the timed offer, not the only way to learn it exists. It takes a
@@ -62,29 +70,35 @@ outside — and one of them must not be punished.
62
70
  The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
63
71
  disabled extension never notifies or writes state.
64
72
  - **`staleSettles`** counts settles since the list last changed; a tool turn that leaves the list
65
- unchanged does not clear it. It
66
- never injects anything and never stops anything: it only decorates the reason string
67
- (`, list unchanged for N settles`) so a burn through the round cap does not read like healthy work.
68
- - **A list cleared by `todo` calls alone is announced.** If every task goes from open to closed in a
69
- turn whose only tool results were `todo`, a warning says so once. The loop's success state and its
70
- failure state are the same observable.
73
+ unchanged does not clear it. It never injects anything and never stops anything: it only decorates
74
+ the reason string
75
+ (`, no todo change for N replies`) so a burn to a configured cap does not read like healthy work.
76
+ - **Deleting the list is announced; finishing it is not.** If every task goes from open to closed in a
77
+ turn whose only tool results were `todo`, the *calls* decide the message: a `delete` in that turn
78
+ produces a warning naming how many tasks were thrown away, because a deleted task is not finished
79
+ work. `completed` calls say nothing — finishing work and ticking the boxes in one turn is the normal
80
+ ending. Calls are counted rather than the snapshot's rows, because rpiv-todo keeps deleted tasks as
81
+ tombstones in every later snapshot; `clear` is announced the same way, since it empties the list
82
+ without completing anything; a result with no `action` at all falls back to the vague wording, since
83
+ there is nothing to inspect.
71
84
 
72
85
  ## Counters
73
86
 
74
87
  | Counter | Meaning | Reset by |
75
88
  |---|---|---|
76
89
  | `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
77
- | `stalled` | settles in a row that left the list unchanged — no cap; it decides when the block hint is offered | the list changing, a completed tool turn, a new human message |
78
- | `staleSettles` | settles since the list last changed | the list changing, a new human message |
90
+ | `staleSettles` | settles since the list last changed — decorates the cap reason and drives the every-tenth warning | the list changing, a new human message |
91
+ | `settleRun` | assistant turns since the last tool turn — `2` is the stop the hint asks on | any tool turn, a new human message, `/todoloop reset` |
79
92
 
80
93
  - **One budget per human message.** A user-role message carrying a `customType` (this extension's
81
94
  marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
82
95
  the budget; `/todoloop` prints the anchor it is using.
83
96
  - **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
84
- another extension's continuation or a provider retry under the same message cannot re-arm 12 rounds.
97
+ another extension's continuation or a provider retry under the same message cannot re-arm the budget.
85
98
  A queued human message is a new anchor and resets the budget the ordinary way.
86
- - **A stale list stops on the cap**, never earlier: any tool turn clears the stall. That is why the cap
87
- reason appends `, list unchanged for N settles`.
99
+ - **A stale list burns to the cap when one is set**, never earlier: only the list changing clears the
100
+ count — a tool turn does not reach the decision at all. That is why the cap reason appends
101
+ `, no todo change for N replies`.
88
102
  - **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
89
103
  and spends no round; the counters are left as they are, so the next settle under the same message can
90
104
  continue — which is what a provider retry hits.
@@ -98,11 +112,11 @@ outside — and one of them must not be punished.
98
112
 
99
113
  | `last:` | Meaning |
100
114
  |---|---|
101
- | `continuing: round 1/12, 2 open` | working as intended |
115
+ | `continuing: 2 tasks still open, continue 1` | working as intended (`continue 1/12` when `maxRounds` is set) |
102
116
  | `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
103
117
  | `stopped: no list` | no recognised todo state on the branch — see the five causes below |
104
118
  | `waiting for you: waiting on Dana's review` | the agent called `todo_loop_block`; your next message resumes |
105
- | `stopped: round cap 12 reached, 2 open` | the budget is spent; `, list unchanged for N settles` means it worked without updating the list, `, the agent stalled and may need a person` means the last round was one stop that did not move the list |
119
+ | `stopped: 12 continuations used, 2 tasks still open` | the budget is spent; `, no todo change for N replies` means it worked without updating the list, `, the agent may need you` means it stopped on a bare stall |
106
120
  | `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
107
121
  | `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
108
122
  | `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
@@ -151,23 +165,28 @@ task `deleted`.
151
165
  ## Configuration limits
152
166
 
153
167
  Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
154
- That includes values below `1` — `"maxRounds": 0` means "use 12", not "never continue". Numbers are
155
- clamped so a typo cannot defeat the cap it configures: `maxRounds` ≤ 1000.
168
+ `maxRounds` is `null` by default, which means no limit: the loop ends when the list clears, the agent
169
+ blocks, or you stop it. Set a number to bound what one message of yours can cost; it is clamped so a
170
+ typo cannot defeat it (`maxRounds` ≤ 1000), and a value below 1 stays unlimited rather than becoming
171
+ "never continue".
156
172
 
157
173
  ## Cost
158
174
 
159
175
  - A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
160
- number of continuations × session size. `maxRounds` caps continuations, not turns — tool turns are
161
- pi's own and unbounded, so a long tool-heavy stretch can be many turns for one round. That is the
162
- dominant term.
176
+ number of continuations × session size — and each continuation lengthens the conversation the next
177
+ one re-sends, so a runaway grows faster than that product suggests. With the default there is no
178
+ continuation cap, so a model
179
+ that keeps stopping without moving the list keeps costing turns until the list clears or you stop
180
+ it; set `maxRounds` if you want a limit. Tool turns are pi's own and unbounded either way, so a
181
+ long tool-heavy stretch can be many turns for one round.
163
182
  - The extension adds one short marker per continuation (~40 tokens), plus one constant tool declaration
164
183
  and the prompt entries pi derives from it — `<tools>` line, `<rules>` guideline, description and
165
184
  schema, roughly 700 characters together, in every request. None of it carries task state, and there
166
185
  is no per-request block. Neither the snippet nor the guideline is gated by the kill switch or
167
186
  `/todoloop pause`; only the hooks and the call itself are. Whatever the model needs it reads with the `todo` tool, and that result
168
187
  is part of its own conversation rather than a parallel copy.
169
- - `context` walks the branch and returns `undefined` unless a marker actually has to be removed, so on
170
- a normal turn it costs one projection pass. `turn_end` reads the config, probes the kill switches and
188
+ - `context` never reads the branch: it returns `undefined` unless one of its own markers has to be
189
+ removed, so a normal turn costs one pass over the message list. `turn_end` reads the config, probes the kill switches and
171
190
  walks the branch on every turn.
172
191
 
173
192
  ## More
@@ -7,9 +7,9 @@ versions that was established against.
7
7
  |---|---|---|
8
8
  | `@earendil-works/pi-coding-agent` | **1.5.0** | `PI_API_SNAPSHOT` in `src/index.ts` |
9
9
  | `@juicesharp/rpiv-todo` | **2.15.0** | this file (item 12) |
10
- | Node | 22.23.2 | README *Requirements* |
10
+ | Node | 22.23.2 | README *Requirements* (which states the floor, 22.19+) |
11
11
 
12
- Last verified **2026-11-04**. Bump a row here, its constant where one exists, and the README table in
12
+ Last verified **2026-11-06**. Bump a row here, its constant where one exists, and the README table in
13
13
  the same commit as the change that depends on it.
14
14
 
15
15
  The contract is followed where it is documented: `turn_end` boundary proposals, draft entry types,
@@ -35,7 +35,8 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
35
35
  what keeps the conversation alternating: `assistant → marker(user) → assistant`. Removing them
36
36
  exposes runs of consecutive assistant messages, which alternation-requiring providers reject — a
37
37
  stricter risk than the one marker-per-round growth they cost (about 200 bytes each, bounded by
38
- `maxRounds` per human message, and they accumulate across messages). Their body is constant (`<todo_loop_continue/>`), so a marker left in
38
+ `maxRounds` per human message — and only when `maxRounds` is set, since the default is no cap, and they accumulate across messages). Their body is one of two fixed sentences (the plain nudge, or the plain nudge plus the
39
+ `todo_loop_block` clause), so a marker left in
39
40
  history cannot claim the loop is on some other round. With the kill switch set the hook rewrites
40
41
  nothing, because a disabled extension must not touch the request.
41
42
  3. **Two different tails are in play, and only one of them is constrained.** pi's boundary checks the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dumbmice/pi-todo-loop",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Keep pi working while a todo list still has open tasks, and stop when the agent asks for a person.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -20,6 +20,10 @@ function coerce(raw: unknown): Partial<LoopConfig> {
20
20
  ? Math.min(Math.floor(value), ceiling)
21
21
  : undefined;
22
22
 
23
+ // `null` is the documented "no limit"; a number is clamped so a typo cannot defeat the
24
+ // budget it configures (`maxRounds: 1e9` would otherwise be honoured). A value below 1
25
+ // is ignored and stays unlimited, which is also the default.
26
+ if (record.maxRounds === null) out.maxRounds = null;
23
27
  const maxRounds = bounded(record.maxRounds, 1_000);
24
28
  if (maxRounds !== undefined) out.maxRounds = maxRounds;
25
29
 
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * context drop a marker that would sit next to a human message, and
7
7
  * nothing else: the prompt is never rewritten with task state
8
- * turn_end request another turn while tasks are open, bounded by maxRounds
9
- * (see loop.ts), and stop when the agent calls todo_loop_block
8
+ * turn_end request another turn while tasks are open (uncapped unless maxRounds is
9
+ * set), and stop when the agent calls todo_loop_block
10
10
  * todo_loop_block the agent's "I need a person" signal, scoped to one message
11
11
  * /todoloop status | pause | resume | reset
12
12
  *
@@ -27,7 +27,7 @@ import { existsSync } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { join } from "node:path";
29
29
  import { loadConfig } from "./config.ts";
30
- import { decide, initialState, type LoopState } from "./loop.ts";
30
+ import { decide, initialState, plural, type LoopState } from "./loop.ts";
31
31
  import { readSnapshot } from "./state.ts";
32
32
 
33
33
  /**
@@ -60,9 +60,10 @@ export const MARKER_BODY =
60
60
  "Continue with the open todo tasks; use the todo tool to read and update the list. If the human's latest instruction says to stop or limits what to do, do that instead.";
61
61
 
62
62
  /**
63
- * The same nudge, offered once per stall: this is the first stop since real progress
64
- * that did not move the list, which is what a blocked agent looks like. The concession
65
- * stays last — the block still ends by telling the model the human outranks it.
63
+ * The same nudge for the first settle since progress that did not move the list — the shape
64
+ * a blocked agent has. It is offered on exactly one request: repeating it would be pressure
65
+ * rather than information. The concession stays last, so the block still ends by telling the
66
+ * model the human outranks it.
66
67
  */
67
68
  export const MARKER_BODY_HINT =
68
69
  "Continue with the open todo tasks; use the todo tool to read and update the list. If you cannot proceed without a person, call todo_loop_block with the reason. If the human's latest instruction says to stop or limits what to do, do that instead.";
@@ -249,17 +250,28 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
249
250
  // A tool turn is *work*, so it clears the stall counter and re-arms the block
250
251
  // hint: an agent that keeps working is not stuck, and a later stall is a new
251
252
  // question rather than a repeat of the old one.
252
- // A list that goes open -> clear in a turn whose only tool calls were `todo` is
253
- // the loop's success state and its failure state at once. Never police it;
254
- // just do not launder the trust: say so once.
253
+ // A list that empties in a turn whose only tool calls were `todo` is the loop's success
254
+ // state and its failure state at once. Never police it — but do not launder the trust.
255
+ // Count *calls*, not snapshot rows: rpiv-todo keeps deleted tasks as tombstones in every
256
+ // later snapshot, so counting rows would fire on a completion two turns after a deletion
257
+ // and would count the same tombstone once per call.
255
258
  if (snapshot.open.length === 0 && state.lastOpenCount > 0 && toolResultCount > 0) {
256
259
  const names = toolResults.map((result) => String((result as Record<string, unknown>)?.toolName ?? ""));
257
- const onlyTodo = names.every((name) => name === "todo");
258
- if (onlyTodo) {
259
- ctx.ui?.notify?.(
260
- "todo-loop: the list was cleared by todo calls alone in that turn — no other tool work accompanied it",
261
- "warning",
260
+ if (names.every((name) => name === "todo")) {
261
+ const actions = toolResults.map((result) =>
262
+ String(((result as Record<string, unknown>)?.details as Record<string, unknown> | undefined)?.action ?? ""),
262
263
  );
264
+ const known = actions.some((action) => action !== "");
265
+ const deleted = actions.filter((action) => action === "delete").length;
266
+ const line =
267
+ deleted > 0
268
+ ? `${plural(deleted, "task")} ${deleted === 1 ? "was" : "were"} deleted rather than completed in the turn that emptied the list. A deleted task is not finished work, so check the work before trusting the result.`
269
+ : actions.includes("clear")
270
+ ? "the todo list was cleared in the turn that emptied it, so nothing there was completed. Check the work before trusting the result."
271
+ : known
272
+ ? ""
273
+ : "the todo list emptied in a turn that only ran the todo tool, and its result carries no task list, so there is no way to tell whether tasks were finished or deleted. Check the work before trusting the result.";
274
+ if (line !== "") ctx.ui?.notify?.(`todo-loop: ${line}`, "warning");
263
275
  }
264
276
  }
265
277
  state.lastOpenCount = snapshot.open.length;
@@ -270,12 +282,13 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
270
282
  }
271
283
 
272
284
  if (toolResultCount > 0) {
285
+ // Any tool turn — even one that failed — is work between two stops, so it ends the
286
+ // run of settles the hint counts.
287
+ state.settleRun = 0;
273
288
  if (String(event?.outcome) !== "completed") {
274
289
  state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
275
290
  return undefined;
276
291
  }
277
- state.stalled = 0;
278
- state.hintSent = false;
279
292
  return undefined;
280
293
  }
281
294
 
@@ -328,6 +341,24 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
328
341
  };
329
342
 
330
343
  Object.assign(state, next, { reason: decision.reason });
344
+ // With no cap by default, a runaway must still become visible. `staleSettles` counts
345
+ // settles since the list last changed and tool turns do not clear it, so this also
346
+ // catches the model this package exists for: tools running, list frozen.
347
+ if (next.staleSettles > 0 && next.staleSettles % 10 === 0) {
348
+ const open = snapshot.open.length;
349
+ const tasks = `${open} ${open === 1 ? "task is" : "tasks are"}`;
350
+ const left = config.maxRounds === null ? 0 : config.maxRounds - next.rounds;
351
+ const brake =
352
+ config.maxRounds === null
353
+ ? "Run /todoloop pause to stop it, or set maxRounds in ~/.pi/agent/todo-loop.json to limit how often it continues"
354
+ : left <= 0
355
+ ? `Your limit is ${config.maxRounds} continuations per message and this message has used ${next.rounds}, so it stops on the next reply`
356
+ : `Your limit is ${config.maxRounds} continuations per message and this message has used ${next.rounds}, so it can continue ${left === 1 ? "once more" : `${left} more times`}`;
357
+ ctx.ui?.notify?.(
358
+ `todo-loop: the agent has replied ${next.staleSettles} times without updating the todo list, and ${tasks} still open. It keeps being asked to continue. ${brake}`,
359
+ "warning",
360
+ );
361
+ }
331
362
  return proposal;
332
363
  });
333
364
 
@@ -381,17 +412,18 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
381
412
  : waitingNow
382
413
  ? "waiting (the agent asked for you; your next message resumes)"
383
414
  : state.reason.startsWith("stopped:")
384
- ? "stopped (next human message starts a new budget)"
415
+ ? "stopped (your next message restarts the count)"
385
416
  : "active";
386
417
  ctx.ui?.notify?.(
387
418
  [
388
419
  `todo-loop ${mode}`,
389
- `rounds ${state.rounds}/${config.maxRounds} · stalled ${state.stalled}`,
420
+ `asked to continue: ${state.rounds}${config.maxRounds === null ? " (no limit)" : ` of ${config.maxRounds}`}`,
421
+ `replies since the todo list last changed: ${state.staleSettles}`,
390
422
  // Only while it still belongs to this message: the human's next one
391
423
  // resumes the loop even though the reason is still in memory.
392
424
  ...(waitingNow ? [`waiting: ${(state.waiting as { reason: string }).reason}`] : []),
393
- `open ${snapshot.open.length}/${snapshot.tasks.length} tracked`,
394
- `anchor ${snapshot.anchor || "(none)"}`,
425
+ `tasks open: ${snapshot.open.length} of ${snapshot.tasks.length}`,
426
+ `counting from: your message ${snapshot.anchor || "(none yet)"} — a new message restarts the count`,
395
427
  `last: ${waitingNow ? `waiting for you: ${(state.waiting as { reason: string }).reason}` : state.reason}`,
396
428
  `config ${configPath}`,
397
429
  ].join("\n"),
package/src/loop.ts CHANGED
@@ -10,27 +10,27 @@ export interface LoopState {
10
10
  /** Continuations started since the last human message. */
11
11
  rounds: number;
12
12
  /**
13
- * Consecutive settles that neither ran a tool nor changed the task list. No cap:
14
- * it decides when to offer the block tool, not when to give up.
13
+ * Assistant turns since the last tool turn. A stop that follows exactly one other stop —
14
+ * `settleRun === 2` — is the one-request-one-stop shape the hint asks about.
15
15
  */
16
- stalled: number;
17
- /** Whether the "call todo_loop_block" clause has already been offered this stall. */
18
- hintSent: boolean;
16
+ settleRun: number;
19
17
  /** Task signature at the previous decision, to detect list changes. */
20
18
  lastSignature: string;
21
- /** Settles since the list last changed: a stale list stops on the cap. */
19
+ /** Settles since the list last changed. Drives the every-tenth warning to the human. */
22
20
  staleSettles: number;
23
21
  /** Id of the user message the current budget belongs to. */
24
22
  anchor: string;
25
23
  }
26
24
 
27
25
  export interface LoopConfig {
28
- /** Max consecutive auto-continuations per human message. */
29
- maxRounds: number;
26
+ /** Continuations allowed per human message; `null` means no limit. */
27
+ maxRounds: number | null;
30
28
  }
31
29
 
32
30
  export const DEFAULTS: LoopConfig = {
33
- maxRounds: 12,
31
+ // No cap by default: the loop ends when the list clears, the agent asks for a person,
32
+ // or you stop it. Set a number to bound what one message of yours can cost.
33
+ maxRounds: null,
34
34
  };
35
35
 
36
36
  export type DecisionInput = {
@@ -58,9 +58,8 @@ export interface Decision {
58
58
  reason: string;
59
59
  /**
60
60
  * True when this continuation should carry the "if you cannot proceed without a
61
- * person, call todo_loop_block" clause. Offered once per stall: this is the first
62
- * stop since real progress that did not move the list — the shape a blocked agent
63
- * has.
61
+ * person, call todo_loop_block" clause: the first settle since real progress that did
62
+ * not move the list. One request, not a series.
64
63
  */
65
64
  hint: boolean;
66
65
  }
@@ -82,13 +81,22 @@ function stop(state: LoopState, reason: string): Decision {
82
81
  * Order matters: a new human message resets the budget before anything else, so
83
82
  * a long conversation cannot inherit the previous turn's spent rounds.
84
83
  */
84
+ export const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`;
85
+
85
86
  export function decide(input: DecisionInput): Decision {
86
87
  const { state, config } = input;
87
88
 
88
89
  // A new human message owns a fresh budget.
89
90
  let current: LoopState =
90
91
  input.anchor !== state.anchor
91
- ? { ...state, rounds: 0, stalled: 0, hintSent: false, lastSignature: "", staleSettles: 0, anchor: input.anchor }
92
+ ? {
93
+ ...state,
94
+ rounds: 0,
95
+ settleRun: 0,
96
+ lastSignature: "",
97
+ staleSettles: 0,
98
+ anchor: input.anchor,
99
+ }
92
100
  : { ...state };
93
101
 
94
102
  if (input.outcome !== "completed") {
@@ -100,42 +108,42 @@ export function decide(input: DecisionInput): Decision {
100
108
 
101
109
  const progressed = input.toolResultCount > 0 || input.signature !== current.lastSignature;
102
110
  if (progressed) {
103
- // Work re-arms the hint: a second stall, after real progress, is a new question.
104
- current.stalled = 0;
105
- current.hintSent = false;
106
111
  current.staleSettles = 0;
107
112
  } else {
108
113
  current.staleSettles += 1;
109
- current.stalled += 1;
110
114
  }
111
115
 
112
- // A repeat stop is the shape a blocked agent has: it already answered once, it did
113
- // not move the list, and it stopped again. Offer the block tool there — once per
114
- // stall — and never stop the loop for it. The only stops are a clear list, the
115
- // round cap, an error, and the agent asking for a person.
116
- const hint = current.stalled >= 1 && !current.hintSent;
116
+ // Every settle counts; a tool turn resets the run. `settleRun === 2` is exactly "it stopped,
117
+ // we sent one request, and it stopped again" — the shape the hint asks about. A list change
118
+ // is progress, so it is not asked there either, and the run keeps growing, so one stall asks
119
+ // once rather than on every stop.
120
+ current.settleRun += 1;
121
+ const hint = current.settleRun === 2 && !progressed;
117
122
 
118
- if (current.rounds >= config.maxRounds) {
123
+ if (config.maxRounds !== null && current.rounds >= config.maxRounds) {
119
124
  const stale =
120
- current.staleSettles >= 2 ? `, list unchanged for ${current.staleSettles} settles` : "";
125
+ current.staleSettles >= 2 ? `, no todo change for ${current.staleSettles} replies` : "";
121
126
  // A hint that comes due on the cap round has no continuation left to ride in.
122
127
  // Do not drop it silently: the human reads the reason, and the agent may be
123
128
  // blocked rather than done.
124
- const stalled = hint ? ", the agent stalled and may need a person" : "";
125
- return stop(current, `stopped: round cap ${config.maxRounds} reached, ${input.openCount} open${stale}${stalled}`);
129
+ const stalled = hint ? ", the agent may need you" : "";
130
+ return stop(
131
+ current,
132
+ `stopped: ${plural(config.maxRounds, "continuation")} used, ${plural(input.openCount, "task")} still open${stale}${stalled}`,
133
+ );
126
134
  }
127
135
 
128
136
  current.rounds += 1;
129
137
  current.lastSignature = input.signature;
130
- if (hint) current.hintSent = true;
138
+ const of = config.maxRounds === null ? "" : `/${config.maxRounds}`;
131
139
  return {
132
140
  continue: true,
133
141
  next: current,
134
142
  hint,
135
- reason: `continuing: round ${current.rounds}/${config.maxRounds}, ${input.openCount} open`,
143
+ reason: `continuing: ${plural(input.openCount, "task")} still open, continue ${current.rounds}${of}`,
136
144
  };
137
145
  }
138
146
 
139
147
  export function initialState(): LoopState {
140
- return { rounds: 0, stalled: 0, hintSent: false, lastSignature: "", staleSettles: 0, anchor: "" };
148
+ return { rounds: 0, settleRun: 0, lastSignature: "", staleSettles: 0, anchor: "" };
141
149
  }