@dumbmice/pi-todo-loop 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +90 -60
- package/CHANGELOG.md +73 -12
- package/README.md +25 -24
- package/docs/behaviour.md +72 -63
- package/docs/pi-api-alignment.md +20 -20
- package/package.json +2 -2
- package/scripts/check-pack.mjs +0 -1
- package/src/config.ts +0 -8
- package/src/index.ts +163 -83
- package/src/loop.ts +36 -26
- package/src/inject.ts +0 -97
package/docs/behaviour.md
CHANGED
|
@@ -4,6 +4,10 @@ The mechanics behind [README](../README.md). Read this when you are debugging be
|
|
|
4
4
|
whether to change a default, or wondering why the loop did something. It assumes you know that a
|
|
5
5
|
**settle** is a model response with no tool calls — the only turn that can end a run.
|
|
6
6
|
|
|
7
|
+
The rule the whole design follows: **the extension never edits your prompt.** It appends one hidden
|
|
8
|
+
nudge at a stop and otherwise keeps out of the conversation. Reading and updating the list is the
|
|
9
|
+
agent's job, through the todo tool it already has.
|
|
10
|
+
|
|
7
11
|
## The turn it adds
|
|
8
12
|
|
|
9
13
|
`rpiv-todo` renders and stores the list but never asks pi for another turn. Pi ends a run when the model
|
|
@@ -13,51 +17,54 @@ requests one more turn, so the model continues instead of handing control back.
|
|
|
13
17
|
Two consequences follow from that being the only mechanism available:
|
|
14
18
|
|
|
15
19
|
- **A continuation is a persisted entry.** pi has no "resume without appending anything", so each
|
|
16
|
-
continuation writes one `custom_message` with `display: false` and
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
continuation writes one `custom_message` with `display: false` and one of two constant bodies:
|
|
21
|
+
*"Continue with the open todo tasks; use the todo tool to read and update the list. If the human's
|
|
22
|
+
latest instruction says to stop or limits what to do, do that instead."* It is invisible in the TUI,
|
|
23
|
+
but it is in the session file, it is what the model reads, and it is the only text this extension
|
|
24
|
+
ever adds.
|
|
19
25
|
- **Only a settle spends a round.** `turn_end` fires after every assistant response, including one that
|
|
20
26
|
ended in a tool call; those already cause pi's next request, so they cost nothing and write no marker.
|
|
21
|
-
A round, a
|
|
27
|
+
A round, a stall and a marker therefore always concern a turn that would otherwise have ended the run.
|
|
22
28
|
|
|
23
29
|
## Markers and role alternation
|
|
24
30
|
|
|
25
31
|
A marker sits between two assistant turns, which is what keeps the conversation alternating: a request
|
|
26
32
|
that contained `assistant → assistant` in a row is rejected by alternation-requiring providers.
|
|
27
33
|
|
|
28
|
-
The `context` hook
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
assistant
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
- **
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
34
|
+
The `context` hook may remove a marker and nothing else. It drops one when either non-marker neighbour
|
|
35
|
+
is user-role — next to your own message, or in a run of markers — because that position would project to
|
|
36
|
+
`user → user`. A marker at the tail always stays: it is the nudge, and dropping it would leave the
|
|
37
|
+
continuation request ending on an assistant turn, which is the shape pi refuses to continue from.
|
|
38
|
+
|
|
39
|
+
The hook returns `undefined` when nothing needs removing, so an ordinary request is passed through as
|
|
40
|
+
the object it already was — not re-built, not extended.
|
|
41
|
+
|
|
42
|
+
## Stalls, and the block tool
|
|
43
|
+
|
|
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 is advertised in the prompt from the first turn —
|
|
56
|
+
pi lists it in `<tools>` and carries its guideline in `<rules>`, because the tool declares a
|
|
57
|
+
`promptSnippet` — and the nudge is the timed offer, not the only way to learn it exists. It takes a
|
|
58
|
+
reason, stores `{reason, anchor}` in memory and stops continuations for that message. The human is notified, `/todoloop` shows
|
|
59
|
+
`waiting for you: <reason>`, and the human's next message is a new anchor, so the block clears itself
|
|
60
|
+
— there is no unblock call.
|
|
61
|
+
- A block with an empty reason is refused, and the loop keeps running: the reason is the whole point.
|
|
62
|
+
The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
|
|
63
|
+
disabled extension never notifies or writes state.
|
|
64
|
+
- **`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.
|
|
61
68
|
- **A list cleared by `todo` calls alone is announced.** If every task goes from open to closed in a
|
|
62
69
|
turn whose only tool results were `todo`, a warning says so once. The loop's success state and its
|
|
63
70
|
failure state are the same observable.
|
|
@@ -67,18 +74,17 @@ compaction cannot remove it.
|
|
|
67
74
|
| Counter | Meaning | Reset by |
|
|
68
75
|
|---|---|---|
|
|
69
76
|
| `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
|
|
70
|
-
| `
|
|
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 |
|
|
71
78
|
| `staleSettles` | settles since the list last changed | the list changing, a new human message |
|
|
72
79
|
|
|
73
80
|
- **One budget per human message.** A user-role message carrying a `customType` (this extension's
|
|
74
81
|
marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
|
|
75
82
|
the budget; `/todoloop` prints the anchor it is using.
|
|
76
|
-
- **A stop keeps the budget shut.** Hitting the cap
|
|
83
|
+
- **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
|
|
77
84
|
another extension's continuation or a provider retry under the same message cannot re-arm 12 rounds.
|
|
78
85
|
A queued human message is a new anchor and resets the budget the ordinary way.
|
|
79
|
-
- **A stale list stops on the cap
|
|
80
|
-
|
|
81
|
-
like healthy work.
|
|
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`.
|
|
82
88
|
- **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
|
|
83
89
|
and spends no round; the counters are left as they are, so the next settle under the same message can
|
|
84
90
|
continue — which is what a provider retry hits.
|
|
@@ -94,18 +100,18 @@ compaction cannot remove it.
|
|
|
94
100
|
|---|---|
|
|
95
101
|
| `continuing: round 1/12, 2 open` | working as intended |
|
|
96
102
|
| `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
|
|
97
|
-
| `stopped: no list` | no recognised todo state on the branch — see the
|
|
98
|
-
| `
|
|
99
|
-
| `stopped: round cap 12 reached, 2 open` | the budget is spent;
|
|
103
|
+
| `stopped: no list` | no recognised todo state on the branch — see the five causes below |
|
|
104
|
+
| `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 |
|
|
100
106
|
| `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
|
|
101
107
|
| `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
|
|
102
|
-
| `resumed by user` | `/todoloop resume
|
|
103
|
-
| `counters reset` | `/todoloop reset` cleared the rounds,
|
|
108
|
+
| `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
|
|
109
|
+
| `counters reset` | `/todoloop reset` cleared the rounds, stalls, list signature and any pending block |
|
|
104
110
|
| `idle` | nothing has run yet in this process |
|
|
105
111
|
|
|
106
112
|
## Where the list comes from
|
|
107
113
|
|
|
108
|
-
The list is read from the session branch on every
|
|
114
|
+
The list is read from the session branch on every turn, never cached: a `rpiv-todo-state` custom
|
|
109
115
|
entry's `data.tasks`, or a `todo` toolResult's `details.tasks`, whichever is newer.
|
|
110
116
|
|
|
111
117
|
That is deliberate. A cache would owe invalidation on session switch, branch, compaction, process start
|
|
@@ -117,13 +123,16 @@ Two deviations follow from it:
|
|
|
117
123
|
|
|
118
124
|
- **The branch is not the model's context.** After a compaction the model's context drops older
|
|
119
125
|
entries, but the reader still walks the whole branch, so `/todoloop` can report open work the model
|
|
120
|
-
has no list for.
|
|
126
|
+
has no list for. This is the hole the removed injection used to paper over: a model that has been
|
|
127
|
+
compacted may not know the list exists until it calls `todo` or is nudged. The nudge is the reminder;
|
|
128
|
+
the tool is the source.
|
|
121
129
|
- **A newer malformed entry is skipped** in favour of an older valid one, so after a shape change the
|
|
122
130
|
reader serves the list as of that change. Reading an errored `todo` call as "list clear" would be
|
|
123
131
|
worse.
|
|
124
132
|
|
|
125
|
-
`no list` therefore has
|
|
126
|
-
this extension does not recognise, or every
|
|
133
|
+
`no list` therefore has five causes: the model never called the `todo` tool in this session, no todo
|
|
134
|
+
tool installed, a tool that is not named `todo`, a shape this extension does not recognise, or every
|
|
135
|
+
task `deleted`.
|
|
127
136
|
|
|
128
137
|
## Scope
|
|
129
138
|
|
|
@@ -143,23 +152,23 @@ this extension does not recognise, or every task `deleted`.
|
|
|
143
152
|
|
|
144
153
|
Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
|
|
145
154
|
That includes values below `1` — `"maxRounds": 0` means "use 12", not "never continue". Numbers are
|
|
146
|
-
clamped so a typo cannot defeat the cap it configures: `maxRounds` ≤ 1000
|
|
147
|
-
`maxInjectedTasks` ≤ 50.
|
|
155
|
+
clamped so a typo cannot defeat the cap it configures: `maxRounds` ≤ 1000.
|
|
148
156
|
|
|
149
157
|
## Cost
|
|
150
158
|
|
|
151
159
|
- A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
|
|
152
160
|
number of continuations × session size. `maxRounds` caps continuations, not turns — tool turns are
|
|
153
|
-
pi's own and unbounded, so a long tool-heavy stretch can be many turns for one round. That
|
|
154
|
-
|
|
155
|
-
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
163
|
+
- The extension adds one short marker per continuation (~40 tokens), plus one constant tool declaration
|
|
164
|
+
and the prompt entries pi derives from it — `<tools>` line, `<rules>` guideline, description and
|
|
165
|
+
schema, roughly 700 characters together, in every request. None of it carries task state, and there
|
|
166
|
+
is no per-request block. Neither the snippet nor the guideline is gated by the kill switch or
|
|
167
|
+
`/todoloop pause`; only the hooks and the call itself are. Whatever the model needs it reads with the `todo` tool, and that result
|
|
168
|
+
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
|
|
171
|
+
walks the branch on every turn.
|
|
163
172
|
|
|
164
173
|
## More
|
|
165
174
|
|
package/docs/pi-api-alignment.md
CHANGED
|
@@ -7,13 +7,15 @@ 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 *
|
|
10
|
+
| Node | 22.23.2 | README *Requirements* |
|
|
11
11
|
|
|
12
12
|
Last verified **2026-11-04**. 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,
|
|
16
|
-
`context` hook ownership of the message list, `display: false`,
|
|
16
|
+
`context` hook ownership of the message list, `display: false`, `registerCommand`, and `registerTool`
|
|
17
|
+
with a hand-written JSON-schema `parameters` object (pi validates any JSON Schema; the `typebox`
|
|
18
|
+
import in the docs is a convenience, and avoiding it keeps this package dependency-free). Nothing here
|
|
17
19
|
calls pi internals; item 1 nonetheless depends on behaviour pi documents nowhere, which is why it is
|
|
18
20
|
listed below rather than assumed.
|
|
19
21
|
|
|
@@ -28,7 +30,7 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
|
|
|
28
30
|
projection must end on a message whose role is not `assistant` — otherwise the boundary reports an
|
|
29
31
|
invalid continuation and drops it. pi offers no `resume()` that appends nothing, so a continuation
|
|
30
32
|
is always expressed as *one* appended entry. We append a `custom_message` with `display: false`:
|
|
31
|
-
invisible in the TUI, about
|
|
33
|
+
invisible in the TUI, about 220 bytes per continuation in the journal _(silent in the transcript)_.
|
|
32
34
|
2. **Markers stay in history and are not stripped.** They sit between two assistant turns, so they are
|
|
33
35
|
what keeps the conversation alternating: `assistant → marker(user) → assistant`. Removing them
|
|
34
36
|
exposes runs of consecutive assistant messages, which alternation-requiring providers reject — a
|
|
@@ -45,14 +47,12 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
|
|
|
45
47
|
used by `pi-invisible-continue`'s `/continue`
|
|
46
48
|
(<https://github.com/monotykamary/pi-invisible-continue>, not re-tested here). Conflating the two is
|
|
47
49
|
the mistake this item exists to prevent.
|
|
48
|
-
4. **The
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
tool tail. No request ending on an assistant message has been exercised against a provider in this
|
|
55
|
-
repository's testing; the live runs used the default configuration.
|
|
50
|
+
4. **The `context` hook only ever removes messages.** It returns a different list in exactly one
|
|
51
|
+
case: a marker of ours sits next to a human message, where it would project to `user → user`. Every
|
|
52
|
+
other request is passed through untouched (`undefined`), including the human's own message and every
|
|
53
|
+
tool turn, so the prompt a human sends is the prompt the model reads. Earlier versions appended a
|
|
54
|
+
state block to each request; that was removed because it rewrote the human's prompt on turns the
|
|
55
|
+
loop did not cause.
|
|
56
56
|
5. **Returning a changed list rebuilds the request head.** pi documents that a `context` handler which
|
|
57
57
|
returns a different message list causes it to replay the current prompt sections and tool
|
|
58
58
|
declarations into one leading system message ahead of the returned list. The rebuild is lossless
|
|
@@ -71,7 +71,10 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
|
|
|
71
71
|
9. **`sendUserMessage` and `Agent.prompt` are deliberately not used.** `sendUserMessage` would add a
|
|
72
72
|
visible user-role message per continuation and pollute the transcript; calling the agent directly
|
|
73
73
|
bypasses `AgentSession` lifecycle handling (busy state, queuing, retry, abort).
|
|
74
|
-
10. **The kill switch is checked in both hooks, not once at load.**
|
|
74
|
+
10. **The kill switch is checked in both hooks and in the block tool, not once at load.** It cannot
|
|
75
|
+
withdraw the tool's prompt entry: pi renders `<tools>`/`<rules>` from the registration, so a
|
|
76
|
+
disabled session still shows an exit whose call is then refused with "not running". Removing it
|
|
77
|
+
would need `pi.setActiveTools` from a hook — a new coupling we did not take for this. Removing it or creating the file
|
|
75
78
|
takes effect on the next request, without `/reload`.
|
|
76
79
|
11. **No dependency on `@earendil-works/pi-coding-agent`, and no peer declaration for it.** Nothing
|
|
77
80
|
here imports the package: the boundary is typed `pi: any` and contexts are typed structurally
|
|
@@ -115,16 +118,13 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
|
|
|
115
118
|
14. **The loop's counters are not reconstructible from the session file** _(silent)_. A `turn_end`
|
|
116
119
|
handler is handed `outcome`, `entries` and `continue`; what a `-p`/JSON run persists for the event
|
|
117
120
|
carries only `{type, message, toolResults}`. A session file therefore shows which continuations happened and
|
|
118
|
-
when, not how many rounds or
|
|
121
|
+
when, not how many rounds or stalls were left, nor why the loop stopped; that is only visible live
|
|
119
122
|
through `/todoloop`. Two runs that end for different reasons can look identical on disk.
|
|
120
123
|
|
|
121
|
-
15.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
row. Accepted deliberately: a compaction summary is exactly when the model most needs the list, and
|
|
126
|
-
losing it silently is worse than an adjacency that providers merge anyway.
|
|
127
|
-
|
|
124
|
+
15. _(Inapplicable since the state block was removed.)_ Three user-role tails —
|
|
125
|
+
`bashExecution`, `branchSummary`, `compactionSummary` — cannot take a merged `content`, because pi
|
|
126
|
+
rebuilds those from their own fields. Nothing is merged into them now: the only text this
|
|
127
|
+
extension adds is a standalone marker message, which needs no merge and creates no adjacency.
|
|
128
128
|
16. **The reader walks `getBranch()`, which is not the model-visible context.** pi's compaction-aware
|
|
129
129
|
projection is `buildContextEntries()`, and a `context_edit` can hide an entry from future model
|
|
130
130
|
context while it stays on the branch. Reading the branch therefore means the extension can report a
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dumbmice/pi-todo-loop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Keep pi working while a todo list still has open tasks, and
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
7
7
|
"author": {
|
package/scripts/check-pack.mjs
CHANGED
package/src/config.ts
CHANGED
|
@@ -23,14 +23,6 @@ function coerce(raw: unknown): Partial<LoopConfig> {
|
|
|
23
23
|
const maxRounds = bounded(record.maxRounds, 1_000);
|
|
24
24
|
if (maxRounds !== undefined) out.maxRounds = maxRounds;
|
|
25
25
|
|
|
26
|
-
const maxStrikes = bounded(record.maxStrikes, 100);
|
|
27
|
-
if (maxStrikes !== undefined) out.maxStrikes = maxStrikes;
|
|
28
|
-
|
|
29
|
-
if (typeof record.injectState === "boolean") out.injectState = record.injectState;
|
|
30
|
-
|
|
31
|
-
const maxInjectedTasks = bounded(record.maxInjectedTasks, 50);
|
|
32
|
-
if (maxInjectedTasks !== undefined) out.maxInjectedTasks = maxInjectedTasks;
|
|
33
|
-
|
|
34
26
|
return out;
|
|
35
27
|
}
|
|
36
28
|
|