@dumbmice/pi-todo-loop 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,183 @@
1
- # Temporary Holding Version
1
+ # @dumbmice/pi-todo-loop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@dumbmice/pi-todo-loop)](https://www.npmjs.com/package/@dumbmice/pi-todo-loop)
4
+ [![license](https://img.shields.io/npm/l/@dumbmice/pi-todo-loop)](https://github.com/DumbMice/pi-todo-loop/blob/main/LICENSE)
5
+ [![node](https://img.shields.io/node/v/@dumbmice/pi-todo-loop)](https://github.com/DumbMice/pi-todo-loop/blob/main/package.json)
6
+ [![tests](https://github.com/DumbMice/pi-todo-loop/actions/workflows/test.yml/badge.svg)](https://github.com/DumbMice/pi-todo-loop/actions/workflows/test.yml)
7
+
8
+ Keeps the [pi coding agent](https://pi.dev) working while its todo list still has open tasks.
9
+
10
+ `rpiv-todo` gives the agent a task list and shows it to you. It never asks pi for another turn, so the
11
+ agent still stops halfway and hands control back. This extension adds the turn: while tasks are open it
12
+ keeps the agent going, puts the list back in front of the model after a compaction, and stops when you
13
+ clear the list.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ pi install npm:@juicesharp/rpiv-todo # the list this reads (skip if installed)
19
+ pi install npm:@dumbmice/pi-todo-loop
20
+ ```
21
+
22
+ Then start a session and ask for something with several steps:
23
+
24
+ ```text
25
+ Use the todo tool to plan this, then work through it: make the reports page
26
+ export CSV, add tests, and update the docs.
27
+ ```
28
+
29
+ There is nothing else to configure. `/todoloop` shows what it is doing; `touch
30
+ ~/.pi/agent/todo-loop-off` turns it off.
31
+
32
+ ## What it looks like
33
+
34
+ ```text
35
+ you: make the whole sequence web-editable, then expose it in the browser
36
+
37
+ pi: [todo: 12 tasks created, first marked in_progress]
38
+ pi: T5 backend is in and verified. Committing a report of the state …
39
+ ← without this extension the run ends here, with 11 tasks open.
40
+
41
+ pi: [todo: T5 completed, T6 in_progress]
42
+ pi: T6 audio/face lanes wired. T7 per-camera PNG export next …
43
+
44
+ pi: [todo: all 12 completed]
45
+ pi: All 12 tasks are complete; here is what was verified …
46
+ ← the list is clear, so the loop stops and stays stopped.
47
+ ```
48
+
49
+ It stops for three reasons, and `/todoloop` always says which:
50
+
51
+ | Why it stopped | What you see |
52
+ |---|---|
53
+ | the list is clear | `stopped: list is clear` — the normal ending |
54
+ | the model stopped working the list | `stopped: 2 consecutive turns with no progress, 3 open` — two quiet turns in a row |
55
+ | the budget is spent | `stopped: round cap 12 reached, 3 open` — 12 continuations for your one message |
56
+
57
+ ## When it helps
58
+
59
+ - Long, multi-step work where the agent summarises and hands back halfway.
60
+ - Sessions long enough to be compacted — the list survives on disk, but the model loses it.
61
+ - A model that runs tools but rarely updates its list. It will still be nudged; it may keep working to
62
+ the round cap.
63
+
64
+ ## When it does not
65
+
66
+ - **No todo tool installed.** The extension is inert: nothing continues, nothing is injected.
67
+ - **You want an autonomous agent that invents its own goals.** This drives the list you have; it stops
68
+ after 12 continuations per message by default.
69
+ - **You want the continuations visible.** They are silent by design.
70
+ - **You want the agent to keep going after you said stop.** Your instruction wins; a model that reads
71
+ "stop" as final will hold, and those refusals cost up to 12 turns.
72
+
73
+ It buys *liveness*, not work. A model can satisfy the loop by closing tasks, including deleting
74
+ placeholders, and a list that is never updated looks the same as one that is finished. Clearing the list
75
+ is the only thing that stops it, so the list has to mean something to you.
76
+
77
+ ## Configuration
78
+
79
+ Optional, at `~/.pi/agent/todo-loop.json`:
80
+
81
+ ```json
82
+ {
83
+ "maxRounds": 12,
84
+ "maxStrikes": 2,
85
+ "injectState": true,
86
+ "maxInjectedTasks": 6
87
+ }
88
+ ```
89
+
90
+ | Key | Default | What it does |
91
+ |---|---|---|
92
+ | `maxRounds` | `12` | continuations allowed per message to the agent |
93
+ | `maxStrikes` | `2` | turns in a row that leave the list unchanged before stopping |
94
+ | `injectState` | `true` | put the open-task list into each request; `false` leaves the model to read the list itself |
95
+ | `maxInjectedTasks` | `6` | rows shown before `… and N more` |
96
+
97
+ A missing or broken file falls back to these values; a bad config never fails a turn.
98
+
99
+ ## Commands
100
+
101
+ | Command | What it does |
102
+ |---|---|
103
+ | `/todoloop` | status: mode, rounds, strikes, open tasks, why it last stopped, config path |
104
+ | `/todoloop pause` | stop continuing and stop injecting, until you resume or restart |
105
+ | `/todoloop resume` | allow the next turn to continue again |
106
+ | `/todoloop reset` | clear the counters and start the budget over |
107
+
108
+ **Kill switch** — checked before every decision:
109
+
110
+ ```bash
111
+ touch ~/.pi/agent/todo-loop-off # everywhere
112
+ touch .pi/todo-loop-off # this project only
113
+ ```
114
+
115
+ ## Is it working?
116
+
117
+ After a response that made no tool calls while tasks are open, `/todoloop` should read something like:
118
+
119
+ ```
120
+ todo-loop active
121
+ rounds 1/12 · strikes 0/2
122
+ open 2/5 tracked · inject on
123
+ anchor 4f3c1a2e
124
+ last: continuing: round 1/12, 2 open
125
+ config ~/.pi/agent/todo-loop.json
126
+ ```
127
+
128
+ To count continuations in a session file:
129
+
130
+ ```bash
131
+ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl
132
+ ```
133
+
134
+ ## Troubleshooting
135
+
136
+ | Symptom | What to do |
137
+ |---|---|
138
+ | Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it, `last: stopped: no list` means no todo tool is writing a list |
139
+ | It stopped with `no progress` | The model answered twice without running a tool or changing the list. Raise `maxStrikes` if you want more attempts |
140
+ | It stopped at the round cap | The budget for that message is spent; send another message. If the reason ends with `list unchanged for N settles`, the model was working without updating the list |
141
+ | Rounds jump by two, or continuations appear twice | Two continuation extensions are installed; `pi list` shows them, remove one, then `/reload` |
142
+ | The list is stale | The extension nudges, it does not write statuses. Nothing here fabricates progress |
143
+ | 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 |
144
+
145
+ ## Uninstall
146
+
147
+ ```bash
148
+ pi remove npm:@dumbmice/pi-todo-loop
149
+ rm -f ~/.pi/agent/todo-loop.json
150
+ rm -f ~/.pi/agent/todo-loop-off .pi/todo-loop-off
151
+ ```
152
+
153
+ Then `/reload`. Nothing was ever written to your working tree, and old session files keep only the
154
+ hidden continuation entries.
155
+
156
+ ## Requirements
157
+
158
+ - **pi 1.5.0** (tested; needs the `turn_end` boundary).
159
+ - **A todo tool** named `todo` that stores its list in the session — tested with
160
+ `@juicesharp/rpiv-todo` 2.15.0.
161
+ - **Node 22.19+**, the floor pi itself requires. The extension has no dependencies.
162
+
163
+ Last verified 2026-11-04; the exact table and what was checked is in
164
+ [docs/pi-api-alignment.md](docs/pi-api-alignment.md).
165
+
166
+ Install only one continuation extension: a second one that also requests turns makes the counters and
167
+ the budget meaningless.
168
+
169
+ ## Further reading
170
+
171
+ - **How it behaves, precisely** — counters, markers, the injected block, cost:
172
+ [docs/behaviour.md](docs/behaviour.md)
173
+ - **pi API alignment** — the deliberate workarounds: [docs/pi-api-alignment.md](docs/pi-api-alignment.md)
174
+ - **Working on the repository**: [AGENTS.md](AGENTS.md)
175
+ - **Changes by version**: [CHANGELOG.md](CHANGELOG.md)
176
+ - **Why it exists** — the upstream issues:
177
+ [rpiv-mono#159](https://github.com/juicesharp/rpiv-mono/issues/159),
178
+ [#222](https://github.com/juicesharp/rpiv-mono/issues/222),
179
+ [#259](https://github.com/juicesharp/rpiv-mono/issues/259)
180
+
181
+ ## License
182
+
183
+ MIT. Source and issues: <https://github.com/DumbMice/pi-todo-loop>.
@@ -0,0 +1,168 @@
1
+ # How it behaves, precisely
2
+
3
+ The mechanics behind [README](../README.md). Read this when you are debugging behaviour, deciding
4
+ whether to change a default, or wondering why the loop did something. It assumes you know that a
5
+ **settle** is a model response with no tool calls — the only turn that can end a run.
6
+
7
+ ## The turn it adds
8
+
9
+ `rpiv-todo` renders and stores the list but never asks pi for another turn. Pi ends a run when the model
10
+ answers with no tool calls. This extension hooks that boundary: while the branch has open tasks it
11
+ requests one more turn, so the model continues instead of handing control back.
12
+
13
+ Two consequences follow from that being the only mechanism available:
14
+
15
+ - **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.
19
+ - **Only a settle spends a round.** `turn_end` fires after every assistant response, including one that
20
+ 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.
22
+
23
+ ## Markers and role alternation
24
+
25
+ A marker sits between two assistant turns, which is what keeps the conversation alternating: a request
26
+ that contained `assistant → assistant` in a row is rejected by alternation-requiring providers.
27
+
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.
55
+ - **The block escalates once.** On the first request of a chain it only asks for status
56
+ moves; from the first continuation onwards it adds a reconciliation clause — *"if this
57
+ list has drifted from the work, reconcile it now: update statuses, add what is missing,
58
+ and remove what no longer applies"* — with removal last. A first sighting should not
59
+ invite reshaping the list; after a nudge that left the list untouched, a drifted list is
60
+ the likelier explanation. Verified: 115 tests, including both sides of the gate.
61
+ - **A list cleared by `todo` calls alone is announced.** If every task goes from open to closed in a
62
+ turn whose only tool results were `todo`, a warning says so once. The loop's success state and its
63
+ failure state are the same observable.
64
+
65
+ ## Counters
66
+
67
+ | Counter | Meaning | Reset by |
68
+ |---|---|---|
69
+ | `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
70
+ | `strikes` | settles in a row that left the list unchanged | the list changing, a completed tool turn, a new human message |
71
+ | `staleSettles` | settles since the list last changed | the list changing, a new human message |
72
+
73
+ - **One budget per human message.** A user-role message carrying a `customType` (this extension's
74
+ marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
75
+ the budget; `/todoloop` prints the anchor it is using.
76
+ - **A stop keeps the budget shut.** Hitting the cap or the strike limit does not clear the counters, so
77
+ another extension's continuation or a provider retry under the same message cannot re-arm 12 rounds.
78
+ A queued human message is a new anchor and resets the budget the ordinary way.
79
+ - **A stale list stops on the cap, not on strikes**, because any tool turn clears the strike budget.
80
+ That is why the cap reason appends `, list unchanged for N settles` — so a stale burn does not read
81
+ like healthy work.
82
+ - **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
83
+ and spends no round; the counters are left as they are, so the next settle under the same message can
84
+ continue — which is what a provider retry hits.
85
+ - **A stop is announced** once, when the reason changes, so a run cannot end silently on a cap.
86
+ - **Counters live in memory.** `/reload`, a restart or a resumed session starts a new budget and
87
+ re-reads the list; a pause does not survive either.
88
+
89
+ ### Reason strings
90
+
91
+ `/todoloop` prints one of these as `last:`. They are also announced when they change.
92
+
93
+ | `last:` | Meaning |
94
+ |---|---|
95
+ | `continuing: round 1/12, 2 open` | working as intended |
96
+ | `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 four causes below |
98
+ | `stopped: 2 consecutive turns with no progress, 2 open` | the last two settles ran no tool and left the list unchanged |
99
+ | `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 |
100
+ | `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
101
+ | `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
102
+ | `resumed by user` | `/todoloop resume`; the next settle may continue |
103
+ | `counters reset` | `/todoloop reset` cleared the rounds, strikes and list signature |
104
+ | `idle` | nothing has run yet in this process |
105
+
106
+ ## Where the list comes from
107
+
108
+ The list is read from the session branch on every request, never cached: a `rpiv-todo-state` custom
109
+ entry's `data.tasks`, or a `todo` toolResult's `details.tasks`, whichever is newer.
110
+
111
+ That is deliberate. A cache would owe invalidation on session switch, branch, compaction, process start
112
+ and fork; and a resumed session with an open list would report `no list` until the model happened to
113
+ call `todo` — the failure this package exists to prevent. The cost is one backward walk of the branch,
114
+ sub-millisecond on thousands of entries.
115
+
116
+ Two deviations follow from it:
117
+
118
+ - **The branch is not the model's context.** After a compaction the model's context drops older
119
+ entries, but the reader still walks the whole branch, so `/todoloop` can report open work the model
120
+ has no list for. The panel and the model can disagree.
121
+ - **A newer malformed entry is skipped** in favour of an older valid one, so after a shape change the
122
+ reader serves the list as of that change. Reading an errored `todo` call as "list clear" would be
123
+ worse.
124
+
125
+ `no list` therefore has four causes: no todo tool installed, a tool that is not named `todo`, a shape
126
+ this extension does not recognise, or every task `deleted`.
127
+
128
+ ## Scope
129
+
130
+ - **A delegated child session runs its own loop.** Each session has its own list and counters, so a
131
+ subagent that loads this extension can continue independently of its parent; markers do not
132
+ interleave, but the work can.
133
+ - **`/todoloop` needs an interactive session.** In `pi -p` and `--mode json` the loop runs, but there is
134
+ no UI for the status or the stop notification; the only signal is the marker count in the session
135
+ file and the model's final message.
136
+ - **`.pi/todo-loop-off` stops every session started in that directory**; `~/.pi/agent/todo-loop-off`
137
+ stops all projects; the config file is global and re-read on every request, so editing it affects
138
+ running sessions.
139
+ - **Nothing is written to your working tree.** The reads are the config, the two kill-switch paths and
140
+ the session branch.
141
+
142
+ ## Configuration limits
143
+
144
+ Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
145
+ 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, `maxStrikes` ≤ 100,
147
+ `maxInjectedTasks` ≤ 50.
148
+
149
+ ## Cost
150
+
151
+ - A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
152
+ 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, not the
154
+ block, is the dominant term.
155
+ - `context` reads the config and probes the kill switch, and walks the branch only when it is going to
156
+ inject. `turn_end` does all three on every turn. While tasks are open the block adds ~100 tokens at
157
+ the tail; `injectState: false` adds none and leaves the model to call the todo tool's `list` action.
158
+ - One small measurement on `deepseek-flash` (a 2.7k-token session, five turns): ~1,050 uncached input
159
+ tokens per turn with injection on against ~405 with `injectState: false`, and cache reads occurred in
160
+ both runs (16.0k vs 36.1k total). The block does not disable the prompt cache, but it and the head
161
+ rebuild cost a few hundred uncached tokens per request. An order of magnitude, not a benchmark — the
162
+ two runs took different trajectories.
163
+
164
+ ## More
165
+
166
+ - [pi extension API alignment](pi-api-alignment.md) — the deliberate workarounds, with the pi version
167
+ each was verified against.
168
+ - [AGENTS.md](../AGENTS.md) — layout, invariants, tests, release process.
@@ -0,0 +1,134 @@
1
+ # pi extension API alignment
2
+
3
+ How this extension's use of pi's extension API relates to the documented contract, and which exact
4
+ versions that was established against.
5
+
6
+ | Component | Tested version | Recorded in |
7
+ |---|---|---|
8
+ | `@earendil-works/pi-coding-agent` | **1.5.0** | `PI_API_SNAPSHOT` in `src/index.ts` |
9
+ | `@juicesharp/rpiv-todo` | **2.15.0** | this file (item 12) |
10
+ | Node | 22.23.2 | README *Tested against* |
11
+
12
+ Last verified **2026-11-04**. Bump a row here, its constant where one exists, and the README table in
13
+ the same commit as the change that depends on it.
14
+
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
17
+ calls pi internals; item 1 nonetheless depends on behaviour pi documents nowhere, which is why it is
18
+ listed below rather than assumed.
19
+
20
+ What is unusual is not the API use but the *shape* of it: this extension exists to perform a
21
+ continuation that pi does not expose as a single operation. Each client-side workaround is listed in
22
+ full. Items that produce no signal in the transcript are marked _(silent)_.
23
+
24
+ 1. **One hidden entry per continuation, and only on a settle.** `turn_end` fires for every assistant
25
+ response, including one that ended in a tool call; such a turn already causes pi's next request, so
26
+ the hook returns early and spends nothing. `continue: true` is proposed only when the run would
27
+ otherwise end, and the proposed
28
+ projection must end on a message whose role is not `assistant` — otherwise the boundary reports an
29
+ invalid continuation and drops it. pi offers no `resume()` that appends nothing, so a continuation
30
+ 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)_.
32
+ 2. **Markers stay in history and are not stripped.** They sit between two assistant turns, so they are
33
+ what keeps the conversation alternating: `assistant → marker(user) → assistant`. Removing them
34
+ exposes runs of consecutive assistant messages, which alternation-requiring providers reject — a
35
+ stricter risk than the one marker-per-round growth they cost (about 200 bytes each, bounded by
36
+ `maxRounds` per human message, and they accumulate across messages). Their body is constant (`<todo_loop_continue/>`), so a marker left in
37
+ history cannot claim the loop is on some other round. With the kill switch set the hook rewrites
38
+ nothing, because a disabled extension must not touch the request.
39
+ 3. **Two different tails are in play, and only one of them is constrained.** pi's boundary checks the
40
+ *persisted projection* when `turn_end` proposes `continue: true`; the appended marker is what
41
+ satisfies that check for this extension (`_buildBoundaryContext().canContinue`, computed from the
42
+ committed drafts — any non-assistant final projected message would do, including a queued one). The
43
+ *provider request* built by the `context` hook is a different list: pi documents no constraint on its
44
+ final role, and a request ending on the assistant message is a normal continuation — the mechanism
45
+ used by `pi-invisible-continue`'s `/continue`
46
+ (<https://github.com/monotykamary/pi-invisible-continue>, not re-tested here). Conflating the two is
47
+ 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.
56
+ 5. **Returning a changed list rebuilds the request head.** pi documents that a `context` handler which
57
+ returns a different message list causes it to replay the current prompt sections and tool
58
+ declarations into one leading system message ahead of the returned list. The rebuild is lossless
59
+ (content appended, sections patched, tools resolved) but the head is not byte-identical to the
60
+ previous request, so a prompt cache keyed on the head can miss. Observed while reading pi 1.5.0's
61
+ extension runner; not measured in this repository.
62
+ 6. **Task state is read from the branch on every request, not cached in memory.** Loop state (counters,
63
+ anchor, pause) is in memory and therefore resets with the process. No `session_compact`
64
+ resync handler is needed, and a branch switch cannot leak another branch's tasks. The cost is one
65
+ backward walk of the active branch per request.
66
+ 7. **The marker `customType` is a stable literal, not a constant keyed to the package version.**
67
+ Sessions written by earlier builds keep filtering correctly after an upgrade.
68
+ 8. **Malformed session entries degrade to "no list".** A boundary handler that throws is reported and
69
+ the turn continues, but the loop would silently stop continuing; failing soft keeps the extension
70
+ inert instead of noisy.
71
+ 9. **`sendUserMessage` and `Agent.prompt` are deliberately not used.** `sendUserMessage` would add a
72
+ visible user-role message per continuation and pollute the transcript; calling the agent directly
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
75
+ takes effect on the next request, without `/reload`.
76
+ 11. **No dependency on `@earendil-works/pi-coding-agent`, and no peer declaration for it.** Nothing
77
+ here imports the package: the boundary is typed `pi: any` and contexts are typed structurally
78
+ (`SessionContext`), so the only manifest link to the host is `pi.extensions`. A `peerDependencies`
79
+ entry would assert a module dependency that does not exist and would have no effect regardless —
80
+ managed installs pass `--omit=peer` and disable peer resolution, and the loader performs no
81
+ compatibility check. The trade-off of dropping the type import: the compiler does not check event
82
+ payload shapes, so `npm test` and a real `pi -e` run carry that weight, and the tested pi version
83
+ is recorded in the table above rather than enforced.
84
+ 12. **A declared dependency on rpiv-todo's *persisted shape*, which is not its public API** (checked
85
+ against `@juicesharp/rpiv-todo` 2.15.0; the symbols named below are that version's). This
86
+ extension imports nothing from `@juicesharp/rpiv-todo` and therefore declares no dependency on it,
87
+ but it reads two representations the package writes: `custom` entries with
88
+ `customType: "rpiv-todo-state"`, and `todo` toolResult `details.tasks`. The package's own issues
89
+ describe `details` as extension-specific session data, so a change to that shape is not
90
+ guaranteed to appear in its semver. Consequences, accepted deliberately:
91
+ - the reader is written defensively (both shapes tried, `subject`/`content` accepted, malformed
92
+ entries treated as "no list") so a drift degrades to inert rather than throwing in a boundary;
93
+ - the tested version is recorded above, not enforced;
94
+ - the parser is duplicated rather than imported. `@juicesharp/rpiv-todo/state/replay.ts` does
95
+ export `replayFromBranch` and `isTaskDetails`, but a peer import would resolve to nothing under
96
+ pi's `--omit=peer` installs, and a declared dependency would make pi install a second nested
97
+ copy of the extension — which registers a duplicate `todo` tool.
98
+ - failure is currently indistinguishable from "the agent never called `todo`": both report
99
+ `stopped: no list` in `/todoloop`. Making those cases distinguishable is the next diagnostic
100
+ improvement, not a compatibility fix;
101
+ - a *newer* entry whose `tasks` is malformed is skipped in favour of an older valid one, so after a
102
+ shape change the reader serves the list as of the change. Not treated as authoritative-empty on
103
+ purpose: an errored `todo` call carries no `tasks`, and reading that as "list clear" would stop the
104
+ loop on a failed tool call.
105
+
106
+ 13. **The anchor skips user-role messages that carry a `customType`** _(silent)_. `anchorFromEntry`
107
+ ignores any `role: "user"` message with a string `customType`, which covers this extension's own
108
+ marker, another continuation extension's hidden resume message, and a command expansion that carries
109
+ one. The alternative — narrowing the skip to our own marker type — would let another extension's
110
+ hidden message move the anchor on every continuation and re-arm the budget each round, which defeats
111
+ the cap; the cost of the broad skip is that such a message does not reset the budget either. That is
112
+ one budget for one message, self-healing on the next plain message and visible in
113
+ `/todoloop`'s `anchor` line.
114
+
115
+ 14. **The loop's counters are not reconstructible from the session file** _(silent)_. A `turn_end`
116
+ handler is handed `outcome`, `entries` and `continue`; what a `-p`/JSON run persists for the event
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
119
+ through `/todoloop`. Two runs that end for different reasons can look identical on disk.
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
+
128
+ 16. **The reader walks `getBranch()`, which is not the model-visible context.** pi's compaction-aware
129
+ projection is `buildContextEntries()`, and a `context_edit` can hide an entry from future model
130
+ context while it stays on the branch. Reading the branch therefore means the extension can report a
131
+ task list the model cannot see — after a compaction, or after another extension edited context. Kept
132
+ deliberately: the newest persisted todo state is the right answer for the loop's counters, and
133
+ reading the projected context instead would make the list vanish mid-run, which is the failure this
134
+ package exists to prevent.
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
- "name": "@dumbmice/pi-todo-loop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@dumbmice/pi-todo-loop",
3
+ "version": "0.1.1",
4
+ "description": "Keep pi working while a todo list still has open tasks, and keep that list in context across compaction.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "DumbMice",
9
+ "email": "bangchengyang@hotmail.com"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi-extension",
14
+ "pi-coding-agent",
15
+ "todo",
16
+ "task-list",
17
+ "continuation",
18
+ "agentic-loop",
19
+ "compaction",
20
+ "deepseek",
21
+ "rpiv-todo"
22
+ ],
23
+ "homepage": "https://github.com/DumbMice/pi-todo-loop#readme",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/DumbMice/pi-todo-loop.git"
27
+ },
28
+ "bugs": "https://github.com/DumbMice/pi-todo-loop/issues",
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "files": [
35
+ "src",
36
+ "scripts",
37
+ "tsconfig.json",
38
+ "docs",
39
+ "README.md",
40
+ "AGENTS.md",
41
+ "CONTRIBUTING.md",
42
+ "LICENSE",
43
+ "CHANGELOG.md"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "test": "node --experimental-strip-types --no-warnings --test test/*.test.ts",
50
+ "typecheck": "tsc -p tsconfig.json",
51
+ "pack:check": "node scripts/check-pack.mjs",
52
+ "prepublishOnly": "npm test && npm run typecheck && npm run pack:check",
53
+ "pretest": "node -e \"const [a,b]=process.versions.node.split('.').map(Number); if(a<22||(a===22&&b<6)){console.error('@dumbmice/pi-todo-loop tests need Node >= 22.6, but node '+process.versions.node+' is first in PATH.\\nPut a newer node first, for example pi own node:\\n ~/.local/share/pi-node/current/bin'); process.exit(1)}\""
54
+ },
55
+ "engines": {
56
+ "node": ">=22.19"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^22.20.5",
60
+ "typescript": "^5.9.3"
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Verify the published tarball actually contains what the package promises.
4
+ *
5
+ * `npm pack --dry-run` prints a file list and exits 0 even when `files` matches
6
+ * nothing, so dropping "src" from the manifest used to pass CI while shipping a
7
+ * package whose `pi.extensions` entry point does not exist. This asserts the
8
+ * entry point, the docs and the licence are in the tarball.
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { firstPack } from "./pack-json.mjs";
13
+
14
+ const REQUIRED = [
15
+ "src/index.ts",
16
+ "src/loop.ts",
17
+ "src/state.ts",
18
+ "src/inject.ts",
19
+ "src/config.ts",
20
+ "docs/pi-api-alignment.md",
21
+ "README.md",
22
+ "AGENTS.md",
23
+ "CONTRIBUTING.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE",
26
+ "tsconfig.json",
27
+ ];
28
+
29
+ const FORBIDDEN = ["test", "test/", ".github", "node_modules", "package-lock.json"];
30
+
31
+ const raw = execFileSync("npm", ["pack", "--dry-run", "--json"], { encoding: "utf8" });
32
+ const tarball = firstPack(JSON.parse(raw));
33
+ if (!tarball || !Array.isArray(tarball.files)) {
34
+ console.error("pack check failed — npm pack produced no file list");
35
+ process.exit(1);
36
+ }
37
+ const files = new Set(tarball.files.map((file) => file.path));
38
+
39
+ const missing = REQUIRED.filter((path) => !files.has(path));
40
+ if (missing.length > 0) {
41
+ console.error(`pack check failed — missing from the tarball:\n ${missing.join("\n ")}`);
42
+ const sample = [...files].slice(0, 20).join("\n ");
43
+ console.error(`\nfiles present (${files.size}):\n ${sample}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ const entry = JSON.parse(execFileSync("node", ["-p", "JSON.stringify(require('./package.json').pi)"], { encoding: "utf8" }));
48
+ for (const declared of entry.extensions ?? []) {
49
+ const path = declared.replace(/^\.\//, "");
50
+ if (!files.has(path)) {
51
+ console.error(`pack check failed — pi.extensions declares ${path}, which is not in the tarball`);
52
+ process.exit(1);
53
+ }
54
+ }
55
+
56
+ const leaked = [...files].filter((path) => FORBIDDEN.some((bad) => path === bad || path.startsWith(`${bad}/`)));
57
+ if (leaked.length > 0) {
58
+ console.error(`pack check failed — dev-only paths in the tarball:\n ${leaked.join("\n ")}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ console.log(`pack ok: ${files.size} files, entry point and docs present, no dev paths`);
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Read `npm pack --json`, whose shape changed between npm majors.
3
+ *
4
+ * npm 10 returns an array of tarball records; npm 11 returns an object keyed by
5
+ * package name. The publish workflow upgrades npm, so both shapes appear in practice
6
+ * and code that destructures `[0]` breaks on the newer one.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ /** Normalise either shape to a list of tarball records. */
12
+ export function packEntries(value) {
13
+ if (Array.isArray(value)) return value.filter((entry) => entry && typeof entry === "object");
14
+ if (value && typeof value === "object") {
15
+ return Object.values(value).filter((entry) => entry && typeof entry === "object");
16
+ }
17
+ return [];
18
+ }
19
+
20
+ /** The first packed record, or undefined. */
21
+ export function firstPack(value) {
22
+ return packEntries(value)[0];
23
+ }
24
+
25
+ if (import.meta.url === `file://${process.argv[1]}`) {
26
+ const raw = execFileSync("npm", ["pack", "--dry-run", "--json"], { encoding: "utf8" });
27
+ const record = firstPack(JSON.parse(raw));
28
+ if (!record || typeof record.filename !== "string") {
29
+ console.error("pack-json: npm pack produced no tarball record");
30
+ process.exit(1);
31
+ }
32
+ console.log(record.filename);
33
+ }