pi-background-tasks 0.7.6 → 0.9.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/PUBLISHING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Publishing pi-background-tasks
2
2
 
3
- Release checklist for npm publishing and standalone git publishing. The current release candidate is 0.7.6; version 0.7.0 introduced the Fusion public surfaces (`/fusion`, `/fusion-models`, `fusion_brainstorm`) in addition to the background-task surfaces. Do not advertise the GitHub install target until the standalone repository has the exact release commit and tag.
3
+ Release checklist for npm publishing and standalone git publishing. The current release candidate is 0.9.0, which adds the `fusion_validate` validation workflow and Anthropic child sanitization; 0.7.0 introduced the Fusion public surfaces (`/fusion`, `/fusion-models`, `fusion_brainstorm`) in addition to the background-task surfaces. Do not advertise the GitHub install target until the standalone repository has the exact release commit and tag.
4
4
 
5
5
  ## Preconditions
6
6
 
@@ -37,8 +37,8 @@ npm publish --access public
37
37
  Pi install smoke after publish:
38
38
 
39
39
  ```bash
40
- PI_CODING_AGENT_DIR=$(mktemp -d) pi -e npm:pi-background-tasks@0.7.6 --offline --no-tools --no-session -p "/jobs"
41
- pi install npm:pi-background-tasks@0.7.6
40
+ PI_CODING_AGENT_DIR=$(mktemp -d) pi -e npm:pi-background-tasks@0.9.0 --offline --no-tools --no-session -p "/jobs"
41
+ pi install npm:pi-background-tasks@0.9.0
42
42
  ```
43
43
 
44
44
  ## Publish to git
@@ -51,15 +51,15 @@ git status --short --branch
51
51
  git log --oneline -3
52
52
  git remote -v
53
53
  git push origin main
54
- git tag v0.7.6
55
- git push origin v0.7.6
54
+ git tag v0.9.0
55
+ git push origin v0.9.0
56
56
  ```
57
57
 
58
58
  Pi install smoke after git tag, using an isolated Pi agent directory so no local checkout or user `~/.pi` state is involved:
59
59
 
60
60
  ```bash
61
- PI_CODING_AGENT_DIR=$(mktemp -d) pi -e git:github.com/ismailsaleekh/pi-background-tasks@v0.7.6 --offline --no-tools --no-session -p "/jobs"
62
- pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.7.6
61
+ PI_CODING_AGENT_DIR=$(mktemp -d) pi -e git:github.com/ismailsaleekh/pi-background-tasks@v0.9.0 --offline --no-tools --no-session -p "/jobs"
62
+ pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.9.0
63
63
  ```
64
64
 
65
65
  ## pi.dev/packages
package/README.md CHANGED
@@ -2,26 +2,26 @@
2
2
 
3
3
  Claude-Code-like explicit background shell task manager for [Pi](https://pi.dev/).
4
4
 
5
- This package adds named, tracked background shell jobs with durable output files, bounded log reads, kill/timeout safety, task-owned context-window/token/tool-use/model telemetry, explicit Pi-agent telemetry wrapping for tasks marked as agents, a focused footer-dock task manager, `/tasks` fallback UI, and completion notifications that can wake the agent when LLM-launched work finishes. It also ships Fusion: a direct child-Pi five-call synthesis workflow exposed as `/fusion`, `/fusion-models`, and the always-active `fusion_brainstorm` tool. A terminal task status is published only after trailing wrapped-agent telemetry is consumed and final output plus terminal metadata have completed their durability writes.
5
+ This package adds named, tracked background shell jobs with durable output files, bounded log reads, kill/timeout safety, task-owned context-window/token/tool-use/model telemetry, explicit Pi-agent telemetry wrapping for tasks marked as agents, a focused footer-dock task manager, `/tasks` fallback UI, and completion notifications that can wake the agent when LLM-launched work finishes. It also ships Fusion: a direct child-Pi five-call synthesis workflow exposed as `/fusion`, `/fusion-models`, and the always-active `fusion_brainstorm` and `fusion_validate` tools. A terminal task status is published only after trailing wrapped-agent telemetry is consumed and final output plus terminal metadata have completed their durability writes.
6
6
 
7
7
  ## Install
8
8
 
9
9
  From npm after publish:
10
10
 
11
11
  ```bash
12
- pi install npm:pi-background-tasks@0.7.6
12
+ pi install npm:pi-background-tasks@0.9.0
13
13
  ```
14
14
 
15
15
  From git after pushing this package to its standalone repository and tagging:
16
16
 
17
17
  ```bash
18
- pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.7.6
18
+ pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.9.0
19
19
  ```
20
20
 
21
21
  For project-local install:
22
22
 
23
23
  ```bash
24
- pi install -l npm:pi-background-tasks@0.7.6
24
+ pi install -l npm:pi-background-tasks@0.9.0
25
25
  ```
26
26
 
27
27
  ## Commands
@@ -91,11 +91,14 @@ The lookup runs at most once per session on `session_start`, is time-boxed, and
91
91
  ## LLM tools
92
92
 
93
93
  - `bg_run` — start named long-running commands without blocking the conversation.
94
+ - `bg_delegate` — launch one background Pi agent seeded with a frozen projection of the current conversation, then return a launch receipt immediately. See [Delegated background agents](#delegated-background-agents).
95
+ - `bg_result` — retrieve a `bg_delegate` answer, hash-verified before it is returned.
94
96
  - `bg_run_pi_attested` — opt-in structured direct-spawn Pi agent task that emits a strict local attestation sidecar after successful completion.
95
97
  - `bg_status` — inspect one task or all recent tasks.
96
98
  - `bg_logs` — read bounded task output.
97
99
  - `bg_kill` — stop a running task.
98
- - `fusion_brainstorm({prompt})` — always-active tool that runs the Fusion workflow and returns the exact merged text as the tool result for the parent agent to consume, with the exact Pi `Usage` shape attached when the host supports tool-result usage: token fields plus complete `cost.input`, `cost.output`, `cost.cacheRead`, `cost.cacheWrite`, and `cost.total`. Its closed public schema has exactly one required parameter, `prompt`; extra keys are rejected. It has no eligibility, quota, routine, or justification gate. Tool context capture excludes the current assistant tool-call leaf when Pi is executing that `fusion_brainstorm` call, so the nested children do not see the in-progress tool call or sibling calls. Children receive the documented conversation projection described under [Conversation context policy](#conversation-context-policy): visible user/assistant text verbatim, with thinking and tool payloads replaced by explicit hash-accounted omission receipts. Because the prompt is composed by the parent agent, it is treated as authoritative and self-contained.
100
+ - `fusion_validate({prompt})` — always-active tool that runs the same five-model Fusion workflow as a validation review of work that was just completed, and returns the merged prose review. Its closed public schema has exactly one parameter, `prompt`; **it takes no `capability` argument**, and a caller-supplied `capability` is rejected loudly rather than ignored. Candidate reviewers always run with the read-only `inspect` capability, because a reasoning-only reviewer cannot read the code it is judging; the evaluator and merger remain no-tools by stage policy. Findings are classified `critical`, `high`, or `minor`, each with a file/symbol location, the evidence the reviewer actually read, and why it matters. A review with no findings must state what was verified rather than returning an unexplained pass. See [Validation workflow](#validation-workflow).
101
+ - `fusion_brainstorm({prompt, capability?})` — always-active tool that runs the Fusion workflow and returns the exact merged text as the tool result for the parent agent to consume, with the exact Pi `Usage` shape attached when the host supports tool-result usage: token fields plus complete `cost.input`, `cost.output`, `cost.cacheRead`, `cost.cacheWrite`, and `cost.total`. Calling it as `fusion_brainstorm({prompt})` uses the default `reason` capability: no tools and byte-identical child argv/prompt behaviour to the previous release. The optional `capability` accepts only `"reason"`, `"inspect"`, or `"research"`; `inspect` gives candidate children read-only repository inspection tools, and `research` adds the package-owned `fusion_web_fetch` tool for targeted public URL fetches. The evaluator and merger remain no-tools by stage policy. Its closed public schema has one required parameter, `prompt`, plus optional `capability`; extra keys are rejected. It has no eligibility, quota, routine, or justification gate. Tool context capture excludes the current assistant tool-call leaf when Pi is executing that `fusion_brainstorm` call, so the nested children do not see the in-progress tool call or sibling calls. Children receive the documented conversation projection described under [Conversation context policy](#conversation-context-policy): visible user/assistant text verbatim, with thinking and tool payloads replaced by explicit hash-accounted omission receipts. Because the prompt is composed by the parent agent, it is treated as authoritative and self-contained.
99
102
 
100
103
  `bg_run` requires a concise `name` for the footer dock, the shell `command`, and required `isAgent: boolean`. Set `isAgent: true` only when the background task launches an LLM/agent process (for example `pi -p ...` or `pi --mode json ...`); set `isAgent: false` for scripts, tests, dev servers, sleeps, and ordinary shell commands. It defaults both `notifyOnCompletion` and `triggerOnCompletion` to `true`. With those defaults, `bg_run` returns immediately, the agent continues only independent useful work or ends its current turn instead of sleeping or polling, and a durable `background-task-notification` for completed, failed, or killed state automatically starts a follow-up turn. The launch receipt states the effective notification/wake behavior explicitly. `bg_status` and `bg_logs` remain available for user-requested inspection, deliberately disabled completion delivery, concrete hang diagnosis, or reading output after the terminal event; they are not waiting primitives, and the terminal notification does not need status reconfirmation. Setting `triggerOnCompletion: false` keeps the notification but prevents it from starting an agent turn. Setting `notifyOnCompletion: false` suppresses both notification and wake-up even if `triggerOnCompletion` is true.
101
104
 
@@ -104,9 +107,170 @@ Tasks marked with `isAgent: true` that launch print/json child Pi agents through
104
107
  `bg_run_pi_attested` is separate from `bg_run` and never accepts a shell command. It takes structured `provider`, `model`, `prompt`, optional literal extra Pi argv, and a relative `reportPath`; launches exactly one direct `pi --mode json` child; records raw Pi JSON events, separate stderr, exact argv/cwd, prompt/report hashes, observed Pi session/provider/model, and `ModelRegistry.isUsingOAuth` credential class. It forbids direct API-key/auth-file launch arguments and emits no partial attestation: failures remain ordinary failed tasks with no sidecar.
105
108
 
106
109
 
110
+ ## Delegated background agents
111
+
112
+ `bg_delegate` fills the gap between `bg_run` (a background agent with a **fresh,
113
+ empty** context) and `fusion_brainstorm` (your current context, but synchronous
114
+ and five-model). It is one agent, one prompt, seeded with the current session's
115
+ context, non-blocking. `bg_result` retrieves its answer safely. They ship
116
+ together: a delegate without a safe retrieval path could not return its work.
117
+
118
+ ```text
119
+ bg_delegate({ name, prompt }) → launch receipt, immediately
120
+ … the parent keeps working; the terminal notification wakes it …
121
+ bg_result({ taskId }) → hash-verified answer
122
+ ```
123
+
124
+ ### Context seeding
125
+
126
+ The child does **not** share the parent's live session. The parent conversation
127
+ is projected with the same frozen `visible-conversation-ledger-v2` transform
128
+ Fusion uses (see [Conversation context policy](#conversation-context-policy)),
129
+ frozen as an immutable seed, and the child is given its **own** `--session-id`
130
+ and a task-owned `--session-dir`. Conceptually it has your context; physically it
131
+ can never open or mutate the parent session.
132
+
133
+ | Content | Disposition |
134
+ |---|---|
135
+ | User text | included verbatim, never clipped |
136
+ | Assistant text | included verbatim, never clipped |
137
+ | User image blocks | marker only, never raw bytes |
138
+ | Assistant thinking | excluded; recorded as a hash-accounted omission receipt |
139
+ | Tool-call arguments | excluded; recorded as a hash-accounted omission receipt |
140
+ | Tool-result payloads | excluded; recorded as a hash-accounted omission receipt |
141
+ | The in-flight `bg_delegate` call and its sibling calls | scope-excluded from the branch |
142
+
143
+ The assistant message carrying the in-flight call is excluded as a whole, so when
144
+ several `bg_delegate` calls share one assistant message **every** sibling call is
145
+ excluded for **every** child: two delegates launched together receive identical
146
+ projected history and neither can observe the other's arguments.
147
+
148
+ The seed is canonical-JSON, SHA-256'd, and persisted. The persisted bytes are the
149
+ exact bytes the child reads, and the child re-verifies that hash **before its
150
+ first model call**. Repeated construction from the same session is
151
+ byte-identical.
152
+
153
+ **Documented limitation:** facts that exist only inside omitted parent tool
154
+ output are **not** available to the child. The child is told this explicitly and
155
+ instructed to say so plainly rather than guess. Restate any such finding in the
156
+ `prompt`.
157
+
158
+ ### Route pinning
159
+
160
+ The route is pinned at launch — by default the parent's current effective
161
+ provider/model, or an explicit `route {provider, model}`. It is **never**
162
+ substituted, never falls back, and is never retried on a different route. An
163
+ unavailable route or one with no declared context window is a typed refusal
164
+ before anything is created. The child additionally asserts that every assistant
165
+ message it produced came from the pinned route; a mismatch prevents the run from
166
+ committing an answer at all.
167
+
168
+ ### Inspect-only capability boundary
169
+
170
+ v1 supports exactly one capability, `inspect`. The child is launched with
171
+ `--tools read,grep,find,ls,delegate_read_artifact`, `--no-builtin-tools`, an
172
+ explicit `--exclude-tools` denylist, and no ambient extensions, skills, prompt
173
+ templates, themes, or context files. **The boundary is enforced by argv and the
174
+ child's tool registry, not by prompt text.** There is no shell, no network, no
175
+ edit/write, no recursive delegation, and no Fusion from the child. Writable
176
+ profiles are deliberately out of scope.
177
+
178
+ ### Budgets, spilling, and limits
179
+
180
+ Admission is checked **before** the child process, the child session, or the
181
+ artifact directory exists, so a refusal leaves **zero** children and **zero**
182
+ artifacts. Inside the child, every model call is measured before dispatch; a call
183
+ that would exceed the pinned route's allowance is refused and the run terminates
184
+ with a typed `provider_context_budget_exhausted`.
185
+
186
+ A tool result larger than the per-result transcript cap is written **in full** to
187
+ a hashed artifact and replaced in the transcript by an explicit receipt naming
188
+ the artifact, its exact byte count, its SHA-256, and how to read a bounded range.
189
+ The raw payload never enters the transcript and **nothing is truncated**. The
190
+ bounded `delegate_read_artifact` tool returns exactly the requested range or
191
+ fails; a request past end-of-file is refused rather than silently shortened.
192
+ Turn, tool-call, aggregate-output, and wall-clock limits are enforced and
193
+ reported.
194
+
195
+ ### Retrieving the answer
196
+
197
+ The child commits exactly one self-contained result package by temp-write,
198
+ `fsync`, rename, directory `fsync`. **The rename is the commit point**: a package
199
+ present under its final name is complete, and its absence means no answer was
200
+ accepted — whatever the process exit code was. A child that exits `0` without
201
+ committing is a typed `child_exited_without_commit`, never a silent empty
202
+ success. A run that degraded anything latches terminal state and **cannot**
203
+ commit a success package, so a hash-valid answer can never be built on silently
204
+ mutilated context.
205
+
206
+ `bg_result` verifies the package identity, seed hash, route, every per-block
207
+ SHA-256, and the aggregate SHA-256 before returning a single byte, and returns
208
+ bytes from the buffer it verified. A running task returns a typed *not ready*
209
+ result and **never blocks or polls**. An answer over the inline cap degrades to
210
+ an artifact reference **explicitly**; requesting `delivery:"inline"` for it is a
211
+ typed `result_too_large_for_inline` failure naming the artifact. It is **never**
212
+ truncated to fit. `autoDeliver` (`never` | `when_small` | `always`) defaults to
213
+ `never`: completion notifications carry metadata, and the answer is fetched
214
+ deliberately with `bg_result`.
215
+
216
+ ### Failure taxonomy
217
+
218
+ Every delegate failure is typed and states what happened, what was preserved, and
219
+ what the operator can do. Admission codes
220
+ (`delegate_hook_contract_unsupported`, `delegate_isolation_unsupported`,
221
+ `route_unresolved`, `route_capacity_unknown`, `seed_projection_failed`,
222
+ `seed_budget_exceeded`, `seed_persist_failed`, `invalid_arguments`) always report
223
+ `childCreated: false`. Execution and integrity codes include `child_spawn_failed`,
224
+ `child_timeout`, `child_cancelled`, `child_turn_limit`, `child_tool_call_limit`,
225
+ `child_exited_without_commit`, `provider_context_budget_exhausted`,
226
+ `aggregate_tool_output_cap`, `child_result_invalid`,
227
+ `child_result_encoding_invalid`, `route_attestation_missing`, `route_mismatch`,
228
+ `seed_hash_mismatch`, `answer_hash_mismatch`, `artifact_spill_failed`, and
229
+ `artifact_read_failed`. Retrieval states are `result_not_ready`,
230
+ `result_unavailable`, `result_too_large_for_inline`, and `task_unknown`.
231
+
232
+ Usage that the provider did not report is recorded as explicitly `unavailable`,
233
+ never as zero.
234
+
235
+ ### Proven Pi hook contract
236
+
237
+ The child-side guard depends on runtime Pi behaviour, which is **proven by
238
+ execution** rather than read from type declarations. The
239
+ `npm run test:hook-contract` gate drives a real Pi agent loop and records what it
240
+ observed. On Pi 0.83 it establishes that `context` fires once before every model
241
+ call in extension load order and that returned messages reach the provider; that
242
+ **throwing** from a `context` handler does **not** block the call (Pi catches it
243
+ and dispatches anyway); that `ctx.abort()` does not skip the provider call site
244
+ but hands it an already-aborted signal and terminates the run; and that
245
+ `tool_result` fires before the result enters the transcript, chains in load order,
246
+ and preserves tool-call id, role, and error flag across replacement.
247
+
248
+ Because neither a throw nor an abort is a hard admission gate on its own, the
249
+ guard uses abort as the barrier **and** removes the oversized content from the
250
+ outgoing message set, so the request cannot carry it even if a provider ignored
251
+ the aborted signal. If a Pi build cannot provide the required guarantees,
252
+ `bg_delegate` refuses to spawn with a typed
253
+ `delegate_hook_contract_unsupported`; the guard is never weakened to fit.
254
+
255
+ Delegate artifacts are written under:
256
+
257
+ ```text
258
+ .pi/delegate/<session-id>-<pid>/<task-id>/
259
+ ```
260
+
261
+ Each run contains `seed.json`, `child-prompt.txt` (the exact bytes handed to the
262
+ child over stdin, never a shell or positional argument),
263
+ `context-omission-ledger.json`, `budget-plan.json`, `manifest.json`, the
264
+ task-owned `child-session/`, any `spill/` artifacts, `result.json` once the child
265
+ commits, and `outcome.json` once the parent adjudicates the run.
266
+
267
+ `result.json` is written by the **child** and `outcome.json` by the **parent**, so
268
+ neither writer can claim a state it did not observe. `manifest.state` records only
269
+ what the parent knew at launch and is never used to decide success.
270
+
107
271
  ## Fusion workflow
108
272
 
109
- Fusion runs direct child `pi --mode text` processes only; it never calls `pi-ai` completion APIs. Each child is launched with `--no-session`, `--no-tools`, `--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-themes`, and `--no-context-files`, plus the resolved provider/model/thinking level and the package-owned private `extensions/fusion-child.ts` metadata extension. The prompt travels over stdin, not a shell or positional argument.
273
+ Fusion runs direct child `pi --mode text` processes only; it never calls `pi-ai` completion APIs. Each child is launched with `--no-session`, `--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-themes`, and `--no-context-files`, plus the tool policy described below, the resolved provider/model/thinking level, and the package-owned private `extensions/fusion-child.ts` metadata extension. The prompt travels over stdin, not a shell or positional argument.
110
274
 
111
275
  Pi text mode writes the final full answer exactly once instead of serializing cumulative reasoning/partial-message events on every token delta. The private child extension emits one compact, reasoning-free metadata record per finalized assistant message for provider/model, stop reason, the complete Pi token/cost `Usage` object, and response byte/hash validation. Fusion persists those compact records in `*.events.jsonl`; the complete answer remains in the stage response artifact. The 32 MiB child stdout cap therefore applies to one final response, not amplified JSON telemetry. Failed attempts keep the authoritative response artifact empty and, when any stdout was captured, persist it separately as an explicitly incomplete `*.response.partial.*` artifact.
112
276
 
@@ -116,15 +280,71 @@ Model configuration is global under the Pi agent directory:
116
280
  fusion-models.json
117
281
  ```
118
282
 
283
+ ### Validation workflow
284
+
285
+ `fusion_validate` is the same orchestrator, artifact store, budget engine, conversation projection, and evaluation schema as `fusion_brainstorm`, with different stage framing. A workflow profile selects the four system prompts and the capability policy; it never changes the canonical input schema, which remains `pi-background-tasks.fusion-input.v4` for both tools. Canonical input bytes and the omission ledger are provably identical across workflows for the same conversation, and that equality is asserted by the golden-bytes gate rather than assumed.
286
+
287
+ | Concern | `fusion_brainstorm` | `fusion_validate` |
288
+ |---|---|---|
289
+ | Parameters | `{prompt, capability?}` | `{prompt}` — capability rejected |
290
+ | Candidate capability | caller-selected, default `reason` | always `inspect` |
291
+ | Evaluator / merger capability | `reason` by stage policy | `reason` by stage policy |
292
+ | Run id prefix | `f` | `v` |
293
+ | Output | prose answer | prose review |
294
+ | Evaluation schema | `fusion-evaluation.v1` | `fusion-evaluation.v1` (identical) |
295
+
296
+ The capability is fixed rather than defaulted. A `capability` argument on `fusion_validate` is a hard error at the schema boundary, and the orchestrator independently re-asserts the workflow capability before the artifact store or any child process exists, so a contradicting request launches **zero children**.
297
+
298
+ The three reviewers are blind-compared exactly like brainstorm candidates, but the evaluator is additionally required to treat each distinct defect claim as a unit and to carry every surviving claim into `synthesis_plan.must_include`, **including claims raised by only one reviewer**. The merger is correspondingly forbidden from dropping a single-source finding or inventing one no reviewer raised, and must state the resolution and reason wherever reviewers disagreed. Without those two clauses a real defect that only one model noticed could disappear by silent majority vote, which is the failure mode a three-model review exists to prevent.
299
+
300
+ `fusion_validate` is advisory and read-only. It never modifies files, it does not gate anything, and it is not a substitute for running tests or builds. Like every Fusion entry point, facts that exist only inside omitted tool output are not visible to reviewers; `inspect` lets them re-derive repository facts themselves, but uncommitted state that exists only in the parent agent's context must be restated in the prompt.
301
+
302
+ ### Anthropic child sanitization
303
+
304
+ Fusion children launch with `--no-extensions` for isolation, which disables extension *discovery* while still honouring explicit `--extension` paths. The parent session normally loads an Anthropic system-prompt sanitizer through discovery, so a Claude child would inherit nothing and fail at the provider: Pi's own system prompt contains documentation lines Anthropic rejects.
305
+
306
+ Children routed to the `anthropic` provider therefore receive a second explicit extension, [`@ravshansbox/pi-anthropic-sps`](https://github.com/ravshansbox/pi-anthropic-sps) (MIT), resolved from the package's own dependency tree. The metadata extension is always passed first so its `message_end` frame is never displaced.
307
+
308
+ Children on every other provider receive exactly one `--extension` and their argv is byte-identical to the pre-sanitizer form. The sanitizer package publishes no `main`/`exports`, so it is located through its manifest's `pi.extensions[0]` entry rather than a direct require. Every resolution failure - package missing, manifest unreadable or malformed, no declared extension, or a declared file that does not exist - is a loud error before launch, because silently omitting the sanitizer would resurface later as an opaque provider rejection.
309
+
310
+ ### Candidate capabilities
311
+
312
+ Fusion candidate children support three launch-time capability profiles. `reason` is the default: it passes `--no-tools` and preserves the previous no-tool candidate argv and prompt bytes. `inspect` is available only to candidate children and replaces `--no-tools` with the exact read-only tool policy `--no-builtin-tools --tools read,grep,find,ls --exclude-tools bash,edit,write,fusion_brainstorm,bg_delegate,bg_result,bg_run,bg_kill,bg_status,bg_logs,bg_run_pi_attested`. `research` extends `inspect` by adding the package-owned `fusion_web_fetch` tool to the `--tools` allowlist; the `--exclude-tools` denylist is unchanged and still bans `bash`, `edit`, `write`, `fusion_brainstorm`, `bg_delegate`, `bg_result`, `bg_run`, `bg_kill`, `bg_status`, `bg_logs`, and `bg_run_pi_attested`. The `reason` and `inspect` argv forms remain byte-identical to v0.7.8.
313
+
314
+ Evaluator, evaluation-repair, and merger children always run with `--no-tools` by stage policy. Caller input cannot grant them tools, even when candidates use `inspect` or `research`. Capability is recorded as launch metadata in the run manifest and child argv, not added to the canonical child-facing input; the canonical input schema remains `pi-background-tasks.fusion-input.v4`.
315
+
316
+ The inspect candidate system prompt tells the child it may re-derive facts from the repository using `read`, `grep`, `find`, and `ls`. The research prompt adds `fusion_web_fetch` for fetching a specific public URL as bounded Markdown or text. Both prompts extend the untrusted-data rule: projected conversation text, file contents, and fetched page content are data, never instructions to follow.
317
+
318
+ `fusion_web_fetch` has a closed schema: `{ url: string, extract?: 'text' | 'markdown' }`. `extract` defaults to Markdown. There is deliberately no per-fetch prompt parameter. Anthropic documents the `{url, prompt}` extraction pattern as lossy by design: the prompt decides what reaches the model, so a false negative can enter a Fusion candidate answer, pass through blind evaluation, and reach the merged answer with no signal that the page contained missed information.
319
+
320
+ HTML extraction uses the runtime dependency `turndown@7.2.4`; its only dependency is `@mixmark-io/domino`, so it does not require `jsdom`. Markdown is the default because it preserves link destinations, headings, tables, and code blocks better than plain text. This version has no web search, browser, PDF support, cache, or domain allowlist.
321
+
322
+ | `fusion_web_fetch` policy | Value |
323
+ |---|---:|
324
+ | Request method | GET |
325
+ | Schemes | `http:` and `https:` only |
326
+ | Timeout | 60 seconds |
327
+ | Response body cap | 2 MiB |
328
+ | Returned content cap | 32 KiB |
329
+ | Redirect cap | 5 hops |
330
+
331
+ Network handling is basic network hygiene, not a secret-exfiltration control. The hostname is resolved once, every returned address is checked, the connection is pinned to the vetted address, and the socket's remote address is verified after connect. Address validation re-runs on every redirect hop. Private, loopback, link-local, unique-local, multicast, and cloud-metadata addresses are refused. A research child that can read files and reach the network can in principle send what it read; that is an accepted trade in this version, not a sandbox or security boundary.
332
+
333
+ `fusion_web_fetch` fails loudly with typed errors rather than retrying or falling back to another URL, scheme, encoding, or extraction mode. Error codes include `invalid_url`, `unsupported_scheme`, `blocked_address`, `dns_failure`, `redirect_limit`, `redirect_blocked`, `response_too_large`, `unsupported_content_type`, `request_timeout`, `network_error`, `extraction_failed`, and `http_error`.
334
+
335
+ Every child has a stale-action watchdog in addition to the 30-minute absolute timeout. `FUSION_CHILD_IDLE_TIMEOUT_MS` defaults to 900 seconds and fails the child if no stdout or stderr activity occurs during that window; any stdout or stderr activity resets the watchdog. The threshold is deliberately far above tool latency: the child metadata frame is emitted only at `message_end` and text-mode stdout carries only the final assistant message, so one slow model turn is legitimately silent on both streams and must not be killed. The absolute timeout remains a backstop for children that keep producing output but never finish.
336
+
337
+ Inspect and research candidates also write a per-attempt tool-call audit log at `candidate-<slot>.attempt-<n>.tool-calls.jsonl`. The child appends one JSON line per completed tool call with the tool name, argument/result byte counts, and SHA-256 digests only. For `fusion_web_fetch`, the record also includes `url`, `final_url`, `http_status`, `response_bytes`, and `content_sha256`. Raw arguments, raw tool results, and page content are never written because file paths, file contents, fetched content, and tool results may contain secrets. After the child exits, the parent verifies the log is complete and contiguous; a trailing partial line, ordinal gap, duplicate ordinal, or schema-version mismatch is a loud failure.
338
+
119
339
  Missing config means all five slots are `$current`. Malformed config, stale explicit models, unavailable current models, and concurrent selector write conflicts fail loudly before child inference. Selector saves use an inter-process lock plus revision re-read before rename so simultaneous dialogs cannot silently overwrite each other. Candidate identities are anonymized before evaluation; provider/model metadata stays in local artifacts, not in evaluator prompts.
120
340
 
121
341
  Progress is surfaced through `fusion` status updates, TUI cancellable loader UI for `/fusion`, and partial `fusion_brainstorm` tool updates. Session shutdown or reload tracks the whole invocation from entry, aborts live or initializing Fusion runs, and waits for cleanup.
122
342
 
123
343
  ### Conversation context policy
124
344
 
125
- Fusion children receive a **versioned conversation projection**, not a raw execution transcript. The canonical input schema is `pi-background-tasks.fusion-input.v3` and every run states exactly what was included and what was omitted.
345
+ Fusion children receive a **versioned conversation projection**, not a raw execution transcript. The canonical input schema is `pi-background-tasks.fusion-input.v4` and every run states exactly what was included and what was omitted.
126
346
 
127
- The projection transform (`visible-conversation-ledger-v1`) is shared by both entry points:
347
+ The projection transform (`visible-conversation-ledger-v2`) is shared by both entry points:
128
348
 
129
349
  | Content | Disposition |
130
350
  |---|---|
@@ -137,20 +357,21 @@ The projection transform (`visible-conversation-ledger-v1`) is shared by both en
137
357
  | Tool-result images | excluded; recorded as an omission receipt (never raw bytes) |
138
358
  | Active `fusion_brainstorm` call and its sibling calls | scope-excluded from the branch |
139
359
 
140
- Omissions are **explicit, deterministic, and auditable** — never silent. Each omitted event produces a ledger row with its kind, exact byte count, and SHA-256 of the omitted bytes. Contiguous omissions collapse into source-ordered `omitted_activity` receipts. Each receipt is deliberately compact, carrying only what a reading model can act on:
360
+ Omissions are **explicit, deterministic, and auditable** — never silent. Each omitted event produces a ledger row with its kind, exact byte count, and SHA-256 of the omitted bytes. Fusion v4 encodes child-facing projection entries as positional tuples to remove repeated object keys while preserving every role, ordinal, span, byte total, count, and text byte:
141
361
 
142
362
  ```json
143
- {"at":[1,9],"bytes":29019,"counts":{"tool_calls":5,"tool_result_texts":5},"kind":"omitted_activity"}
363
+ ["t","u",0,0,"hello"]
364
+ ["o",[1,9],29019,[0,5,5]]
144
365
  ```
145
366
 
146
- `at` is the inclusive source-ordinal span, `bytes` is the total omitted non-image payload for that run, and a zero-valued count is absent rather than serialized. Per-event hashes, ledger indices, and the per-kind byte map live in `context-omission-ledger.json`, not in the prompt: a child cannot verify a hash of payload it does not hold, so forwarding one only consumed context. That ledger also carries a `projection_map` proving every ledger row is represented by exactly one receipt or image marker, and `accounting.omission_receipt_utf8_bytes` records the exact receipt cost. The complete ledger is persisted as `context-omission-ledger.json`. **No head, tail, or preview of an omitted payload is ever forwarded** (`tool_payload_preview_bytes` is `0`), because an arbitrary prefix is usually irrelevant and can leak secrets or carry tool-output prompt injection. Repeated construction is byte-identical, so hashes are stable.
367
+ Text tuples are `["t", role, sourceOrdinal, blockOrdinal, text]`, where `role` is `"u"` for user or `"a"` for assistant. Omission tuples are `["o", [firstSourceOrdinal, lastSourceOrdinal], bytes, [assistantThinking, toolCalls, toolResultTexts]]`. The span is inclusive, `bytes` is the total omitted non-image payload for that run, and the count tuple order is fixed. Per-event hashes, ledger indices, and per-event byte details live in `context-omission-ledger.json`, not in the prompt: a child cannot verify a hash of payload it does not hold, so forwarding one only consumed context. That ledger also carries a `projection_map` proving every ledger row is represented by exactly one receipt or ledger-only image marker, and `accounting.omission_receipt_utf8_bytes` records the exact compact tuple receipt cost. The complete ledger is persisted as `context-omission-ledger.json`, and its row shape and root hash are unchanged by the compact encoding. **No head, tail, or preview of an omitted payload is ever forwarded** (`tool_payload_preview_bytes` is `0`), because an arbitrary prefix is usually irrelevant and can leak secrets or carry tool-output prompt injection. Repeated construction is byte-identical via canonical JSON, so prompt bytes and hashes are stable.
147
368
 
148
369
  Two entry points share the transform but differ in request authority:
149
370
 
150
371
  | Entry point | Policy id | `request.authority` |
151
372
  |---|---|---|
152
- | `fusion_brainstorm({prompt})` | `fusion-tool-explicit-v1` | `explicit_text` — the prompt is authoritative and self-contained |
153
- | `/fusion [prompt]` | `fusion-command-conversation-v1` | `directive_over_projected_conversation` |
373
+ | `fusion_brainstorm({prompt})` | `fusion-tool-explicit-v2` | `explicit_text` — the prompt is authoritative and self-contained |
374
+ | `/fusion [prompt]` | `fusion-command-conversation-v2` | `directive_over_projected_conversation` |
154
375
 
155
376
  **Documented limitation:** facts that exist only inside omitted tool output are not available to Fusion children. Restate any required finding as visible conversation text, or include it in the `fusion_brainstorm` prompt. Children are instructed to say so plainly rather than guess. No model-generated summarization is used as hidden preprocessing.
156
377
 
@@ -158,7 +379,7 @@ Two entry points share the transform but differ in request authority:
158
379
 
159
380
  Every prompt-expansion stage — candidate, evaluator, evaluation repair, and merger — is size-checked **before any child process is created**, and each stage is checked against **its own configured route**, so a large-context slot cannot hide a small-context sibling and a small slot cannot veto stages it never serves.
160
381
 
161
- The input token bound is `ceil(utf8Bytes / 2)`. This is a ceiling, not an estimate: across 159 real large Fusion prompts the densest observed ratio was 3.552 bytes per input token, so the divisor keeps roughly a 1.7x margin and also bounds dense non-ASCII input.
382
+ Input forecasting uses the shared affine estimator `estimateInputTokens({family, segments})`: additive integer byte-class accounting plus a 512-token affine intercept. Calibrated normal-ASCII rates are used only for backed exact model IDs, measured prompts at or above 50 KiB, and prompts that pass the low-whitespace dense-ASCII gate. That gate records the measured whitespace fraction in `budget-plan.json` and falls back conservatively for out-of-distribution near-zero-whitespace payloads; it is a heuristic token-density proxy, not a bound. Multibyte UTF-8 uses the conservative 2.0 B/tok fatal rate while persisting the provable 1.00 B/tok ceiling as advisory. The calibration basis is 882 real large Fusion prompts: Anthropic observed floor 2.047 B/tok (shipped `r=1.73`) and Codex observed floor 3.400 B/tok (shipped `r=2.89`); unknown providers and unbacked model IDs use the unbacked 1.00 B/tok floor and are surfaced in artifacts and result details.
162
383
 
163
384
  Each stage is forecast with its **real prompt builder**, rendered with empty embedded-output slots, plus the enforced output contracts for whatever that stage will embed:
164
385
 
@@ -177,11 +398,11 @@ When a workflow cannot fit, the error names the **first failing mandatory stage*
177
398
 
178
399
  Remediation is derived, not guessed: Fusion re-plans the entire workflow **with the request removed**. If it still fails, the error says plainly that shortening the request cannot help and points at starting a fresh conversation or raising the route's context window. If it then fits, the request is what determines feasibility, and the error states the exact minimum byte reduction and the maximum safe request size.
179
400
 
180
- Runs that do fit still emit an advisory utilization warning for any stage at or above 80% of its allowance, recorded in `budget-plan.json` and as a progress event, so a session approaching the ceiling is visible before it hard-fails. The warning never alters behaviour.
401
+ Preflight is two-tiered: input-only forecasts are fatal (`prompt_budget_exceeded_forecast`), while worst-case downstream output reservations are warning-only and recorded in `budget-plan.json`. Exact rendered per-stage checks remain fatal (`prompt_budget_exceeded_measured`). Runs that fit still emit advisory warnings for tight utilization or reservation overage. The warning never alters behaviour.
181
402
 
182
403
  Every configured route must also satisfy a documented minimum capacity; smaller routes are rejected at configuration time with an actionable error naming the requirement, rather than being accepted and failing later at the provider. All route capacities, per-stage forecasts, headroom, utilization, the byte composition of the blocking stage, and the blockers list are persisted as `budget-plan.json`.
183
404
 
184
- If an input still exceeds the safe budget, Fusion fails with a typed `prompt_budget_exceeded` error naming the stage, measured bytes, measured token upper bound, allowed tokens, the limiting configured model and its context window, and concrete remediation. **Zero children are launched** when preflight rejects. Provider context-window failures remain loud child failures; there is no hidden local truncation and no silent fallback anywhere in this path.
405
+ If an input still exceeds the safe budget, Fusion fails with `prompt_budget_exceeded_forecast` for input-only preflight or `prompt_budget_exceeded_measured` for exact rendered prompts. The error names the stage, measured bytes, measured token upper bound, allowed tokens, the limiting configured model and its context window, estimator source, and concrete remediation. **Zero children are launched** when preflight rejects. Provider context-window failures remain loud child failures; there is no hidden local truncation and no silent fallback anywhere in this path.
185
406
 
186
407
  ## Extension EventBus API
187
408
 
@@ -225,7 +446,7 @@ Fusion writes private debugging artifacts under:
225
446
  .pi/fusion/<session-id>-<pid>/<run-id>/
226
447
  ```
227
448
 
228
- Each run contains `manifest.json`, `canonical-input.json`, `context-omission-ledger.json`, `budget-plan.json`, candidate/evaluation/merge prompts, raw child JSONL events, stderr, responses, `blind-candidates.json`, `evaluation.json`, `merged.md`, and `error.json` for failed/cancelled runs. Persisted stage prompts are byte-identical to the exact bytes written to that child's stdin. `context-omission-ledger.json` carries the complete source-ordered omission ledger, and `budget-plan.json` records every configured route's capacity plus the pre-candidate feasibility decision, so a rejected run is as auditable as a successful one. Artifact files are written by private temp-file/fsync/rename, and v2 manifests persist cumulative child usage plus per-attempt observed usage/model data for successful, failed, and cancelled child attempts. Every usage record preserves the complete Pi cost breakdown; the same exact shape is cloned into `fusion_brainstorm` tool results so newer Pi hosts can calculate and replay footer/session statistics safely. These artifacts are local evidence only; they are not shown in `/jobs` or the background-task dock.
449
+ Each run contains `manifest.json`, `canonical-input.json`, `context-omission-ledger.json`, `budget-plan.json`, candidate/evaluation/merge prompts, raw child JSONL events, stderr, responses, and, when inspect or research candidates run, tool-call logs named `candidate-<slot>.attempt-<n>.tool-calls.jsonl`, plus `blind-candidates.json`, `evaluation.json`, `merged.md`, and `error.json` for failed/cancelled runs. Persisted stage prompts are byte-identical to the exact bytes written to that child's stdin. `context-omission-ledger.json` carries the complete source-ordered omission ledger, and `budget-plan.json` records every configured route's capacity plus the pre-candidate feasibility decision, so a rejected run is as auditable as a successful one. Artifact files are written by private temp-file/fsync/rename, and v2 manifests persist cumulative child usage plus per-attempt observed usage/model data for successful, failed, and cancelled child attempts. Every usage record preserves the complete Pi cost breakdown; the same exact shape is cloned into `fusion_brainstorm` tool results so newer Pi hosts can calculate and replay footer/session statistics safely. These artifacts are local evidence only; they are not shown in `/jobs` or the background-task dock.
229
450
 
230
451
  For attested Pi tasks only, the task id is `b` plus 32 random hex characters (128 bits) and additional flat siblings are written in the same directory:
231
452
 
package/TESTING.md CHANGED
@@ -24,8 +24,39 @@ npm run test:sdk
24
24
  npm run test:rpc
25
25
  npm run test:component
26
26
  npm run test:package
27
+ npm run test:hook-contract
27
28
  ```
28
29
 
30
+ `npm run test:hook-contract` is the **Pi hook characterisation gate**. It drives a
31
+ real Pi agent loop against a deterministic scripted provider and records what Pi's
32
+ `context` and `tool_result` hooks actually do, because the `bg_delegate` child-side
33
+ guard depends on that behaviour and it must be proven by execution rather than read
34
+ from type declarations.
35
+
36
+ The observed guarantees are written to
37
+ `tests/scripted-provider/pi-hook-contract-evidence.json` and shipped as
38
+ `src/core/delegate/hook-contract-evidence.json`. A package test asserts the two are
39
+ byte-identical, so the runtime gate and the gate that proved it cannot drift apart.
40
+ If the evidence file already exists, the gate **compares** against it rather than
41
+ rewriting it: a change in Pi's hook behaviour fails loudly and forces a deliberate
42
+ re-review of the child guard instead of silently regenerating.
43
+
44
+ On Pi 0.83 the gate establishes, by execution:
45
+
46
+ | Question | Observed |
47
+ |---|---|
48
+ | Does `context` fire before every model call? | yes, once per call, in extension load order |
49
+ | Do messages returned from `context` reach the provider? | yes |
50
+ | Does **throwing** in `context` prevent the provider call? | **no** — Pi catches it and dispatches anyway |
51
+ | Does `ctx.abort()` prevent it? | it does not skip the call site, but the call receives an already-aborted signal and the run terminates |
52
+ | Does `tool_result` fire before the transcript entry, and can a handler replace it? | yes, chained in load order; the replacement reaches the provider and the original does not |
53
+ | Do tool-call id, role, and `isError` survive replacement? | yes |
54
+
55
+ Because neither a throw nor an abort is a hard admission gate on its own, the child
56
+ guard uses abort as the barrier **and** removes the oversized content from the
57
+ outgoing message set. A Pi build that cannot provide the required guarantees causes
58
+ `bg_delegate` to refuse to spawn with a typed `delegate_hook_contract_unsupported`.
59
+
29
60
  Full interactive gate:
30
61
 
31
62
  ```bash
@@ -58,6 +89,69 @@ Current smoke is `tsx scripts/smoke.ts`. It creates a temporary Pi agent/session
58
89
 
59
90
  It performs no inference and spawns no child, so it is safe to run offline and costs nothing. It exits non-zero if any stage would exceed the budget.
60
91
 
92
+ ### Fusion byte-immutability gates
93
+
94
+ Two unit gates protect Fusion's persisted artifact bytes, which are a frozen format:
95
+
96
+ - `tests/unit/fusion-golden-bytes.test.ts` renders an exhaustive 28-case differential
97
+ corpus (empty conversations, run-boundary and image-coalescing branches, unknown
98
+ blocks, tool-name ordering, `compactCounts` combinations, UTF-8 and lone-surrogate
99
+ content, every budget stage across three route sets) and compares the raw bytes
100
+ against `tests/fixtures/fusion-golden-bytes.json`. The golden file is never
101
+ auto-updated once it exists.
102
+ - `tests/unit/fusion-extraction-equivalence.test.ts` compares the current
103
+ implementation against `tests/oracle/fusion-context-pre-extraction.ts`, a verbatim
104
+ copy of the projection engine as it existed before the shared transform was
105
+ extracted. This is an **independent oracle**, so equivalence is proven rather than
106
+ merely self-consistent, including `Object.is` comparison of budget floats and exact
107
+ error-message parity.
108
+
109
+ ### Delegate gates
110
+
111
+ - `tests/unit/delegate-seed.test.ts` — verbatim visible text, thinking/tool-payload
112
+ exclusion, marker-only images, sibling-batch exclusion, byte-identical construction
113
+ across repeated builds and separate processes, and receive-side seed verification.
114
+ - `tests/unit/delegate-budget.test.ts` — reserve arithmetic, boundary accept/reject,
115
+ and the total runtime governor.
116
+ - `tests/unit/delegate-result-package.test.ts` — hash verification, strict base64,
117
+ encoding refusal for lone surrogates, route-mismatch and missing-attestation
118
+ detection, and explicitly unavailable usage.
119
+ - `tests/unit/delegate-artifacts.test.ts` — spill/receipt coordinates under
120
+ out-of-order completion, aggregate caps, exact bounded range reads, and terminal
121
+ evaluation including a zero-exit child that never committed.
122
+ - `tests/unit/delegate-launch.test.ts` — route pinning without substitution, argv-level
123
+ isolation, the hook-contract gate, and the property that a refused launch creates
124
+ **zero** children and **zero** artifacts.
125
+ - `tests/scripted-provider/delegate-child-guard.test.ts` — the child guard inside a
126
+ real Pi agent loop: a 2 MB tool result spilled to a hashed artifact with the payload
127
+ kept out of the transcript, a blocked over-budget model call, exact bounded range
128
+ reads, route-drift refusal, and turn-limit enforcement.
129
+ - `tests/sdk/delegate-sdk.test.ts` — the full public loop through the shipped
130
+ entrypoint with a fake child `pi`: launch receipt, projected context actually
131
+ reaching the child, child session isolation, not-ready retrieval, corruption
132
+ detection, and oversized answers degrading to an artifact reference without
133
+ truncation.
134
+ - `tests/package/delegate-mutation-guard.test.ts` — fails if silent truncation, a
135
+ silent fallback, a route substitution, an unbounded inline answer, a dropped
136
+ preflight, a synthesized zero usage, a fail-open guard hook, or an undelivered
137
+ seed is reintroduced. Verified by actually mutating the source: disabling the
138
+ spill makes two behavioural tests fail.
139
+
140
+ ### Live subscription evidence run
141
+
142
+ `npx tsx scripts/delegate-live-run.ts` is a release-time evidence harness. It
143
+ builds a genuinely large parent session (43 visible text entries plus 120 omitted
144
+ tool events withholding ~162 KB of tool-result payload), launches **one** real
145
+ child `pi` on the parent's current **subscription OAuth** route with no API-key
146
+ argument, and asserts that the child produced a hash-verified answer that used
147
+ **both** its read-only file tools and the projected conversation. It also asserts
148
+ the omitted payload never appears in the seed or the child prompt.
149
+
150
+ It is not part of the default gate because it performs real inference. It caught
151
+ two defects that no offline gate did: a child that verified its seed file but was
152
+ never handed a prompt, and a budget that measured the seed instead of the prompt
153
+ actually sent. Both are now pinned by unit and mutation-guard tests.
154
+
61
155
  Smoke proves loadability only; completion requires `npm run test`, `npm run test:full`, `npm run pack:dry-run`, and the release-only compatibility gate when preparing a release.
62
156
 
63
157
  ## Required isolated environment