@dumbmice/pi-todo-loop 0.1.0 → 0.2.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/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,45 +17,52 @@ 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 a constant body that states the
17
- instruction (`Continue with the open todo tasks…`). It is invisible in the TUI, but it is in the
18
- session file and, when no block is injected, it is what the model reads.
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 strike and a marker therefore always concern a turn that would otherwise have ended the run.
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 therefore keeps a marker only where it separates two assistant turns, and drops it
29
- when either neighbour is user-role: at the tail, immediately before your own message, or in a run of
30
- markers. The block replaces a marker tail, merges into a `user`/`custom` tail, and is appended after an
31
- assistant or toolResult tail.
32
-
33
- One exception is documented: `bashExecution`, `branchSummary` and `compactionSummary` tails cannot take
34
- a merge — pi rebuilds those from their own fields and ignores `content` — so the block becomes a
35
- separate message there and the request briefly holds two user-role messages. Losing the list on a
36
- compaction summary would be worse.
37
-
38
- ## The injected block
39
-
40
- While tasks are open, every request ends with an ephemeral `<todo_loop_state>` block: the open tasks with
41
- their ids and statuses, and an instruction that yields to you. It is never persisted, which is why
42
- compaction cannot remove it.
43
-
44
- - **The block is user-role text and the last thing in the request.** It sits after your newest message
45
- and says that your latest instruction outranks it. Models vary in how readily they take that
46
- concession: one that reads your "stop" as authoritative will refuse, and those refusals cost up to
47
- `maxRounds` turns. That is the same reason `injectState: false` exists.
48
- - **Task text is sanitised, not interpolated.** Subject, `activeForm` and `status` are model-authored —
49
- possibly from a fetched page or a repo file — so every Unicode line terminator flattens, `<` is
50
- escaped, controls/bidi/zero-width characters and unpaired surrogates are dropped, and labels truncate
51
- by code point. Without this, a task subject could close the tag and forge a directive.
52
- - **The ids are the todo tool's own**, except where the stored list supplies an unusable or duplicate
53
- id: that row moves to a free number so each row can be addressed, which means a `todo` call using the
54
- shown number can fail when the tool wrote the list that way.
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 takes a reason, stores `{reason, anchor}` in memory
56
+ and stops continuations for that message. The human is notified, `/todoloop` shows
57
+ `waiting for you: <reason>`, and the human's next message is a new anchor, so the block clears itself
58
+ — there is no unblock call.
59
+ - A block with an empty reason is refused, and the loop keeps running: the reason is the whole point.
60
+ The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
61
+ disabled extension never notifies or writes state.
62
+ - **`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.
55
66
  - **A list cleared by `todo` calls alone is announced.** If every task goes from open to closed in a
56
67
  turn whose only tool results were `todo`, a warning says so once. The loop's success state and its
57
68
  failure state are the same observable.
@@ -61,18 +72,17 @@ compaction cannot remove it.
61
72
  | Counter | Meaning | Reset by |
62
73
  |---|---|---|
63
74
  | `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
64
- | `strikes` | settles in a row that left the list unchanged | the list changing, a completed tool turn, a new human message |
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 |
65
76
  | `staleSettles` | settles since the list last changed | the list changing, a new human message |
66
77
 
67
78
  - **One budget per human message.** A user-role message carrying a `customType` (this extension's
68
79
  marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
69
80
  the budget; `/todoloop` prints the anchor it is using.
70
- - **A stop keeps the budget shut.** Hitting the cap or the strike limit does not clear the counters, so
81
+ - **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
71
82
  another extension's continuation or a provider retry under the same message cannot re-arm 12 rounds.
72
83
  A queued human message is a new anchor and resets the budget the ordinary way.
73
- - **A stale list stops on the cap, not on strikes**, because any tool turn clears the strike budget.
74
- That is why the cap reason appends `, list unchanged for N settles` — so a stale burn does not read
75
- like healthy work.
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`.
76
86
  - **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
77
87
  and spends no round; the counters are left as they are, so the next settle under the same message can
78
88
  continue — which is what a provider retry hits.
@@ -88,18 +98,18 @@ compaction cannot remove it.
88
98
  |---|---|
89
99
  | `continuing: round 1/12, 2 open` | working as intended |
90
100
  | `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
91
- | `stopped: no list` | no recognised todo state on the branch — see the four causes below |
92
- | `stopped: 2 consecutive turns with no progress, 2 open` | the last two settles ran no tool and left the list unchanged |
93
- | `stopped: round cap 12 reached, 2 open` | the budget is spent; with `, list unchanged for N settles` appended, the model worked without updating the list |
101
+ | `stopped: no list` | no recognised todo state on the branch — see the five causes below |
102
+ | `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 |
94
104
  | `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
95
105
  | `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
96
- | `resumed by user` | `/todoloop resume`; the next settle may continue |
97
- | `counters reset` | `/todoloop reset` cleared the rounds, strikes and list signature |
106
+ | `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
107
+ | `counters reset` | `/todoloop reset` cleared the rounds, stalls, list signature and any pending block |
98
108
  | `idle` | nothing has run yet in this process |
99
109
 
100
110
  ## Where the list comes from
101
111
 
102
- The list is read from the session branch on every request, never cached: a `rpiv-todo-state` custom
112
+ The list is read from the session branch on every turn, never cached: a `rpiv-todo-state` custom
103
113
  entry's `data.tasks`, or a `todo` toolResult's `details.tasks`, whichever is newer.
104
114
 
105
115
  That is deliberate. A cache would owe invalidation on session switch, branch, compaction, process start
@@ -111,13 +121,16 @@ Two deviations follow from it:
111
121
 
112
122
  - **The branch is not the model's context.** After a compaction the model's context drops older
113
123
  entries, but the reader still walks the whole branch, so `/todoloop` can report open work the model
114
- has no list for. The panel and the model can disagree.
124
+ has no list for. This is the hole the removed injection used to paper over: a model that has been
125
+ compacted may not know the list exists until it calls `todo` or is nudged. The nudge is the reminder;
126
+ the tool is the source.
115
127
  - **A newer malformed entry is skipped** in favour of an older valid one, so after a shape change the
116
128
  reader serves the list as of that change. Reading an errored `todo` call as "list clear" would be
117
129
  worse.
118
130
 
119
- `no list` therefore has four causes: no todo tool installed, a tool that is not named `todo`, a shape
120
- this extension does not recognise, or every task `deleted`.
131
+ `no list` therefore has five causes: the model never called the `todo` tool in this session, no todo
132
+ tool installed, a tool that is not named `todo`, a shape this extension does not recognise, or every
133
+ task `deleted`.
121
134
 
122
135
  ## Scope
123
136
 
@@ -137,23 +150,21 @@ this extension does not recognise, or every task `deleted`.
137
150
 
138
151
  Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
139
152
  That includes values below `1` — `"maxRounds": 0` means "use 12", not "never continue". Numbers are
140
- clamped so a typo cannot defeat the cap it configures: `maxRounds` ≤ 1000, `maxStrikes` ≤ 100,
141
- `maxInjectedTasks` ≤ 50.
153
+ clamped so a typo cannot defeat the cap it configures: `maxRounds` ≤ 1000.
142
154
 
143
155
  ## Cost
144
156
 
145
157
  - A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
146
158
  number of continuations × session size. `maxRounds` caps continuations, not turns — tool turns are
147
- pi's own and unbounded, so a long tool-heavy stretch can be many turns for one round. That, not the
148
- block, is the dominant term.
149
- - `context` reads the config and probes the kill switch, and walks the branch only when it is going to
150
- inject. `turn_end` does all three on every turn. While tasks are open the block adds ~100 tokens at
151
- the tail; `injectState: false` adds none and leaves the model to call the todo tool's `list` action.
152
- - One small measurement on `deepseek-flash` (a 2.7k-token session, five turns): ~1,050 uncached input
153
- tokens per turn with injection on against ~405 with `injectState: false`, and cache reads occurred in
154
- both runs (16.0k vs 36.1k total). The block does not disable the prompt cache, but it and the head
155
- rebuild cost a few hundred uncached tokens per request. An order of magnitude, not a benchmark — the
156
- two runs took different trajectories.
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
164
+ 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
167
+ walks the branch on every turn.
157
168
 
158
169
  ## More
159
170
 
@@ -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 *Tested against* |
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`, and `registerCommand`. Nothing here
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 200 bytes per continuation in the journal _(silent in the transcript)_.
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 block is appended only where it is needed, and it never doubles the tail.** Three
49
- configurations get no block: `injectState: false`, a list with no open task, and the kill switch. In
50
- the first two the request keeps whatever tail the history has (a marker, an assistant turn, or the
51
- human's own message) and the model must call the todo tool's `list` action to learn the list — the
52
- one place this extension asks for compliance instead of enforcing state. When a block is added it
53
- replaces a tail marker, is merged into a human-role tail, and is appended only after an assistant or
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,7 @@ 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.** Removing it or creating the file
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
75
  takes effect on the next request, without `/reload`.
76
76
  11. **No dependency on `@earendil-works/pi-coding-agent`, and no peer declaration for it.** Nothing
77
77
  here imports the package: the boundary is typed `pi: any` and contexts are typed structurally
@@ -115,16 +115,13 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
115
115
  14. **The loop's counters are not reconstructible from the session file** _(silent)_. A `turn_end`
116
116
  handler is handed `outcome`, `entries` and `continue`; what a `-p`/JSON run persists for the event
117
117
  carries only `{type, message, toolResults}`. A session file therefore shows which continuations happened and
118
- when, not how many rounds or strikes were left, nor why the loop stopped; that is only visible live
118
+ when, not how many rounds or stalls were left, nor why the loop stopped; that is only visible live
119
119
  through `/todoloop`. Two runs that end for different reasons can look identical on disk.
120
120
 
121
- 15. **Three user-role tails cannot take a merge, so the block becomes a second user message there.**
122
- pi rebuilds the content of `bashExecution`, `branchSummary` and `compactionSummary` from their own
123
- fields and ignores `content`, so writing the block into one of them would delete it. Those three
124
- get a separate user message instead, which means the request briefly has two user-role messages in a
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
-
121
+ 15. _(Inapplicable since the state block was removed.)_ Three user-role tails —
122
+ `bashExecution`, `branchSummary`, `compactionSummary` — cannot take a merged `content`, because pi
123
+ rebuilds those from their own fields. Nothing is merged into them now: the only text this
124
+ extension adds is a standalone marker message, which needs no merge and creates no adjacency.
128
125
  16. **The reader walks `getBranch()`, which is not the model-visible context.** pi's compaction-aware
129
126
  projection is `buildContextEntries()`, and a `context_edit` can hide an entry from future model
130
127
  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.1.0",
4
- "description": "Keep pi working while a todo list still has open tasks, and keep that list in context across compaction.",
3
+ "version": "0.2.0",
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": {
@@ -15,7 +15,6 @@ const REQUIRED = [
15
15
  "src/index.ts",
16
16
  "src/loop.ts",
17
17
  "src/state.ts",
18
- "src/inject.ts",
19
18
  "src/config.ts",
20
19
  "docs/pi-api-alignment.md",
21
20
  "README.md",
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