@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/AGENTS.md +366 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +4 -0
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/docs/behaviour.md +168 -0
- package/docs/pi-api-alignment.md +134 -0
- package/package.json +61 -5
- package/scripts/check-pack.mjs +62 -0
- package/scripts/pack-json.mjs +33 -0
- package/src/config.ts +48 -0
- package/src/index.ts +323 -0
- package/src/inject.ts +97 -0
- package/src/loop.ts +131 -0
- package/src/state.ts +216 -0
- package/tsconfig.json +17 -0
package/README.md
CHANGED
|
@@ -1,3 +1,183 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dumbmice/pi-todo-loop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@dumbmice/pi-todo-loop)
|
|
4
|
+
[](https://github.com/DumbMice/pi-todo-loop/blob/main/LICENSE)
|
|
5
|
+
[](https://github.com/DumbMice/pi-todo-loop/blob/main/package.json)
|
|
6
|
+
[](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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|