@dumbmice/pi-todo-loop 0.2.0 → 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
@@ -84,7 +84,11 @@ adding a `CHANGELOG.md` line — all in the same commit.
84
84
  - **The only *variable* agent-facing text is the nudge.** One of two fixed sentences, appended once
85
85
  per continuation. No state block, no reminder, no per-request rewrite: the list is the agent's to
86
86
  read with the todo tool. The tool declaration (`todo_loop_block`) is constant and carries no task
87
- state, but it is in every request — count it in cost claims. The non-assistant constraint is pi's `turn_end` projection check, not the request (register
87
+ state, but it is in every request — count it in cost claims: the declaration plus the one-line
88
+ `<tools>` snippet and the `<rules>` guideline it earns (a few hundred characters together). It
89
+ declares a `promptSnippet` and a guideline that names it, because pi only shows a tool in the
90
+ prompt's `<tools>` list when a snippet exists; without one the exit is in the tool array but absent
91
+ from that summary. The non-assistant constraint is pi's `turn_end` projection check, not the request (register
88
92
  item 3) — do not re-derive a coupling between the two.
89
93
  - **One entry per continuation is a floor, not a choice.** pi has no `resume()` that appends nothing.
90
94
  Do not try to reach zero entries; keep the single `custom_message` with `display: false`.
@@ -98,14 +102,17 @@ adding a `CHANGELOG.md` line — all in the same commit.
98
102
  - **Only a settle spends a round.** `turn_end` fires for every assistant response, including one that
99
103
  ended in a tool call; those already cause pi's next request, so the hook returns before deciding. A
100
104
  round, a marker and a stall are therefore always about a turn that would have ended the run.
101
- - **A tool turn clears the stall and spends no round** — except a tool turn that ends `error`/`aborted`,
102
- which records the stop and leaves the counter alone. Tool turns already cause pi's next request, so
103
- they must not consume a round; but they are work, so they reset `stalled` and re-arm the block hint.
104
- - **Nothing stops the loop for being quiet.** There is no strike limit: `stalled` decides only when the
105
- 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
106
111
  one are indistinguishable from here, so the agent is asked rather than judged; the only stops are a
107
- clear list, the round cap, an error, and the human's own `todo_loop_block` call.
108
- - **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
109
116
  stop re-arms a full budget on the next `turn_end` under the same anchor (another extension's
110
117
  continuation, a provider retry). The anchor comparison is the only *automatic* reset;
111
118
  `/todoloop reset` and process start clear the counters explicitly. A queued human message is a
@@ -123,7 +130,9 @@ adding a `CHANGELOG.md` line — all in the same commit.
123
130
  continues, but the loop would then stop silently — inert is better than loud.
124
131
  - **Nothing is written.** No files, no network, no process execution. The only reads are the optional
125
132
  config, the two kill-switch paths and the session branch.
126
- - **The kill switch is checked in both hooks and in `todo_loop_block`'s `execute`.** Disabling takes effect on the next request, without
133
+ - **The kill switch is checked in both hooks and in `todo_loop_block`'s `execute`.** It cannot remove
134
+ the tool from the prompt: pi builds `<tools>`/`<rules>` from the registration, so a disabled session
135
+ still advertises an exit that will be refused. Disabling takes effect on the next request, without
127
136
  `/reload`.
128
137
  - **No pi type import; `pi: any` at the registration boundary.** Contexts the file reads are typed
129
138
  structurally (`SessionContext`). Adding the pi types would make a dependency out of a package that
@@ -138,10 +147,11 @@ introduces an edge.
138
147
  |---|---|
139
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` |
140
149
  | error and aborted outcomes never continue | `never continues on an error or aborted turn` |
141
- | 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` |
142
152
  | the first sighting of a list is progress, not a stall | `the first sighting of a list counts as progress, not a stall` |
143
- | a list change clears the stall and re-arms the hint | `a list change clears the stall and re-arms the hint` |
144
- | 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` |
145
155
  | a new human message resets the budget | `a new human message resets the budget` |
146
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` |
147
157
  | the newest custom entry wins over older ones | `reads the newest rpiv-todo custom entry` |
@@ -159,8 +169,8 @@ introduces an edge.
159
169
  | a malformed context payload is not rewritten | `context leaves a malformed payload alone instead of rewriting it` |
160
170
  | either kill switch disables both hooks | `the global kill switch disables both hooks`, `the project-local kill switch disables both hooks` |
161
171
  | only a settle spends a round: tool turns cost nothing | `a turn that ran tools spends no round and appends no marker` |
162
- | a tool turn clears the stall and re-arms the hint | `tool turns clear the stall but never spend a round` |
163
- | 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` |
164
174
  | `todo_loop_block` stops the loop and reports the reason | `todo_loop_block stops the loop for this message and reports the reason` |
165
175
  | a block without a reason is refused | `a block without a reason is refused and does not stop the loop` |
166
176
  | the human's next message clears the block | `the human's next message clears the block` |
@@ -169,8 +179,13 @@ introduces an edge.
169
179
  | the block's tool turn costs no round, and no marker is written after it | `state machine: the block call's tool turn costs no round, and the settle after it stops` |
170
180
  | a model-authored reason is flattened and bounded | `a reason is flattened to one line and bounded` |
171
181
  | the kill switch silences the block tool too | `the kill switch also silences todo_loop_block` |
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` |
172
183
  | a paused loop refuses the block tool and stays silent | `a paused loop refuses todo_loop_block and stays silent` |
173
- | 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` |
174
189
  | the next human message clears the block and the stale `last:` | `the human's next message clears the block` |
175
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` |
176
191
  | a malformed event payload does not throw | `turn_end tolerates a malformed event payload instead of throwing` |
@@ -196,7 +211,7 @@ introduces an edge.
196
211
  3. Add cases to `test/loop.test.ts`: the new rule, its boundary, and the ordering against the reset
197
212
  and cap rules. Order matters — the anchor reset runs before every other check.
198
213
  4. Add a user-visible line to the `/todoloop` status if an operator needs to see it.
199
- 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]`.
200
215
 
201
216
  ## Fragile areas
202
217
 
@@ -205,7 +220,7 @@ Ranked by how easily a small change breaks them.
205
220
  1. **Two different tails.** A continuation whose *persisted projection* ends on an assistant message is
206
221
  rejected by pi (`canContinue`); the *request* has no such constraint but must keep role alternation.
207
222
  Conflating the two produced the round-2 marker leak. `test/index.test.ts` pins both shapes.
208
- 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
209
224
  (anchor change, `/todoloop reset`, process start). A missing reset either loops forever or refuses to
210
225
  continue after one bad turn; a reset in the wrong place (inside `stop()`) silently re-arms the cap.
211
226
  3. **The anchor.** It must be human-authored and never empty. `#<index>` is the fallback when an entry
@@ -278,21 +293,32 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl # one per
278
293
 
279
294
  ## Release canary
280
295
 
281
- The one failure this suite cannot see is pi changing how a `display: false` `custom_message` projects,
282
- or tightening `canContinue`. Drafts are committed before the continuation is validated, so that change
296
+ Two failures this suite cannot see. The first is pi changing how a `display: false` `custom_message`
297
+ projects, or tightening `canContinue`; the second is how pi renders a custom tool into the system
298
+ prompt, since the harness stub never builds one — a tool without a `promptSnippet` was silently dropped
299
+ from `<tools>` in pi 1.5.0. Drafts are committed before the continuation is validated, so that change
283
300
  would persist a marker, drop the continuation, and leave `/todoloop` reporting `continuing: round n/12`
284
- 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
285
302
  harness drives a stub `pi`.
286
303
 
287
304
  Before every version bump, on the *new* pi, run the documented live check once:
288
305
 
289
306
  ```bash
290
- pi -e . -p "create two todos 'a' and 'b', then reply ok" --tools todo
307
+ # `-ne` because the released copy registers the same tool name: `pi -e .` next to the installed
308
+ # version fails with `Tool "todo_loop_block" conflicts with …`.
309
+ pi -ne -e . -p "create two todos 'a' and 'b', then reply ok" --tools todo
291
310
  grep -c 'todo-loop:continue' ~/.pi/agent/sessions/--<project>--/*.jsonl # 0 is a failure
292
311
  ```
293
312
 
294
- and read the pi changelog for `custom_message`, `display`, `canContinue`, plus rpiv-todo's for
295
- `reminder` (upstream PR #190 would put a second task block in the request).
313
+ For the prompt half, run with `--mode json` and confirm the tool reached the system prompt section:
314
+
315
+ ```bash
316
+ pi -ne -e . --mode json -p "reply ok" | grep -o 'todo_loop_block: Stop the todo loop'
317
+ ```
318
+
319
+ and read the pi changelog for `custom_message`, `display`, `canContinue`, `promptSnippet`,
320
+ `promptGuidelines`, plus rpiv-todo's for `reminder` (upstream PR #190 would put a second task block in
321
+ the request).
296
322
 
297
323
  ## Known limitations to revisit
298
324
 
@@ -300,7 +326,7 @@ and read the pi changelog for `custom_message`, `display`, `canContinue`, plus r
300
326
  the final actionable boundary that can append entries and request one continuation, and at
301
327
  `turn_end` the hook has to reconstruct "the run would otherwise end" from `toolResults.length === 0`.
302
328
  The consequence is a bounded wasted round when a human message is queued mid-turn, and the stall
303
- 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
304
330
  ship with a CHANGELOG line because it changes which turns spend a round.
305
331
  - **`getBranch()` is not the model-visible context** (register item 16): after compaction the panel can
306
332
  show work the model has no list for.
@@ -351,7 +377,7 @@ Cutting a release:
351
377
  ```bash
352
378
  npm version patch --no-git-tag-version # or minor/major; the tree must be clean
353
379
  VERSION="$(node -p "require('./package.json').version")"
354
- # move the CHANGELOG entry from [Unreleased] to "## [$VERSION] — YYYY-MM-DD"
380
+ # move the CHANGELOG entry from [Unreleased] to "## [$VERSION] - YYYY-MM-DD"
355
381
  git add -A
356
382
  git commit -m "chore: release v$VERSION"
357
383
  git tag -a "v$VERSION" -m "v$VERSION"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ 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
+
43
+ ## [0.2.1] - 2026-11-05
44
+
45
+ ### Changed
46
+
47
+ - `todo_loop_block` now declares a `promptSnippet` and a `promptGuidelines` bullet, so pi lists it in the
48
+ system prompt's `<tools>` section and its guideline in `<rules>`. Before this the tool was in the
49
+ request's tool array but absent from the prompt summary, so an agent could only find it through the
50
+ nudge. The guideline also tells the model to pick a reasonable option and state its assumption when a
51
+ choice is its own, rather than asking a person.
52
+
7
53
  ## [0.2.0] - 2026-11-05
8
54
 
9
55
  ### Added
@@ -133,4 +179,7 @@ Initial release.
133
179
 
134
180
  [0.1.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.0
135
181
  [0.1.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.1
136
- [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,26 +61,32 @@ 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
81
- (`todo_loop_block`, which carries no task state), so the list stays the agent's to maintain and the
82
- conversation stays yours.
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
89
+ agent's to maintain and the conversation stays yours.
83
90
 
84
91
  ## Configuration
85
92
 
@@ -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,47 +42,63 @@ 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.
55
- - **`todo_loop_block`** is the agent's own exit. It takes a reason, stores `{reason, anchor}` in memory
56
- and stops continuations for that message. The human is notified, `/todoloop` shows
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.
63
+ - **`todo_loop_block`** is the agent's own exit. It is advertised in the prompt from the first turn —
64
+ pi lists it in `<tools>` and carries its guideline in `<rules>`, because the tool declares a
65
+ `promptSnippet` — and the nudge is the timed offer, not the only way to learn it exists. It takes a
66
+ reason, stores `{reason, anchor}` in memory and stops continuations for that message. The human is notified, `/todoloop` shows
57
67
  `waiting for you: <reason>`, and the human's next message is a new anchor, so the block clears itself
58
68
  — there is no unblock call.
59
69
  - A block with an empty reason is refused, and the loop keeps running: the reason is the whole point.
60
70
  The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
61
71
  disabled extension never notifies or writes state.
62
72
  - **`staleSettles`** counts settles since the list last changed; a tool turn that leaves the list
63
- unchanged does not clear it. It
64
- never injects anything and never stops anything: it only decorates the reason string
65
- (`, list unchanged for N settles`) so a burn through the round cap does not read like healthy work.
66
- - **A list cleared by `todo` calls alone is announced.** If every task goes from open to closed in a
67
- turn whose only tool results were `todo`, a warning says so once. The loop's success state and its
68
- 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.
69
84
 
70
85
  ## Counters
71
86
 
72
87
  | Counter | Meaning | Reset by |
73
88
  |---|---|---|
74
89
  | `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
75
- | `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 |
76
- | `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` |
77
92
 
78
93
  - **One budget per human message.** A user-role message carrying a `customType` (this extension's
79
94
  marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
80
95
  the budget; `/todoloop` prints the anchor it is using.
81
96
  - **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
82
- 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.
83
98
  A queued human message is a new anchor and resets the budget the ordinary way.
84
- - **A stale list stops on the cap**, never earlier: any tool turn clears the stall. That is why the cap
85
- 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`.
86
102
  - **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
87
103
  and spends no round; the counters are left as they are, so the next settle under the same message can
88
104
  continue — which is what a provider retry hits.
@@ -96,11 +112,11 @@ outside — and one of them must not be punished.
96
112
 
97
113
  | `last:` | Meaning |
98
114
  |---|---|
99
- | `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) |
100
116
  | `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
101
117
  | `stopped: no list` | no recognised todo state on the branch — see the five causes below |
102
118
  | `waiting for you: waiting on Dana's review` | the agent called `todo_loop_block`; your next message resumes |
103
- | `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 |
104
120
  | `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
105
121
  | `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
106
122
  | `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
@@ -149,21 +165,28 @@ task `deleted`.
149
165
  ## Configuration limits
150
166
 
151
167
  Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
152
- That includes values below `1` — `"maxRounds": 0` means "use 12", not "never continue". Numbers are
153
- 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".
154
172
 
155
173
  ## Cost
156
174
 
157
175
  - A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
158
- number of continuations × session size. `maxRounds` caps continuations, not turns — tool turns are
159
- pi's own and unbounded, so a long tool-heavy stretch can be many turns for one round. That is the
160
- dominant term.
161
- - The extension adds one short marker per continuation (~40 tokens) plus one constant tool declaration
162
- (`todo_loop_block`, a few hundred characters of description and schema) that pi puts in every
163
- request. Neither carries task state, and there is no per-request block. Whatever the model needs it reads with the `todo` tool, and that result
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.
182
+ - The extension adds one short marker per continuation (~40 tokens), plus one constant tool declaration
183
+ and the prompt entries pi derives from it — `<tools>` line, `<rules>` guideline, description and
184
+ schema, roughly 700 characters together, in every request. None of it carries task state, and there
185
+ is no per-request block. Neither the snippet nor the guideline is gated by the kill switch or
186
+ `/todoloop pause`; only the hooks and the call itself are. Whatever the model needs it reads with the `todo` tool, and that result
164
187
  is part of its own conversation rather than a parallel copy.
165
- - `context` walks the branch and returns `undefined` unless a marker actually has to be removed, so on
166
- 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
167
190
  walks the branch on every turn.
168
191
 
169
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
@@ -71,7 +72,10 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
71
72
  9. **`sendUserMessage` and `Agent.prompt` are deliberately not used.** `sendUserMessage` would add a
72
73
  visible user-role message per continuation and pollute the transcript; calling the agent directly
73
74
  bypasses `AgentSession` lifecycle handling (busy state, queuing, retry, abort).
74
- 10. **The kill switch is checked in both hooks and in the block tool, not once at load.** Removing it or creating the file
75
+ 10. **The kill switch is checked in both hooks and in the block tool, not once at load.** It cannot
76
+ withdraw the tool's prompt entry: pi renders `<tools>`/`<rules>` from the registration, so a
77
+ disabled session still shows an exit whose call is then refused with "not running". Removing it
78
+ would need `pi.setActiveTools` from a hook — a new coupling we did not take for this. Removing it or creating the file
75
79
  takes effect on the next request, without `/reload`.
76
80
  11. **No dependency on `@earendil-works/pi-coding-agent`, and no peer declaration for it.** Nothing
77
81
  here imports the package: the boundary is typed `pi: any` and contexts are typed structurally
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dumbmice/pi-todo-loop",
3
- "version": "0.2.0",
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.";
@@ -127,6 +128,13 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
127
128
  label: "Block the todo loop",
128
129
  description:
129
130
  "Stop the automatic continuation loop for the rest of this message because you need a person: a decision, a review, a credential or information only the human has. The human is shown your reason and their next message resumes the loop. Use it only when you cannot proceed on the open tasks without them.",
131
+ // Without a snippet pi leaves the tool out of the prompt's `<tools>` summary; it is
132
+ // still in the request's tool array, but a model reading the prompt summary does not
133
+ // see the exit until the nudge names it. The exit must not be hidden that way.
134
+ promptSnippet: "Stop the todo loop for this message and ask the human for what you need",
135
+ promptGuidelines: [
136
+ "Use todo_loop_block when you cannot proceed on the open todo tasks without a person — a decision, a review, a credential or information only they have. If the choice is yours to make, pick the reasonable option and say what you assumed instead. Do not use it to end work you could finish yourself.",
137
+ ],
130
138
  parameters: {
131
139
  type: "object",
132
140
  properties: {
@@ -242,17 +250,28 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
242
250
  // A tool turn is *work*, so it clears the stall counter and re-arms the block
243
251
  // hint: an agent that keeps working is not stuck, and a later stall is a new
244
252
  // question rather than a repeat of the old one.
245
- // A list that goes open -> clear in a turn whose only tool calls were `todo` is
246
- // the loop's success state and its failure state at once. Never police it;
247
- // 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.
248
258
  if (snapshot.open.length === 0 && state.lastOpenCount > 0 && toolResultCount > 0) {
249
259
  const names = toolResults.map((result) => String((result as Record<string, unknown>)?.toolName ?? ""));
250
- const onlyTodo = names.every((name) => name === "todo");
251
- if (onlyTodo) {
252
- ctx.ui?.notify?.(
253
- "todo-loop: the list was cleared by todo calls alone in that turn — no other tool work accompanied it",
254
- "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 ?? ""),
255
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");
256
275
  }
257
276
  }
258
277
  state.lastOpenCount = snapshot.open.length;
@@ -263,12 +282,13 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
263
282
  }
264
283
 
265
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;
266
288
  if (String(event?.outcome) !== "completed") {
267
289
  state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
268
290
  return undefined;
269
291
  }
270
- state.stalled = 0;
271
- state.hintSent = false;
272
292
  return undefined;
273
293
  }
274
294
 
@@ -321,6 +341,24 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
321
341
  };
322
342
 
323
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
+ }
324
362
  return proposal;
325
363
  });
326
364
 
@@ -374,17 +412,18 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
374
412
  : waitingNow
375
413
  ? "waiting (the agent asked for you; your next message resumes)"
376
414
  : state.reason.startsWith("stopped:")
377
- ? "stopped (next human message starts a new budget)"
415
+ ? "stopped (your next message restarts the count)"
378
416
  : "active";
379
417
  ctx.ui?.notify?.(
380
418
  [
381
419
  `todo-loop ${mode}`,
382
- `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}`,
383
422
  // Only while it still belongs to this message: the human's next one
384
423
  // resumes the loop even though the reason is still in memory.
385
424
  ...(waitingNow ? [`waiting: ${(state.waiting as { reason: string }).reason}`] : []),
386
- `open ${snapshot.open.length}/${snapshot.tasks.length} tracked`,
387
- `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`,
388
427
  `last: ${waitingNow ? `waiting for you: ${(state.waiting as { reason: string }).reason}` : state.reason}`,
389
428
  `config ${configPath}`,
390
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
  }