pi-background-tasks 0.7.4 → 0.7.7

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.4; 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.7.7; 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.
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.4 --offline --no-tools --no-session -p "/jobs"
41
- pi install npm:pi-background-tasks@0.7.4
40
+ PI_CODING_AGENT_DIR=$(mktemp -d) pi -e npm:pi-background-tasks@0.7.7 --offline --no-tools --no-session -p "/jobs"
41
+ pi install npm:pi-background-tasks@0.7.7
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.4
55
- git push origin v0.7.4
54
+ git tag v0.7.7
55
+ git push origin v0.7.7
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.4 --offline --no-tools --no-session -p "/jobs"
62
- pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.7.4
61
+ PI_CODING_AGENT_DIR=$(mktemp -d) pi -e git:github.com/ismailsaleekh/pi-background-tasks@v0.7.7 --offline --no-tools --no-session -p "/jobs"
62
+ pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.7.7
63
63
  ```
64
64
 
65
65
  ## pi.dev/packages
package/README.md CHANGED
@@ -9,19 +9,19 @@ This package adds named, tracked background shell jobs with durable output files
9
9
  From npm after publish:
10
10
 
11
11
  ```bash
12
- pi install npm:pi-background-tasks@0.7.4
12
+ pi install npm:pi-background-tasks@0.7.7
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.4
18
+ pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.7.7
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.4
24
+ pi install -l npm:pi-background-tasks@0.7.7
25
25
  ```
26
26
 
27
27
  ## Commands
@@ -91,6 +91,8 @@ 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.
@@ -104,6 +106,167 @@ Tasks marked with `isAgent: true` that launch print/json child Pi agents through
104
106
  `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
107
 
106
108
 
109
+ ## Delegated background agents
110
+
111
+ `bg_delegate` fills the gap between `bg_run` (a background agent with a **fresh,
112
+ empty** context) and `fusion_brainstorm` (your current context, but synchronous
113
+ and five-model). It is one agent, one prompt, seeded with the current session's
114
+ context, non-blocking. `bg_result` retrieves its answer safely. They ship
115
+ together: a delegate without a safe retrieval path could not return its work.
116
+
117
+ ```text
118
+ bg_delegate({ name, prompt }) → launch receipt, immediately
119
+ … the parent keeps working; the terminal notification wakes it …
120
+ bg_result({ taskId }) → hash-verified answer
121
+ ```
122
+
123
+ ### Context seeding
124
+
125
+ The child does **not** share the parent's live session. The parent conversation
126
+ is projected with the same frozen `visible-conversation-ledger-v2` transform
127
+ Fusion uses (see [Conversation context policy](#conversation-context-policy)),
128
+ frozen as an immutable seed, and the child is given its **own** `--session-id`
129
+ and a task-owned `--session-dir`. Conceptually it has your context; physically it
130
+ can never open or mutate the parent session.
131
+
132
+ | Content | Disposition |
133
+ |---|---|
134
+ | User text | included verbatim, never clipped |
135
+ | Assistant text | included verbatim, never clipped |
136
+ | User image blocks | marker only, never raw bytes |
137
+ | Assistant thinking | excluded; recorded as a hash-accounted omission receipt |
138
+ | Tool-call arguments | excluded; recorded as a hash-accounted omission receipt |
139
+ | Tool-result payloads | excluded; recorded as a hash-accounted omission receipt |
140
+ | The in-flight `bg_delegate` call and its sibling calls | scope-excluded from the branch |
141
+
142
+ The assistant message carrying the in-flight call is excluded as a whole, so when
143
+ several `bg_delegate` calls share one assistant message **every** sibling call is
144
+ excluded for **every** child: two delegates launched together receive identical
145
+ projected history and neither can observe the other's arguments.
146
+
147
+ The seed is canonical-JSON, SHA-256'd, and persisted. The persisted bytes are the
148
+ exact bytes the child reads, and the child re-verifies that hash **before its
149
+ first model call**. Repeated construction from the same session is
150
+ byte-identical.
151
+
152
+ **Documented limitation:** facts that exist only inside omitted parent tool
153
+ output are **not** available to the child. The child is told this explicitly and
154
+ instructed to say so plainly rather than guess. Restate any such finding in the
155
+ `prompt`.
156
+
157
+ ### Route pinning
158
+
159
+ The route is pinned at launch — by default the parent's current effective
160
+ provider/model, or an explicit `route {provider, model}`. It is **never**
161
+ substituted, never falls back, and is never retried on a different route. An
162
+ unavailable route or one with no declared context window is a typed refusal
163
+ before anything is created. The child additionally asserts that every assistant
164
+ message it produced came from the pinned route; a mismatch prevents the run from
165
+ committing an answer at all.
166
+
167
+ ### Inspect-only capability boundary
168
+
169
+ v1 supports exactly one capability, `inspect`. The child is launched with
170
+ `--tools read,grep,find,ls,delegate_read_artifact`, `--no-builtin-tools`, an
171
+ explicit `--exclude-tools` denylist, and no ambient extensions, skills, prompt
172
+ templates, themes, or context files. **The boundary is enforced by argv and the
173
+ child's tool registry, not by prompt text.** There is no shell, no network, no
174
+ edit/write, no recursive delegation, and no Fusion from the child. Writable
175
+ profiles are deliberately out of scope.
176
+
177
+ ### Budgets, spilling, and limits
178
+
179
+ Admission is checked **before** the child process, the child session, or the
180
+ artifact directory exists, so a refusal leaves **zero** children and **zero**
181
+ artifacts. Inside the child, every model call is measured before dispatch; a call
182
+ that would exceed the pinned route's allowance is refused and the run terminates
183
+ with a typed `provider_context_budget_exhausted`.
184
+
185
+ A tool result larger than the per-result transcript cap is written **in full** to
186
+ a hashed artifact and replaced in the transcript by an explicit receipt naming
187
+ the artifact, its exact byte count, its SHA-256, and how to read a bounded range.
188
+ The raw payload never enters the transcript and **nothing is truncated**. The
189
+ bounded `delegate_read_artifact` tool returns exactly the requested range or
190
+ fails; a request past end-of-file is refused rather than silently shortened.
191
+ Turn, tool-call, aggregate-output, and wall-clock limits are enforced and
192
+ reported.
193
+
194
+ ### Retrieving the answer
195
+
196
+ The child commits exactly one self-contained result package by temp-write,
197
+ `fsync`, rename, directory `fsync`. **The rename is the commit point**: a package
198
+ present under its final name is complete, and its absence means no answer was
199
+ accepted — whatever the process exit code was. A child that exits `0` without
200
+ committing is a typed `child_exited_without_commit`, never a silent empty
201
+ success. A run that degraded anything latches terminal state and **cannot**
202
+ commit a success package, so a hash-valid answer can never be built on silently
203
+ mutilated context.
204
+
205
+ `bg_result` verifies the package identity, seed hash, route, every per-block
206
+ SHA-256, and the aggregate SHA-256 before returning a single byte, and returns
207
+ bytes from the buffer it verified. A running task returns a typed *not ready*
208
+ result and **never blocks or polls**. An answer over the inline cap degrades to
209
+ an artifact reference **explicitly**; requesting `delivery:"inline"` for it is a
210
+ typed `result_too_large_for_inline` failure naming the artifact. It is **never**
211
+ truncated to fit. `autoDeliver` (`never` | `when_small` | `always`) defaults to
212
+ `never`: completion notifications carry metadata, and the answer is fetched
213
+ deliberately with `bg_result`.
214
+
215
+ ### Failure taxonomy
216
+
217
+ Every delegate failure is typed and states what happened, what was preserved, and
218
+ what the operator can do. Admission codes
219
+ (`delegate_hook_contract_unsupported`, `delegate_isolation_unsupported`,
220
+ `route_unresolved`, `route_capacity_unknown`, `seed_projection_failed`,
221
+ `seed_budget_exceeded`, `seed_persist_failed`, `invalid_arguments`) always report
222
+ `childCreated: false`. Execution and integrity codes include `child_spawn_failed`,
223
+ `child_timeout`, `child_cancelled`, `child_turn_limit`, `child_tool_call_limit`,
224
+ `child_exited_without_commit`, `provider_context_budget_exhausted`,
225
+ `aggregate_tool_output_cap`, `child_result_invalid`,
226
+ `child_result_encoding_invalid`, `route_attestation_missing`, `route_mismatch`,
227
+ `seed_hash_mismatch`, `answer_hash_mismatch`, `artifact_spill_failed`, and
228
+ `artifact_read_failed`. Retrieval states are `result_not_ready`,
229
+ `result_unavailable`, `result_too_large_for_inline`, and `task_unknown`.
230
+
231
+ Usage that the provider did not report is recorded as explicitly `unavailable`,
232
+ never as zero.
233
+
234
+ ### Proven Pi hook contract
235
+
236
+ The child-side guard depends on runtime Pi behaviour, which is **proven by
237
+ execution** rather than read from type declarations. The
238
+ `npm run test:hook-contract` gate drives a real Pi agent loop and records what it
239
+ observed. On Pi 0.83 it establishes that `context` fires once before every model
240
+ call in extension load order and that returned messages reach the provider; that
241
+ **throwing** from a `context` handler does **not** block the call (Pi catches it
242
+ and dispatches anyway); that `ctx.abort()` does not skip the provider call site
243
+ but hands it an already-aborted signal and terminates the run; and that
244
+ `tool_result` fires before the result enters the transcript, chains in load order,
245
+ and preserves tool-call id, role, and error flag across replacement.
246
+
247
+ Because neither a throw nor an abort is a hard admission gate on its own, the
248
+ guard uses abort as the barrier **and** removes the oversized content from the
249
+ outgoing message set, so the request cannot carry it even if a provider ignored
250
+ the aborted signal. If a Pi build cannot provide the required guarantees,
251
+ `bg_delegate` refuses to spawn with a typed
252
+ `delegate_hook_contract_unsupported`; the guard is never weakened to fit.
253
+
254
+ Delegate artifacts are written under:
255
+
256
+ ```text
257
+ .pi/delegate/<session-id>-<pid>/<task-id>/
258
+ ```
259
+
260
+ Each run contains `seed.json`, `child-prompt.txt` (the exact bytes handed to the
261
+ child over stdin, never a shell or positional argument),
262
+ `context-omission-ledger.json`, `budget-plan.json`, `manifest.json`, the
263
+ task-owned `child-session/`, any `spill/` artifacts, `result.json` once the child
264
+ commits, and `outcome.json` once the parent adjudicates the run.
265
+
266
+ `result.json` is written by the **child** and `outcome.json` by the **parent**, so
267
+ neither writer can claim a state it did not observe. `manifest.state` records only
268
+ what the parent knew at launch and is never used to decide success.
269
+
107
270
  ## Fusion workflow
108
271
 
109
272
  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.
@@ -122,9 +285,9 @@ Progress is surfaced through `fusion` status updates, TUI cancellable loader UI
122
285
 
123
286
  ### Conversation context policy
124
287
 
125
- Fusion children receive a **versioned conversation projection**, not a raw execution transcript. The canonical input schema is `pi-background-tasks.fusion-input.v2` and every run states exactly what was included and what was omitted.
288
+ 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
289
 
127
- The projection transform (`visible-conversation-ledger-v1`) is shared by both entry points:
290
+ The projection transform (`visible-conversation-ledger-v2`) is shared by both entry points:
128
291
 
129
292
  | Content | Disposition |
130
293
  |---|---|
@@ -137,31 +300,52 @@ The projection transform (`visible-conversation-ledger-v1`) is shared by both en
137
300
  | Tool-result images | excluded; recorded as an omission receipt (never raw bytes) |
138
301
  | Active `fusion_brainstorm` call and its sibling calls | scope-excluded from the branch |
139
302
 
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 carrying counts, byte totals, and a run hash. 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.
303
+ 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:
304
+
305
+ ```json
306
+ ["t","u",0,0,"hello"]
307
+ ["o",[1,9],29019,[0,5,5]]
308
+ ```
309
+
310
+ 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.
141
311
 
142
312
  Two entry points share the transform but differ in request authority:
143
313
 
144
314
  | Entry point | Policy id | `request.authority` |
145
315
  |---|---|---|
146
- | `fusion_brainstorm({prompt})` | `fusion-tool-explicit-v1` | `explicit_text` — the prompt is authoritative and self-contained |
147
- | `/fusion [prompt]` | `fusion-command-conversation-v1` | `directive_over_projected_conversation` |
316
+ | `fusion_brainstorm({prompt})` | `fusion-tool-explicit-v2` | `explicit_text` — the prompt is authoritative and self-contained |
317
+ | `/fusion [prompt]` | `fusion-command-conversation-v2` | `directive_over_projected_conversation` |
148
318
 
149
319
  **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.
150
320
 
151
321
  ### Stage budgets
152
322
 
153
- Every prompt-expansion stage — candidate, evaluator, evaluation repair, and merger — is size-checked **before any child process is created**. Safety is based on the **smallest** configured model's context window, never the largest, so adding one small-context slot cannot be masked by large-context siblings.
323
+ 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.
324
+
325
+ 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.
326
+
327
+ 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:
328
+
329
+ | Stage | Embeds | Mandatory |
330
+ |---|---|---|
331
+ | `candidate` | canonical input only | yes, once per slot |
332
+ | `evaluation` | + 3 candidate answers | yes |
333
+ | `evaluation_repair` | + 3 candidates, invalid evaluator output, diagnostics | conditional, still budgeted |
334
+ | `merge` | + 3 candidates, validated evaluation | yes |
335
+
336
+ `evaluation_repair` only runs when the evaluator returns schema-invalid JSON, but it is budgeted unconditionally: "uncommon" is not a reliability contract.
337
+
338
+ Output sizes are guaranteed by **enforced contracts**, not assumptions. Each stage has a maximum response size measured in *JSON-rendered transfer bytes* — the bytes the response actually costs once a later stage embeds it, escaping included (candidate 48 KiB, evaluator 64 KiB, merger 64 KiB, repair diagnostics 8 KiB). Measuring the rendered form removes any escaping guess: quotes, backslashes, and newlines expand 2x and control characters up to 6x. A response over its contract is a loud `child_output_cap` failure; it is preserved in the run artifacts and is never sliced, truncated, or forwarded.
154
339
 
155
- 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.
340
+ When a workflow cannot fit, the error names the **first failing mandatory stage** in deterministic order (candidate slots, then evaluation, then merge), lists every other blocking stage, and labels conditional ones. It never blames a stage that would have succeeded.
156
341
 
157
- Downstream growth is guaranteed by two cooperating layers rather than by an assumption:
342
+ 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.
158
343
 
159
- 1. **Enforced output contracts.** Each stage has a maximum response size measured in *JSON-rendered transfer bytes* the bytes the response actually costs once a later stage embeds it, escaping included (candidate 48 KiB, evaluator 64 KiB, merger 64 KiB, repair diagnostics 8 KiB). Measuring the rendered form removes any escaping guess: quotes, backslashes, and newlines expand 2x and control characters up to 6x, so a raw-byte contract would not bound the embedded size. A response over its contract is a loud `child_output_cap` failure; it is preserved in the run artifacts and is never sliced, truncated, or forwarded.
160
- 2. **An exact reserve.** The canonical input must leave room for the widest stage — the evaluation repair, which embeds three candidate answers, the invalid evaluator output, and diagnostics — plus fixed wrapper overhead. Because the contracts above are already rendered-byte bounds, this is an exact sum, not a raw size inflated by an estimated factor. The reserve is converted to tokens with the *same* `ceil(bytes / 2)` function used to measure prompts; reserving output *tokens* directly would understate the cost of re-embedding those bytes.
344
+ 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.
161
345
 
162
- Each route additionally reserves its output contract, 4,096 framing, and 4,096 safety tokens. Because every step is uniformly conservative, the policy requires roughly a **168,000-token context window per configured slot**. Smaller routes are rejected at configuration time with an actionable error naming the requirement, rather than being accepted and failing later at the provider. Route capacities and the pre-candidate feasibility decision are persisted as `budget-plan.json`.
346
+ 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`.
163
347
 
164
- 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.
348
+ 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.
165
349
 
166
350
  ## Extension EventBus API
167
351
 
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
@@ -86,7 +180,7 @@ npm run test:rpc
86
180
  npm run test:agent-loop
87
181
  ```
88
182
 
89
- The Fusion SDK/RPC/scripted-provider tests install a deterministic fake child `pi` in a temp `PATH` from `tests/helpers/fusion-fake-pi.ts`. Parent Pi remains the real SDK/RPC runtime; only direct child `pi --mode text` calls with the package-owned private compact metadata extension are intercepted. `PI_CODING_AGENT_DIR` is pointed at the temp agent directory so `fusion-models.json` is never read from the user's real global Pi directory. Fusion context coverage is conversation-projection coverage. `tests/unit/fusion-context-prompts.test.ts` verifies that a synthetic session carrying more than 1 MB of tool arguments/results still yields a small canonical input, that user and assistant text survive verbatim, that thinking and tool payloads never appear (including no head/tail/preview sentinel), that omission counts, byte totals, and hashes are exact and stable, that repeated construction is byte-identical, that the active `fusion_brainstorm` leaf and sibling calls stay scope-excluded, that images remain marker-only or ledger-only with no raw base64 in child prompts, and that every retained source block receives exactly one disposition. `tests/unit/fusion-budget.test.ts` covers stage budgets: the limiting model is the smallest configured route (including when it is the evaluator rather than a candidate), unknown or too-small capacities fail before spawn, boundary prompts pass at exactly the limit and fail one byte past it, the child system prompt counts as input, dense multi-byte UTF-8 cannot bypass byte accounting, and candidate, evaluator, evaluation-repair, and merger expansions are each rejected before their child is spawned with zero partial launches. `tests/package/typebox-compat.test.ts` pins the TypeBox posture and compiles nullable-array schemas. The release-only `npm run test:compat` packs the package, installs exact supported Pi versions, runs `/jobs`, runs `/fusion` through the installed package entrypoint with the fake child Pi, verifies five child invocations, verifies `/fusion-models` rejects non-TUI mode, asserts the resolved `typebox` is Pi's bundled peer rather than a private or nested copy, and scans the installed package bytes for TypeBox APIs removed in the 1.3.x line. It then drives the current host Pi through a real RPC `fusion_brainstorm` parent-agent loop, checks the persisted tool result carries the complete Pi `Usage.cost` object, invokes `get_session_stats` (the same aggregation boundary used by the TUI footer), reopens the durable session, and verifies identical token/cost totals. All parent and child inference remains deterministic and local.
183
+ The Fusion SDK/RPC/scripted-provider tests install a deterministic fake child `pi` in a temp `PATH` from `tests/helpers/fusion-fake-pi.ts`. Parent Pi remains the real SDK/RPC runtime; only direct child `pi --mode text` calls with the package-owned private compact metadata extension are intercepted. `PI_CODING_AGENT_DIR` is pointed at the temp agent directory so `fusion-models.json` is never read from the user's real global Pi directory. Fusion context coverage is conversation-projection coverage. `tests/unit/fusion-context-prompts.test.ts` verifies that a synthetic session carrying more than 1 MB of tool arguments/results still yields a small canonical input, that user and assistant text survive verbatim, that thinking and tool payloads never appear (including no head/tail/preview sentinel), that omission counts, byte totals, and hashes are exact and stable, that repeated construction is byte-identical, that the active `fusion_brainstorm` leaf and sibling calls stay scope-excluded, that images remain marker-only or ledger-only with no raw base64 in child prompts, and that every retained source block receives exactly one disposition. `tests/unit/fusion-high-cardinality.test.ts` covers the receipt-cardinality regression: a session of many short interleaved tool events (rather than a few enormous results) pins per-receipt cost, proves the compact `omitted_activity` fields are the only model-facing ones, reconciles every receipt against the ledger through `projection_map`, and proves the whole workflow fits a real route budget. `tests/unit/fusion-budget.test.ts` covers stage budgets: the limiting model is the smallest configured route (including when it is the evaluator rather than a candidate), unknown or too-small capacities fail before spawn, boundary prompts pass at exactly the limit and fail one byte past it, the child system prompt counts as input, dense multi-byte UTF-8 cannot bypass byte accounting, and candidate, evaluator, evaluation-repair, and merger expansions are each rejected before their child is spawned with zero partial launches. `tests/package/typebox-compat.test.ts` pins the TypeBox posture and compiles nullable-array schemas. The release-only `npm run test:compat` packs the package, installs exact supported Pi versions, runs `/jobs`, runs `/fusion` through the installed package entrypoint with the fake child Pi, verifies five child invocations, verifies `/fusion-models` rejects non-TUI mode, asserts the resolved `typebox` is Pi's bundled peer rather than a private or nested copy, and scans the installed package bytes for TypeBox APIs removed in the 1.3.x line. It then drives the current host Pi through a real RPC `fusion_brainstorm` parent-agent loop, checks the persisted tool result carries the complete Pi `Usage.cost` object, invokes `get_session_stats` (the same aggregation boundary used by the TUI footer), reopens the durable session, and verifies identical token/cost totals. All parent and child inference remains deterministic and local.
90
184
 
91
185
  ## Coverage summary
92
186
 
package/TEST_PLAN.md CHANGED
@@ -13,7 +13,7 @@ This package follows:
13
13
  | Package | `pi-background-tasks` |
14
14
  | Extension entrypoint | `extensions/background-tasks.ts` |
15
15
  | Public commands | `/bg`, `/jobs`, `/logs`, `/kill`, `/tasks`, `/bg-tasks`, `/bg-clear`, `/bg-update`, `/fusion`, `/fusion-models` |
16
- | Public tools | `bg_run`, `bg_run_pi_attested`, `bg_status`, `bg_logs`, `bg_kill`, `fusion_brainstorm` |
16
+ | Public tools | `bg_run`, `bg_delegate`, `bg_result`, `bg_run_pi_attested`, `bg_status`, `bg_logs`, `bg_kill`, `fusion_brainstorm` |
17
17
  | Extension EventBus API | `pi-background-tasks:request:v1`, `pi-background-tasks:response:v1`, `pi-background-tasks:terminal:v1`; schemas exported from `src/core/extension-api.ts` |
18
18
  | Shortcuts | `Shift+Down`; optional fallback `Ctrl+Alt+C` |
19
19
  | Custom UI | footer status + focused bottom dock overlay |
@@ -32,9 +32,11 @@ This package follows:
32
32
  | Package | `npm run test:package` | yes | implemented |
33
33
  | PTY/TUI | `npm run test:pty` | full gate | implemented (answers pi's Kitty keyboard-protocol negotiation; auto-skips with a loud reason on hosts that cannot deliver raw-mode Node stdin via `/usr/bin/expect`) |
34
34
  | Scripted provider | `npm run test:agent-loop` | full gate | implemented |
35
+ | Hook contract | `npm run test:hook-contract` | yes | implemented; drives a real Pi agent loop and compares observed `context`/`tool_result` behaviour against committed evidence. Fails loudly if Pi's hook semantics change rather than regenerating them |
35
36
  | Pack dry run | `npm run pack:dry-run` | release gate | implemented |
36
37
  | Smoke | `npm run smoke` | no | implemented; isolated load-only |
37
38
  | Large-context smoke | `npm run smoke:large-context` | release gate | implemented; rebuilds the production failure byte composition, proves the pre-fix input is rejected and the post-fix projection fits all four stages against the smallest configured route, with no inference and no child spawn |
39
+ | Live subscription evidence | `npx tsx scripts/delegate-live-run.ts` | release gate | implemented; one real subscription-OAuth child on the parent's current route, large seeded session, hash-verified answer, and explicit leak checks. Caught two defects no offline gate did (undelivered prompt, seed-not-prompt budgeting), both now pinned by unit and mutation tests |
38
40
  | Compatibility | `npm run test:compat` | release gate | implemented; exact Pi `0.75.5`, `0.81.1`, `0.82.1`, `0.83.0` pack/install plus `/jobs`, `/fusion`, and `/fusion-models` surfaces, per-version bundled-TypeBox peer verification, removed-TypeBox-API scan of installed package bytes, followed by a current-host real `fusion_brainstorm` session-stat and replay witness |
39
41
 
40
42
  ## Feature coverage matrix
@@ -53,9 +55,17 @@ This package follows:
53
55
  | Stop task from LLM tool | `bg_kill` | | yes | | | | | | Covers running kill and already-finished loud failure. |
54
56
  | Fusion command direct result | `/fusion`, `fusion-result` custom message | yes | yes | yes | | yes | yes | | Core unit covers deterministic v2 conversation-projection construction, explicit image omission markers without raw image data, direct final-text child argv/stdin/compact-metadata parsing, exact multi-block answer reconstruction, artifacts, pre-abort handling, and orchestration. SDK runs real `/fusion` against a fake child `pi`, verifies exactly five child text-mode invocations, hidden prompt persistence, visible exact merged custom message, no parent assistant rewrite, renderer output, progress/status behavior, no-argument editor flow, editor cancellation, and malformed config causing zero children. RPC verifies command discovery, `/fusion <prompt>` with U+2028/U+2029 content, custom-message result emission, no parent `agent_start`, no-argument editor protocol, malformed config/child failure visibility, and child isolation flags. PTY verifies `/fusion` renders the exact merged answer directly in a real TUI. Compatibility smoke runs `/fusion` through every supported Pi version with the installed package entrypoint. |
55
57
  | Fusion tool result | `fusion_brainstorm({prompt})` | yes | yes | | | current-host stats/replay | yes | yes | Tool is registered at load and re-added on `session_start`; no eligibility/quota/routine/justification gates are implemented. BUG-182 unit/SDK coverage pins the exact host `Usage` contract, complete cost-component aggregation, rejection of legacy `costTotal`, v2 child/result/manifest schemas, exact merged text, progress, context exclusion, and final details. The release compatibility gate drives a real current-host RPC agent through the tool, verifies persisted tool usage, invokes the same session-stat path used by the footer, then reopens the durable session and verifies identical stats. Scripted-provider coverage proves normal parent consumption and evaluator schema repair. |
56
- | Fusion conversation projection | canonical input `fusion-input.v2`, `context-omission-ledger.json` | yes | yes | yes | | | yes | | Unit covers a >1 MB synthetic tool-heavy session staying within budget, verbatim user/assistant text, thinking exclusion, zero tool-payload preview bytes (head/tail sentinels absent), exact and stable omission counts/byte totals/hashes, byte-identical repeated construction, hash change on omitted-payload mutation without exposing the payload, contiguous omission-run collapsing with dense source-ordered ledger indices, active-tool-call-leaf and sibling-call exclusion, marker-only user images plus ledger-only tool-result images with no base64, per-entry-point policy ids and request authority, and the exactly-one-disposition property for every retained block. SDK verifies the exact child stdin carries `conversation_projection` and never `conversation_transcript`. RPC verifies the v2 request object. Package tests assert the ledger artifact ships in the documented artifact set. |
57
- | Fusion stage budgets | `budget-plan.json`, typed `prompt_budget_exceeded` | yes | | | | | | | Unit covers the conservative bytes-per-token bound, smallest-configured-route limiting selection (candidate or evaluator), rejection of unknown/zero/negative/too-small context windows, the reserved canonical-input share, boundary accept at exactly the limit and reject one byte past, the child system prompt counted as input, multi-byte UTF-8 byte accounting, pre-spawn rejection with zero children for candidate, evaluator, evaluation-repair, and merger expansion, safe prompts completing all five calls, persisted route/plan snapshots including negative slack on rejection, and the reproduced 1 MB failure shape now fitting the smallest configured budget. Errors carry stage, measured size, allowed size, limiting model, and remediation in both structured detail and message text. |
58
+ | Fusion conversation projection | canonical input `fusion-input.v4`, `context-omission-ledger.json` | yes | yes | yes | | | yes | | Unit covers a >1 MB synthetic tool-heavy session staying within budget, verbatim user/assistant text, thinking exclusion, zero tool-payload preview bytes (head/tail sentinels absent), exact and stable omission counts/byte totals/hashes, tuple round-trip preservation of roles/source ordinals/block ordinals/exact text, byte-identical repeated construction and separate-process determinism, hash change on omitted-payload mutation without exposing the payload, ledger root hash unchanged by compact encoding, contiguous omission-run collapsing into compact `["o", span, bytes, [thinking, toolCalls, toolResults]]` tuples, material canonical-input byte reduction versus the verbose object encoding, receipt-to-ledger reconciliation through `projection_map`, active-tool-call-leaf and sibling-call exclusion, marker-only user images plus ledger-only tool-result images with no base64, per-entry-point policy ids and request authority, and the exactly-one-disposition property for every retained block. SDK verifies the exact child stdin carries `conversation_projection` and never `conversation_transcript`. RPC verifies the versioned request object. A dedicated high-cardinality fixture (340 receipt runs over 1,360 omitted events with incident-matching visible-text volume) pins per-receipt cost and proves the pre-fix verbose format would overflow the merge stage while the compact tuple format fits. Package tests assert the ledger artifact ships in the documented artifact set. |
59
+ | Fusion stage budgets | `budget-plan.json` (v3 per-stage forecasts), typed `prompt_budget_exceeded_forecast` / `prompt_budget_exceeded_measured` | yes | | | | | | | Unit covers the per-family affine estimator, additive segment accounting, multibyte 1.0-token/byte charging, unknown-provider floor visibility, byte-capacity route selection, scope guards for small windows, input-only fatal preflight versus warning-only reservations, rejection of unknown/zero/negative/too-small context windows, boundary accept at exactly the limit and reject one byte past, the child system prompt counted as input, per-stage forecasts built from the real prompt builders against each stage's own route, reservation warnings, breach-detector artifacts, safe prompts completing all five calls, persisted route/plan snapshots including negative slack on fatal rejection, and the reproduced 1 MB failure shape now fitting the smallest configured budget. Errors carry stage, measured size, allowed size, limiting model, estimator source, and remediation in both structured detail and message text. |
58
60
  | Pi/TypeBox compatibility | `peerDependencies`, packed bytes | yes | | | | | yes | | Unit pins typebox as a `"*"` peer that is neither a runtime nor bundled dependency, requires the resolved TypeBox to be Pi 0.83's 1.3.x line, requires all four supported Pi/TUI lines in the peer range, scans all package TypeScript for the seven removed TypeBox APIs, and compiles the shipped tool schema plus nullable-array/nullable-string and optional-field shapes under TypeBox 1.3. The release compat gate repeats the removed-API scan against installed package bytes per Pi version. |
61
+ | Pi hook contract | `npm run test:hook-contract`, `tests/scripted-provider/pi-hook-contract-evidence.json`, `src/core/delegate/hook-contract-evidence.json` | | | | | | evidence parity | yes | Executes a real Pi agent loop and records, rather than assumes: `context` fires once before every model call in load order; returned messages reach the provider; **throwing** in `context` does NOT block dispatch (Pi catches and continues); `ctx.abort()` does not skip the call site but delivers an already-aborted signal and terminates the run; `tool_result` fires before the transcript entry, chains in load order, replacement reaches the provider while the original does not, and toolCallId/role/`isError` survive. Handler ordering across two separate probe extensions is pinned. The evidence file is compared, never silently regenerated, and a package test asserts the shipped copy is byte-identical. |
62
+ | Delegate context seeding | `bg_delegate` seed `pi-background-tasks.delegate-seed.v1`, `seed.json`, `context-omission-ledger.json` | yes | yes | | | | mutation guard | yes | Unit covers verbatim visible user/assistant text, complete exclusion of thinking/tool-call arguments/tool-result payloads, marker-only images with no raw bytes anywhere in the seed, exclusion of the in-flight `bg_delegate` call **and every sibling call in the same assistant message** (two delegates launched together get byte-identical projected history), the prompt preserved exactly and marked `explicit_text` authority, zero payload-preview bytes, byte-identical construction across repeated builds and across separate processes via a fixed-leaf fixture, and refusal of a blank prompt. Receive-side verification rejects a single mutated byte, a foreign task identity, a structurally malformed seed whose hash matches, a directive whose hash disagrees with its text, and an unsupported capability. SDK proves the projected parent text actually reaches the child. |
63
+ | Delegate route pinning | `bg_delegate` `route`, `route_attestations` | yes | yes | | | | mutation guard | yes | Unit covers defaulting to the parent current model, explicit pinning, refusal of an unavailable route with `route_unresolved` and no substitution, refusal of a route with no declared context window with `route_capacity_unknown` rather than assuming one, and refusal when neither is available. The result package rejects a mismatched route, a mismatched attestation, and a package carrying no attestation at all. The scripted-provider gate proves an observed route drift prevents a success commit entirely. The mutation guard fails if a fallback list or first-available selection appears. |
64
+ | Delegate budget and guard | `budget-plan.json`, typed `seed_budget_exceeded` / `provider_context_budget_exhausted` | yes | yes | | | | mutation guard | yes | Unit covers reserve subtraction, exact-boundary accept and one-byte-past reject, the child system prompt counted as input, multi-byte UTF-8 measured by bytes, refusal of unusable windows without defaulting, and a total runtime governor that cannot throw from inside a hook. Preflight rejection is proven to create **zero** children and **zero** artifacts for hook-contract, budget, and blank-prompt refusals, with the artifact directory verified absent. The scripted-provider gate proves an over-budget model call is blocked in a real agent loop and produces a typed terminal record with no committed result. |
65
+ | Delegate tool-result spilling | `spill/` artifacts, `pi-background-tasks.delegate-receipt.v1`, `delegate_read_artifact` | yes | yes | | | | mutation guard | yes | Unit proves a 2 MB payload is written in full, hashed, and represented by a receipt carrying `(turn_sequence, source_call_index, tool_call_id)` assigned before execution, so parallel spills completing out of order cannot be mis-associated; an aggregate-cap refusal writes nothing and emits no receipt; bounded range reads return exactly the requested bytes; a range past end-of-file, a path escaping the artifact directory, and non-positive lengths are refused rather than shortened. The scripted-provider gate proves in a real agent loop that the raw payload never enters the transcript, the transcript instead carries a receipt naming the hash and stating nothing was truncated, and an over-long range read fails loudly. |
66
+ | Delegate result commit and `bg_result` | `result.json` (`pi-background-tasks.delegate-result.v1`), `bg_result` | yes | yes | | | | mutation guard | yes | The single atomically-committed package is the sole answer data plane: temp-write, fsync, rename, directory fsync, with the rename as the commit point. Unit covers round-trip verification, multi-block concatenation, exact preservation of U+2028/U+2029/emoji/unnormalized sequences, refusal of lone surrogates rather than U+FFFD substitution, detection of block-hash, aggregate-hash, declared-length, and non-strict-base64 corruption, foreign identity/seed rejection, and explicitly unavailable usage that is never reported as zero. Terminal evaluation treats a zero-exit child with no committed package as typed `child_exited_without_commit`, reports the child-recorded reason when present, and classifies a killed task as cancelled. SDK covers not-ready (non-blocking), completed, corrupted, route-drift, and unknown-task paths, and proves an oversized answer degrades to an artifact reference while an explicit inline request fails with `result_too_large_for_inline` and is never truncated. |
67
+ | Delegate isolation boundary | child argv, `--session-id`, `--session-dir`, `--tools` | yes | yes | | | | mutation guard | yes | Unit proves the child receives its own session id and a task-owned session directory, no `--continue`/`--resume`/`--session`/`--fork`, only the inspect tool set, an explicit denylist covering `bash`/`edit`/`write`/`bg_delegate`/`fusion_brainstorm`, disabled discovery of extensions/skills/prompt templates/themes/context files, exactly one explicitly loaded package guard extension, explicit provider/model with no `--api-key`, and parent session identity stripped from the child environment. SDK verifies the same facts from the argv and environment the child actually observed. |
68
+ | Fusion artifact byte immutability | `canonical-input.json`, `context-omission-ledger.json`, `budget-plan.json` | yes | | | | | shared-source guard | | A 28-case differential corpus is rendered to raw bytes and compared against a committed golden file that is never auto-updated once present, and separately against `tests/oracle/fusion-context-pre-extraction.ts`, a verbatim pre-extraction copy of the projection engine used as an **independent oracle**. Equivalence covers canonical input, ledger, and budget-plan bytes, `Object.is` comparison of budget floats including `utilization`, field-for-field accounting parity, ledger root hashes, and identical error type and message for unknown blocks and blank requests. The package guard now scans the shared `context/` modules, not only the Fusion facade, and pins both the shared divisor definition and Fusion's binding to it. |
59
69
  | Fusion model selector | `/fusion-models`, `fusion-models.json`, `FusionModelSelector` | yes | yes | yes | yes | yes | yes | | Unit covers strict config parsing, duplicates, slash-containing model IDs, stale model failures, `$current`, atomic save, inter-process lock/revision compare-and-swap, and deterministic concurrent-save conflict. Component covers all five slots, duplicate selection, searchable model list, stale display, reset/save/cancel, persistence errors, and width safety. SDK drives the real command in a synthetic TUI context without `ctx.mode` to cover old Pi compatibility and verifies duplicate `$current`/explicit model persistence; SDK also verifies headless no-UI rejection. RPC verifies non-TUI notification without hanging, PTY verifies the real selector opens in a TUI, and compatibility smoke verifies print-mode rejection for every supported Pi version. |
60
70
  | Fusion child isolation/lifecycle | child `pi --mode text`, private compact metadata extension, shutdown cleanup, `.pi/fusion` artifacts | yes | yes | yes | | current-host stats/replay | yes | | BUG-180 coverage proves final-text transport, reasoning exclusion, strict response reconstruction, unchanged caps, isolation, process cleanup, failure artifacts, and lifecycle semantics. BUG-182 upgrades the private compact metadata and artifact manifest to v2, preserves all token and cost components, centralizes clone/add/empty operations, rejects missing/legacy/unknown/non-finite cost shapes, and proves successful plus failed/cancelled attempt costs aggregate exactly once. SDK/RPC verify public child isolation; session shutdown tracks initializing and live runs. |
61
71
  | Extension request/response service | `pi-background-tasks:request:v1` → `pi-background-tasks:response:v1` | yes | yes | | | | yes | | Unit covers closed-frame validation, capability handshake, unknown keys, unknown operation, duplicate request IDs, missing `session_start`, shutdown refusal, strict `run.payload`, strict malformed frames, and unsubscribe. SDK loads the real extension with a shared `createEventBus()`, starts `printf api-ok`, reads bounded logs, lists status, starts and kills a real sleep task, and checks malformed/unknown/duplicate controls without model/provider calls. Package tests assert `src/core/extension-api.ts` ships. |
@@ -104,7 +114,14 @@ Lane A residual hardening is now covered by automated tests. No remaining harden
104
114
 
105
115
  ## Acceptance checklist
106
116
 
107
- - [x] `npm run test` passes offline in isolated temp dirs.
117
+ - [x] `npm run test` passes offline in isolated temp dirs, including the Pi hook characterisation gate.
118
+ - [x] Pi hook behaviour relied on by the delegate child guard is proven by execution and committed as evidence, not inferred from type declarations.
119
+ - [x] Fusion's persisted artifact bytes are proven unchanged against an independent pre-extraction oracle.
120
+ - [x] Delegate preflight refusal is proven to create zero child processes and zero artifacts.
121
+ - [x] No silent truncation, silent fallback, route substitution, unbounded inline answer, synthesized zero usage, fail-open guard hook, or undelivered seed exists on the delegate path, enforced by a mutation guard.
122
+ - [x] A guard-hook exception fails closed: it latches terminal state and suppresses content instead of letting the original message set reach the provider.
123
+ - [x] An incomplete response (`length`, non-`stop` stop reason) or a whitespace-only answer is never committed as a complete result, because a valid hash proves integrity but not completeness.
124
+ - [x] A real subscription-only run demonstrates the whole loop end to end, including that the child used both its read-only tools and the projected conversation.
108
125
  - [x] `npm run test:full` validates baseline real TUI/PTY behavior.
109
126
  - [x] `npm run pack:dry-run` passes.
110
127
  - [x] README claims and all plausible edge cases are exhaustively mapped in this test plan, including Fusion command/tool/model-selector public surfaces and the text-only image omission limitation.
@@ -0,0 +1 @@
1
+ export { default } from '../src/delegate-child-extension.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background-tasks",
3
- "version": "0.7.4",
3
+ "version": "0.7.7",
4
4
  "description": "Pi extension package for background shell tasks plus five-model fusion brainstorming: bg_run tools, /bg commands, /fusion, fusion_brainstorm, model selector UI, bounded logs, kill/timeout safety, and completion wakeups.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -45,7 +45,7 @@
45
45
  "test:rpc": "tsx --test tests/rpc/**/*.test.ts",
46
46
  "test:component": "tsx --test tests/component/**/*.test.ts",
47
47
  "test:package": "tsx --test tests/package/**/*.test.ts",
48
- "test": "npm run typecheck && npm run test:type-safety && npm run test:unit && npm run test:sdk && npm run test:rpc && npm run test:component && npm run test:package",
48
+ "test": "npm run typecheck && npm run test:type-safety && npm run test:unit && npm run test:sdk && npm run test:rpc && npm run test:component && npm run test:package && npm run test:hook-contract",
49
49
  "test:pty": "tsx --test tests/pty/**/*.test.ts",
50
50
  "test:windows": "tsx --test tests/windows/**/*.test.ts",
51
51
  "test:agent-loop": "tsx --test --test-concurrency=1 tests/scripted-provider/**/*.test.ts",
@@ -55,7 +55,8 @@
55
55
  "pack:dry-run": "npm pack --dry-run",
56
56
  "lint": "cd ../.. && npm run quality:ts:lint",
57
57
  "format:check": "cd ../.. && npm run quality:ts:format",
58
- "test:compat": "tsx scripts/test-compat.ts"
58
+ "test:compat": "tsx scripts/test-compat.ts",
59
+ "test:hook-contract": "tsx --test --test-concurrency=1 tests/scripted-provider/pi-hook-contract.test.ts"
59
60
  },
60
61
  "pi": {
61
62
  "extensions": [