@dumbmice/pi-todo-loop 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +365 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +4 -0
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/docs/behaviour.md +162 -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 +77 -0
- package/src/loop.ts +131 -0
- package/src/state.ts +216 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
- **A list cleared by `todo` calls alone is announced.** If every task goes from open to closed in a
|
|
56
|
+
turn whose only tool results were `todo`, a warning says so once. The loop's success state and its
|
|
57
|
+
failure state are the same observable.
|
|
58
|
+
|
|
59
|
+
## Counters
|
|
60
|
+
|
|
61
|
+
| Counter | Meaning | Reset by |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `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 |
|
|
65
|
+
| `staleSettles` | settles since the list last changed | the list changing, a new human message |
|
|
66
|
+
|
|
67
|
+
- **One budget per human message.** A user-role message carrying a `customType` (this extension's
|
|
68
|
+
marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
|
|
69
|
+
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
|
|
71
|
+
another extension's continuation or a provider retry under the same message cannot re-arm 12 rounds.
|
|
72
|
+
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.
|
|
76
|
+
- **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
|
|
77
|
+
and spends no round; the counters are left as they are, so the next settle under the same message can
|
|
78
|
+
continue — which is what a provider retry hits.
|
|
79
|
+
- **A stop is announced** once, when the reason changes, so a run cannot end silently on a cap.
|
|
80
|
+
- **Counters live in memory.** `/reload`, a restart or a resumed session starts a new budget and
|
|
81
|
+
re-reads the list; a pause does not survive either.
|
|
82
|
+
|
|
83
|
+
### Reason strings
|
|
84
|
+
|
|
85
|
+
`/todoloop` prints one of these as `last:`. They are also announced when they change.
|
|
86
|
+
|
|
87
|
+
| `last:` | Meaning |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `continuing: round 1/12, 2 open` | working as intended |
|
|
90
|
+
| `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 |
|
|
94
|
+
| `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
|
|
95
|
+
| `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 |
|
|
98
|
+
| `idle` | nothing has run yet in this process |
|
|
99
|
+
|
|
100
|
+
## Where the list comes from
|
|
101
|
+
|
|
102
|
+
The list is read from the session branch on every request, never cached: a `rpiv-todo-state` custom
|
|
103
|
+
entry's `data.tasks`, or a `todo` toolResult's `details.tasks`, whichever is newer.
|
|
104
|
+
|
|
105
|
+
That is deliberate. A cache would owe invalidation on session switch, branch, compaction, process start
|
|
106
|
+
and fork; and a resumed session with an open list would report `no list` until the model happened to
|
|
107
|
+
call `todo` — the failure this package exists to prevent. The cost is one backward walk of the branch,
|
|
108
|
+
sub-millisecond on thousands of entries.
|
|
109
|
+
|
|
110
|
+
Two deviations follow from it:
|
|
111
|
+
|
|
112
|
+
- **The branch is not the model's context.** After a compaction the model's context drops older
|
|
113
|
+
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.
|
|
115
|
+
- **A newer malformed entry is skipped** in favour of an older valid one, so after a shape change the
|
|
116
|
+
reader serves the list as of that change. Reading an errored `todo` call as "list clear" would be
|
|
117
|
+
worse.
|
|
118
|
+
|
|
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`.
|
|
121
|
+
|
|
122
|
+
## Scope
|
|
123
|
+
|
|
124
|
+
- **A delegated child session runs its own loop.** Each session has its own list and counters, so a
|
|
125
|
+
subagent that loads this extension can continue independently of its parent; markers do not
|
|
126
|
+
interleave, but the work can.
|
|
127
|
+
- **`/todoloop` needs an interactive session.** In `pi -p` and `--mode json` the loop runs, but there is
|
|
128
|
+
no UI for the status or the stop notification; the only signal is the marker count in the session
|
|
129
|
+
file and the model's final message.
|
|
130
|
+
- **`.pi/todo-loop-off` stops every session started in that directory**; `~/.pi/agent/todo-loop-off`
|
|
131
|
+
stops all projects; the config file is global and re-read on every request, so editing it affects
|
|
132
|
+
running sessions.
|
|
133
|
+
- **Nothing is written to your working tree.** The reads are the config, the two kill-switch paths and
|
|
134
|
+
the session branch.
|
|
135
|
+
|
|
136
|
+
## Configuration limits
|
|
137
|
+
|
|
138
|
+
Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
|
|
139
|
+
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.
|
|
142
|
+
|
|
143
|
+
## Cost
|
|
144
|
+
|
|
145
|
+
- A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
|
|
146
|
+
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.
|
|
157
|
+
|
|
158
|
+
## More
|
|
159
|
+
|
|
160
|
+
- [pi extension API alignment](pi-api-alignment.md) — the deliberate workarounds, with the pi version
|
|
161
|
+
each was verified against.
|
|
162
|
+
- [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.0",
|
|
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
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading. A missing, unreadable, or malformed file falls back to the
|
|
3
|
+
* defaults: this extension must never be the reason a turn fails.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { DEFAULTS, type LoopConfig } from "./loop.ts";
|
|
8
|
+
|
|
9
|
+
export { DEFAULTS };
|
|
10
|
+
|
|
11
|
+
function coerce(raw: unknown): Partial<LoopConfig> {
|
|
12
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
13
|
+
const record = raw as Record<string, unknown>;
|
|
14
|
+
const out: Partial<LoopConfig> = {};
|
|
15
|
+
|
|
16
|
+
// Clamped, not just validated: a hostile or typo'd config must not be able to
|
|
17
|
+
// defeat the caps it configures (`maxRounds: 1e9` would otherwise be honoured).
|
|
18
|
+
const bounded = (value: unknown, ceiling: number): number | undefined =>
|
|
19
|
+
typeof value === "number" && Number.isFinite(value) && value >= 1
|
|
20
|
+
? Math.min(Math.floor(value), ceiling)
|
|
21
|
+
: undefined;
|
|
22
|
+
|
|
23
|
+
const maxRounds = bounded(record.maxRounds, 1_000);
|
|
24
|
+
if (maxRounds !== undefined) out.maxRounds = maxRounds;
|
|
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
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mergeConfig(raw: unknown): LoopConfig {
|
|
38
|
+
return { ...DEFAULTS, ...coerce(raw) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfig(path: string): LoopConfig {
|
|
42
|
+
try {
|
|
43
|
+
return mergeConfig(JSON.parse(readFileSync(path, "utf8")));
|
|
44
|
+
} catch {
|
|
45
|
+
// A fresh copy: the shared object must never be mutable by a caller.
|
|
46
|
+
return { ...DEFAULTS };
|
|
47
|
+
}
|
|
48
|
+
}
|