@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.
@@ -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
- "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.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
+ }