pi-background-tasks 2.1.4 → 2.4.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.
Files changed (45) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/PUBLISHING.md +2 -0
  3. package/README.md +16 -8
  4. package/TESTING.md +23 -14
  5. package/TEST_PLAN.md +13 -12
  6. package/THIRD_PARTY_NOTICES.md +30 -0
  7. package/docs/INDEX.md +8 -4
  8. package/docs/choose-a-workflow.md +5 -2
  9. package/docs/commands/claude-cache.md +50 -0
  10. package/docs/concepts/context-projection-and-budgeting.md +4 -2
  11. package/docs/getting-started.md +3 -0
  12. package/docs/manifest.json +86 -22
  13. package/docs/operations/configuration.md +15 -1
  14. package/docs/operations/releasing.md +6 -3
  15. package/docs/operations/troubleshooting.md +3 -1
  16. package/docs/read-before-edit.md +4 -1
  17. package/docs/reference/runtime-contracts.md +53 -53
  18. package/docs/subsystems/anthropic-attribution.md +63 -0
  19. package/docs/subsystems/attested-pi-runs.md +2 -2
  20. package/docs/subsystems/child-launch-durability-and-safety.md +3 -3
  21. package/docs/subsystems/delegation.md +34 -17
  22. package/docs/subsystems/docs-freshness-gate.md +6 -6
  23. package/docs/subsystems/fusion.md +2 -2
  24. package/docs/tools/bg_delegate.md +33 -16
  25. package/docs/tools/bg_result.md +2 -2
  26. package/docs/tools/bg_run.md +5 -0
  27. package/extensions/anthropic-attribution.ts +1 -0
  28. package/package.json +4 -2
  29. package/src/core/anthropic-attribution-path.ts +26 -0
  30. package/src/core/{fusion/anthropic-attribution.ts → anthropic-attribution.ts} +61 -8
  31. package/src/core/attested-pi-run.ts +10 -1
  32. package/src/core/common.ts +2 -1
  33. package/src/core/context/token-budget.ts +16 -3
  34. package/src/core/delegate/artifacts.ts +18 -12
  35. package/src/core/delegate/budget.ts +78 -33
  36. package/src/core/delegate/launch.ts +48 -16
  37. package/src/core/delegate/result-package.ts +16 -0
  38. package/src/core/delegate/runner.ts +45 -2
  39. package/src/core/delegate/seed.ts +12 -0
  40. package/src/core/delegate/types.ts +22 -3
  41. package/src/core/fusion/config.ts +1 -1
  42. package/src/core/fusion/pi-child.ts +7 -124
  43. package/src/core/registry.ts +4 -1
  44. package/src/delegate-child-extension.ts +377 -71
  45. package/src/delegate-extension.ts +58 -6
@@ -17,6 +17,7 @@ import {
17
17
  DELEGATE_TOOL_NAME,
18
18
  DelegateError,
19
19
  type DelegateCapability,
20
+ type DelegateExtensionMode,
20
21
  type DelegateLimits,
21
22
  type DelegatePinnedRoute,
22
23
  type DelegateRoute,
@@ -87,7 +88,7 @@ export function resolveDelegateRoute(input: DelegateRouteResolutionInput): Deleg
87
88
  childCreated: false,
88
89
  remediation: [
89
90
  'Name a provider/model pair that appears in the current model registry.',
90
- 'Omit the route argument to use the parent session\'s current model.',
91
+ "Omit the route argument to use the parent session's current model.",
91
92
  ],
92
93
  },
93
94
  );
@@ -239,12 +240,23 @@ export function delegateToolsFor(capability: DelegateCapability): readonly strin
239
240
  });
240
241
  }
241
242
 
243
+ function assertDelegateExtensionMode(mode: DelegateExtensionMode): void {
244
+ if (mode === 'isolated' || mode === 'ambient') return;
245
+ throw new DelegateError(`bg_delegate extension mode ${String(mode)} is not supported`, {
246
+ code: 'invalid_arguments',
247
+ childCreated: false,
248
+ remediation: ['Use extensionMode "isolated" or "ambient".'],
249
+ });
250
+ }
251
+
242
252
  export interface DelegateChildArgvInput {
243
253
  route: DelegatePinnedRoute;
244
254
  capability: DelegateCapability;
255
+ extensionMode: DelegateExtensionMode;
245
256
  childSessionId: string;
246
257
  childSessionDir: string;
247
258
  childExtensionPath: string;
259
+ attributionExtensionPath?: string | undefined;
248
260
  systemPrompt: string;
249
261
  }
250
262
 
@@ -252,11 +264,15 @@ export interface DelegateChildArgvInput {
252
264
  * Build the child argv.
253
265
  *
254
266
  * The child gets its own `--session-id` and a task-owned `--session-dir`, so it
255
- * is structurally incapable of opening or mutating the parent session. Discovery
256
- * of ambient extensions, skills, prompt templates, themes, and context files is
257
- * disabled; only the package's own guard extension is loaded explicitly.
267
+ * is structurally incapable of opening or mutating the parent session. Skills,
268
+ * prompt templates, themes, and context files are always disabled. Extension
269
+ * discovery is disabled in isolated mode and deliberately enabled in ambient
270
+ * mode; ambient extensions execute arbitrary code and are not sandboxed by the
271
+ * model-visible tool allowlist. The package guard is always explicit; Anthropic
272
+ * routes first load the package attribution/sanitization extension.
258
273
  */
259
274
  export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[] {
275
+ assertDelegateExtensionMode(input.extensionMode);
260
276
  const tools = delegateToolsFor(input.capability);
261
277
  for (const forbidden of DELEGATE_FORBIDDEN_TOOLS) {
262
278
  if (tools.includes(forbidden)) {
@@ -266,6 +282,20 @@ export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[]
266
282
  );
267
283
  }
268
284
  }
285
+ const extensionPaths =
286
+ input.route.provider === 'anthropic'
287
+ ? [
288
+ input.attributionExtensionPath ??
289
+ (() => {
290
+ throw new DelegateError(
291
+ 'Anthropic delegate launch requires the package attribution extension',
292
+ { code: 'delegate_isolation_unsupported', childCreated: false },
293
+ );
294
+ })(),
295
+ input.childExtensionPath,
296
+ ]
297
+ : [input.childExtensionPath];
298
+
269
299
  return [
270
300
  '--mode',
271
301
  'text',
@@ -279,13 +309,12 @@ export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[]
279
309
  tools.join(','),
280
310
  '--exclude-tools',
281
311
  DELEGATE_FORBIDDEN_TOOLS.join(','),
282
- '--no-extensions',
312
+ ...(input.extensionMode === 'isolated' ? ['--no-extensions'] : []),
283
313
  '--no-skills',
284
314
  '--no-prompt-templates',
285
315
  '--no-themes',
286
316
  '--no-context-files',
287
- '--extension',
288
- input.childExtensionPath,
317
+ ...extensionPaths.flatMap((path) => ['--extension', path]),
289
318
  '--provider',
290
319
  input.route.provider,
291
320
  '--model',
@@ -351,7 +380,9 @@ export function buildDelegateChildSystemPrompt(seedPathHint: string): string {
351
380
  '',
352
381
  'You are inspect-only. You can read, search, and list files. You cannot run shell commands, edit or write files, reach the network, or start further delegates. Do not claim to have done so.',
353
382
  '',
354
- 'If a tool result is replaced by a spill receipt, the complete bytes are on disk and nothing was truncated. Use delegate_read_artifact with an exact offset and length when you genuinely need them.',
383
+ 'If a tool result is replaced by a spill receipt, the complete encoded content is on disk and nothing was truncated. Use delegate_read_artifact with an exact offset and length when you genuinely need lossless base64 bytes, then interpret them using the receipt content_format.',
384
+ '',
385
+ 'The child controls retained context by spilling tool results before they consume protected final-answer runway. A spill is not a failure. If a finalization-runway notice appears, all investigation tools are finished: stop investigating and answer immediately from the evidence already gathered.',
355
386
  '',
356
387
  'Finish with a single, direct, self-contained answer to the directive. Your final assistant message is the answer that will be returned to the parent.',
357
388
  ].join('\n');
@@ -362,6 +393,7 @@ export interface DelegatePreflightInput {
362
393
  toolCallId: string | undefined;
363
394
  prompt: string;
364
395
  capability: DelegateCapability;
396
+ extensionMode: DelegateExtensionMode;
365
397
  route: DelegatePinnedRoute;
366
398
  limitOverrides: DelegateLimitOverrides;
367
399
  hookEvidence: DelegateHookContractEvidence;
@@ -411,10 +443,9 @@ export function buildDelegateChildPrompt(seedSerialized: string, directive: stri
411
443
  * one of these can refuse, and none of them has created a process, a session, or
412
444
  * an artifact by the time it does.
413
445
  */
414
- export function preflightDelegateLaunch(
415
- input: DelegatePreflightInput,
416
- ): DelegatePreflightResult {
446
+ export function preflightDelegateLaunch(input: DelegatePreflightInput): DelegatePreflightResult {
417
447
  assertDelegateHookContract(input.hookEvidence);
448
+ assertDelegateExtensionMode(input.extensionMode);
418
449
  // Validates the capability and proves the tool set contains nothing forbidden.
419
450
  delegateToolsFor(input.capability);
420
451
  const limits = resolveDelegateLimits(input.route, input.limitOverrides);
@@ -427,16 +458,19 @@ export function preflightDelegateLaunch(
427
458
  toolCallId: input.toolCallId,
428
459
  directive: input.prompt,
429
460
  capability: input.capability,
461
+ extensionMode: input.extensionMode,
430
462
  route: input.route,
431
463
  limits,
432
464
  });
433
- const childSystemPrompt = buildDelegateChildSystemPrompt('the task seed in your first user message');
465
+ const childSystemPrompt = buildDelegateChildSystemPrompt(
466
+ 'the task seed in your first user message',
467
+ );
434
468
  const childPrompt = buildDelegateChildPrompt(seed.serialized, seed.seed.directive.text);
435
469
  const plan = planDelegateAdmission({
436
470
  route: input.route,
437
471
  // The seed reaches the child inside its prompt, so the admission forecast
438
472
  // must measure the prompt that is actually sent, not the seed alone.
439
- seedSerialized: childPrompt,
473
+ childPrompt,
440
474
  childSystemPrompt,
441
475
  limits,
442
476
  });
@@ -454,9 +488,7 @@ export function preflightDelegateLaunch(
454
488
  }
455
489
 
456
490
  /** Task-owned child session directory. Never the parent's session directory. */
457
- export async function ensureDelegateChildSessionDir(
458
- artifactDirAbs: string,
459
- ): Promise<string> {
491
+ export async function ensureDelegateChildSessionDir(artifactDirAbs: string): Promise<string> {
460
492
  const dir = join(artifactDirAbs, 'child-session');
461
493
  await mkdir(dir, { recursive: true, mode: 0o700 });
462
494
  return dir;
@@ -247,6 +247,21 @@ function parseAttestations(
247
247
  });
248
248
  }
249
249
 
250
+ function parseSpillContentFormat(
251
+ value: unknown,
252
+ taskId: string,
253
+ ): DelegateSpillReceipt['content_format'] {
254
+ if (value === undefined) return undefined;
255
+ if (
256
+ value !== 'single_text_utf8' &&
257
+ value !== 'tool_result_content_json_v1' &&
258
+ value !== 'opaque_bytes'
259
+ ) {
260
+ fail('delegate spill receipt content_format is invalid', 'child_result_invalid', taskId);
261
+ }
262
+ return value;
263
+ }
264
+
250
265
  function parseSpillReceipts(value: unknown, taskId: string): readonly DelegateSpillReceipt[] {
251
266
  if (!Array.isArray(value))
252
267
  fail('delegate result package spilled_artifacts must be an array', 'child_result_invalid', taskId);
@@ -264,6 +279,7 @@ function parseSpillReceipts(value: unknown, taskId: string): readonly DelegateSp
264
279
  source_call_index: requireInteger(entry, 'source_call_index', taskId),
265
280
  byte_length: requireInteger(entry, 'byte_length', taskId),
266
281
  sha256: requireSha256(entry, 'sha256', taskId),
282
+ content_format: parseSpillContentFormat(entry['content_format'], taskId),
267
283
  };
268
284
  });
269
285
  }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { canonicalJson } from '../attested-pi-run.js';
4
4
  import { replaceFileDurable } from '../durable-fs.js';
5
+ import { resolveAnthropicAttributionExtensionPath } from '../anthropic-attribution-path.js';
5
6
  import { join } from 'node:path';
6
7
  import { DelegateArtifactStore, discardDelegateArtifactRoot } from './artifacts.js';
7
8
  import {
@@ -20,6 +21,7 @@ import { verifyDelegateResultPackage, type VerifiedDelegateResult } from './resu
20
21
  import {
21
22
  DelegateError,
22
23
  type DelegateAutoDeliverMode,
24
+ type DelegateExtensionMode,
23
25
  type DelegateResultPackageV1,
24
26
  } from './types.js';
25
27
  import type { DelegateTaskFacts, DelegateTaskOutcome } from '../common.js';
@@ -48,7 +50,9 @@ export interface PrepareDelegateLaunchInput extends DelegatePreflightInput {
48
50
  cwd: string;
49
51
  sessionId: string | undefined;
50
52
  autoDeliver: DelegateAutoDeliverMode;
53
+ extensionMode: DelegateExtensionMode;
51
54
  childExtensionPath?: string | undefined;
55
+ attributionExtensionPath?: string | undefined;
52
56
  env?: NodeJS.ProcessEnv | undefined;
53
57
  now?: (() => Date) | undefined;
54
58
  }
@@ -68,6 +72,22 @@ export async function prepareDelegateLaunch(
68
72
  // its child guard must refuse rather than spawn an unguarded child.
69
73
  const childExtensionPath =
70
74
  input.childExtensionPath ?? resolveDelegateChildExtensionPath();
75
+ let attributionExtensionPath: string | undefined;
76
+ if (input.route.provider === 'anthropic') {
77
+ try {
78
+ attributionExtensionPath =
79
+ input.attributionExtensionPath ?? resolveAnthropicAttributionExtensionPath();
80
+ } catch (error) {
81
+ throw new DelegateError(
82
+ `Anthropic delegate attribution extension could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
83
+ {
84
+ code: 'delegate_isolation_unsupported',
85
+ childCreated: false,
86
+ remediation: ['Reinstall the package; Anthropic delegates require attribution.'],
87
+ },
88
+ );
89
+ }
90
+ }
71
91
 
72
92
  const preflight = preflightDelegateLaunch(input);
73
93
 
@@ -78,6 +98,7 @@ export async function prepareDelegateLaunch(
78
98
  sessionId: input.sessionId,
79
99
  childSessionId: preflight.childSessionId,
80
100
  childSessionDir: '',
101
+ extensionMode: input.extensionMode,
81
102
  route: input.route,
82
103
  limits: preflight.limits,
83
104
  seedSha256: preflight.seed.sha256,
@@ -107,9 +128,11 @@ export async function prepareDelegateLaunch(
107
128
  const argv = buildDelegateChildArgv({
108
129
  route: input.route,
109
130
  capability: input.capability,
131
+ extensionMode: input.extensionMode,
110
132
  childSessionId: preflight.childSessionId,
111
133
  childSessionDir: childSessionDirAbs,
112
134
  childExtensionPath,
135
+ attributionExtensionPath,
113
136
  systemPrompt: preflight.childSystemPrompt,
114
137
  });
115
138
  const env = delegateChildEnv(
@@ -137,7 +160,9 @@ export async function prepareDelegateLaunch(
137
160
  budget: {
138
161
  family: preflight.plan.route.family,
139
162
  rate_source: preflight.plan.route.rate_source,
163
+ conservative_rate_source: preflight.plan.conservative_estimate.rateSource,
140
164
  },
165
+ extensionMode: input.extensionMode,
141
166
  autoDeliver: input.autoDeliver,
142
167
  };
143
168
  const stdinBytes = Buffer.from(preflight.childPrompt, 'utf8');
@@ -176,6 +201,9 @@ export interface EvaluateDelegateTerminalInput {
176
201
  /** Terminal status observed by the background task registry. */
177
202
  taskStatus: 'completed' | 'failed' | 'killed';
178
203
  taskError: string | undefined;
204
+ /** Real merged child output owned by the background-task registry. */
205
+ taskOutputPath?: string | undefined;
206
+ taskOutputAbsPath?: string | undefined;
179
207
  }
180
208
 
181
209
  /**
@@ -227,6 +255,21 @@ async function adjudicateDelegateTerminal(
227
255
  recorded?.message ??
228
256
  input.taskError ??
229
257
  'the delegate child exited without committing a result package';
258
+ const preserved = ['seed.json', 'budget-plan.json', 'child-terminal.json', 'runtime-budget.json']
259
+ .filter((name) => existsSync(join(input.artifactDirAbs, name)));
260
+ if (
261
+ input.taskOutputPath !== undefined &&
262
+ input.taskOutputAbsPath !== undefined &&
263
+ existsSync(input.taskOutputAbsPath)
264
+ ) {
265
+ preserved.push(input.taskOutputPath);
266
+ }
267
+ const diagnosticTargets = preserved.filter(
268
+ (name) => name === 'child-terminal.json' || name === 'runtime-budget.json' || name === input.taskOutputPath,
269
+ );
270
+ const diagnostic = diagnosticTargets.length === 0
271
+ ? 'No child terminal record or merged task output exists; inspect the preserved launch artifacts listed above.'
272
+ : `Inspect the preserved diagnostic evidence: ${diagnosticTargets.join(', ')}.`;
230
273
  const error = new DelegateError(
231
274
  `bg_delegate produced no committed answer: ${detail}`,
232
275
  {
@@ -234,9 +277,9 @@ async function adjudicateDelegateTerminal(
234
277
  childCreated: true,
235
278
  taskId: input.taskId,
236
279
  artifactDir: input.artifactDirAbs,
237
- preserved: ['seed.json', 'budget-plan.json', 'child.stdout.txt', 'child.stderr.txt'],
280
+ preserved,
238
281
  remediation: [
239
- 'Inspect child.stderr.txt and child-terminal.json in the artifact directory.',
282
+ diagnostic,
240
283
  'No partial answer is returned; nothing was truncated to look like success.',
241
284
  ],
242
285
  },
@@ -22,6 +22,7 @@ import {
22
22
  type DelegateContextPolicyDescriptor,
23
23
  type DelegateConversationProjection,
24
24
  type DelegateLedgerV1,
25
+ type DelegateExtensionMode,
25
26
  type DelegateLimits,
26
27
  type DelegatePinnedRoute,
27
28
  type DelegateSeedV1,
@@ -34,6 +35,7 @@ export interface BuildDelegateSeedOptions {
34
35
  toolCallId: string | undefined;
35
36
  directive: string;
36
37
  capability: DelegateCapability;
38
+ extensionMode: DelegateExtensionMode;
37
39
  route: DelegatePinnedRoute;
38
40
  limits: DelegateLimits;
39
41
  }
@@ -167,6 +169,7 @@ export function buildDelegateSeed(
167
169
  launch_nonce: options.launchNonce,
168
170
  cwd: ctx.cwd,
169
171
  capability: options.capability,
172
+ extension_mode: options.extensionMode,
170
173
  route: options.route,
171
174
  parent_system_prompt: ctx.getSystemPrompt(),
172
175
  parent_leaf_id: snapshot.leafId,
@@ -296,6 +299,14 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
296
299
  taskId,
297
300
  });
298
301
  }
302
+ const extensionMode = requireString(record, 'extension_mode', taskId);
303
+ if (extensionMode !== 'isolated' && extensionMode !== 'ambient') {
304
+ throw new DelegateError(`delegate seed extension mode ${extensionMode} is not recognised`, {
305
+ code: 'seed_hash_mismatch',
306
+ childCreated: true,
307
+ taskId,
308
+ });
309
+ }
299
310
  const routeRecord = requireRecord(record, 'route', taskId);
300
311
  const routeOrigin = requireString(routeRecord, 'origin', taskId);
301
312
  if (routeOrigin !== 'parent_current' && routeOrigin !== 'explicit') {
@@ -378,6 +389,7 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
378
389
  launch_nonce: requireString(record, 'launch_nonce', taskId),
379
390
  cwd: requireString(record, 'cwd', taskId),
380
391
  capability,
392
+ extension_mode: extensionMode,
381
393
  route,
382
394
  parent_system_prompt: requireString(record, 'parent_system_prompt', taskId),
383
395
  parent_leaf_id: parentLeafId,
@@ -12,14 +12,14 @@ import type {
12
12
  ProjectionEntry,
13
13
  } from '../context/visible-conversation-v2.js';
14
14
 
15
- export const DELEGATE_SEED_SCHEMA_VERSION = 'pi-background-tasks.delegate-seed.v1' as const;
15
+ export const DELEGATE_SEED_SCHEMA_VERSION = 'pi-background-tasks.delegate-seed.v2' as const;
16
16
  export const DELEGATE_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.delegate-ledger.v1' as const;
17
17
  export const DELEGATE_RESULT_PACKAGE_SCHEMA_VERSION =
18
18
  'pi-background-tasks.delegate-result.v1' as const;
19
19
  export const DELEGATE_RECEIPT_SCHEMA_VERSION = 'pi-background-tasks.delegate-receipt.v1' as const;
20
20
  export const DELEGATE_BUDGET_PLAN_SCHEMA_VERSION =
21
- 'pi-background-tasks.delegate-budget-plan.v2' as const;
22
- export const DELEGATE_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.delegate-manifest.v1' as const;
21
+ 'pi-background-tasks.delegate-budget-plan.v3' as const;
22
+ export const DELEGATE_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.delegate-manifest.v2' as const;
23
23
 
24
24
  /**
25
25
  * Delegate's own context policy id. It shares the frozen
@@ -35,6 +35,13 @@ export const DELEGATE_RESULT_TOOL_NAME = 'bg_result';
35
35
  export const DELEGATE_CAPABILITIES = ['inspect'] as const;
36
36
  export type DelegateCapability = (typeof DELEGATE_CAPABILITIES)[number];
37
37
 
38
+ /**
39
+ * Controls only Pi's ambient extension discovery for delegate children.
40
+ * Tool and project-resource restrictions remain independently enforced.
41
+ */
42
+ export const DELEGATE_EXTENSION_MODES = ['isolated', 'ambient'] as const;
43
+ export type DelegateExtensionMode = (typeof DELEGATE_EXTENSION_MODES)[number];
44
+
38
45
  export const DELEGATE_AUTO_DELIVER_MODES = ['never', 'when_small', 'always'] as const;
39
46
  export type DelegateAutoDeliverMode = (typeof DELEGATE_AUTO_DELIVER_MODES)[number];
40
47
 
@@ -57,6 +64,7 @@ export interface DelegatePinnedRoute extends DelegateRoute {
57
64
  export interface DelegateBudgetRouteSource {
58
65
  family: TokenBudgetFamily;
59
66
  rate_source: TokenBudgetRateSource;
67
+ conservative_rate_source?: TokenBudgetRateSource | undefined;
60
68
  }
61
69
 
62
70
  export interface DelegateContextPolicyDescriptor {
@@ -110,6 +118,7 @@ export interface DelegateSeedV1 {
110
118
  launch_nonce: string;
111
119
  cwd: string;
112
120
  capability: DelegateCapability;
121
+ extension_mode: DelegateExtensionMode;
113
122
  route: DelegatePinnedRoute;
114
123
  parent_system_prompt: string;
115
124
  parent_leaf_id: string | null;
@@ -184,6 +193,11 @@ export interface DelegateResultPackageV1 {
184
193
  spilled_artifacts: readonly DelegateSpillReceipt[];
185
194
  }
186
195
 
196
+ export type DelegateSpillContentFormat =
197
+ | 'single_text_utf8'
198
+ | 'tool_result_content_json_v1'
199
+ | 'opaque_bytes';
200
+
187
201
  export interface DelegateSpillReceipt {
188
202
  schema_version: typeof DELEGATE_RECEIPT_SCHEMA_VERSION;
189
203
  artifact: string;
@@ -193,6 +207,11 @@ export interface DelegateSpillReceipt {
193
207
  source_call_index: number;
194
208
  byte_length: number;
195
209
  sha256: string;
210
+ /**
211
+ * Encoding of the hashed artifact bytes. Optional only for compatibility
212
+ * with v1 receipts written before content formats were recorded.
213
+ */
214
+ content_format?: DelegateSpillContentFormat | undefined;
196
215
  }
197
216
 
198
217
  export const DELEGATE_ERROR_CODES = [
@@ -5,7 +5,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
5
  import type { Api, Model } from '@earendil-works/pi-ai';
6
6
  import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
7
7
  import { replaceFileDurable } from '../durable-fs.js';
8
- import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from './anthropic-attribution.js';
8
+ import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from '../anthropic-attribution.js';
9
9
  import {
10
10
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
11
11
  FusionError,
@@ -1,8 +1,7 @@
1
1
  import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { constants, existsSync, readFileSync } from 'node:fs';
3
+ import { constants, existsSync } from 'node:fs';
4
4
  import { open } from 'node:fs/promises';
5
- import { createRequire } from 'node:module';
6
5
  import { dirname, resolve } from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
8
7
  import {
@@ -73,6 +72,7 @@ import {
73
72
  resolvePiLaunch,
74
73
  type PiLaunchDependencies,
75
74
  } from '../pi-launch.js';
75
+ import { resolveAnthropicAttributionExtensionPath } from '../anthropic-attribution-path.js';
76
76
 
77
77
  // The response cap now applies to one final full answer, not cumulative Pi JSON events.
78
78
  export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
@@ -274,21 +274,6 @@ export function fusionPiChildEnv(
274
274
  return out;
275
275
  }
276
276
 
277
- export function resolveFusionAnthropicAttributionExtensionPath(
278
- moduleUrl = import.meta.url,
279
- pathExists: (path: string) => boolean = existsSync,
280
- ): string {
281
- const modulePath = fileURLToPath(moduleUrl);
282
- const extension = modulePath.endsWith('.ts')
283
- ? 'anthropic-attribution.ts'
284
- : 'anthropic-attribution.js';
285
- const candidate = resolve(dirname(modulePath), extension);
286
- if (!pathExists(candidate)) {
287
- throw new Error(`Fusion Anthropic attribution extension is missing: ${candidate}`);
288
- }
289
- return candidate;
290
- }
291
-
292
277
  export function resolveFusionChildExtensionPath(
293
278
  moduleUrl = import.meta.url,
294
279
  pathExists: (path: string) => boolean = existsSync,
@@ -303,109 +288,10 @@ export function resolveFusionChildExtensionPath(
303
288
  }
304
289
 
305
290
  /**
306
- * Provider whose children require the Anthropic system-prompt sanitizer.
307
- *
308
- * Pi's own system prompt contains documentation lines that Anthropic rejects, so a
309
- * Claude child launched without the sanitizer fails at the provider rather than
310
- * producing an answer. The parent session loads the sanitizer through ordinary
311
- * extension discovery, but Fusion children run with `--no-extensions` for
312
- * isolation and therefore inherit nothing; the sanitizer must be re-supplied
313
- * explicitly per child.
291
+ * Provider whose isolated children require the package-owned attribution and
292
+ * exact-match system-prompt sanitization extension.
314
293
  */
315
294
  export const FUSION_SANITIZED_PROVIDER = 'anthropic';
316
- export const FUSION_ANTHROPIC_SANITIZER_PACKAGE = '@ravshansbox/pi-anthropic-sps';
317
- const FUSION_ANTHROPIC_SANITIZER_MANIFEST = `${FUSION_ANTHROPIC_SANITIZER_PACKAGE}/package.json`;
318
-
319
- export interface FusionSanitizerDependencies {
320
- resolvePackageJson?: ((specifier: string) => string) | undefined;
321
- readManifest?: ((path: string) => string) | undefined;
322
- pathExists?: ((path: string) => boolean) | undefined;
323
- }
324
-
325
- function manifestExtensionEntry(manifestText: string, manifestPath: string): string {
326
- let parsed: unknown;
327
- try {
328
- parsed = parseJsonText(manifestText);
329
- } catch (error) {
330
- throw new FusionError(
331
- `Anthropic sanitizer manifest ${manifestPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
332
- { code: 'orchestration_failed', childCreated: false },
333
- );
334
- }
335
- if (!isJsonObject(parsed)) {
336
- throw new FusionError(`Anthropic sanitizer manifest ${manifestPath} must be an object`, {
337
- code: 'orchestration_failed',
338
- childCreated: false,
339
- });
340
- }
341
- const pi = parsed['pi'];
342
- if (!isJsonObject(pi)) {
343
- throw new FusionError(
344
- `Anthropic sanitizer manifest ${manifestPath} has no "pi" section declaring its extension`,
345
- { code: 'orchestration_failed', childCreated: false },
346
- );
347
- }
348
- const extensions = pi['extensions'];
349
- if (!Array.isArray(extensions) || extensions.length === 0) {
350
- throw new FusionError(
351
- `Anthropic sanitizer manifest ${manifestPath} declares no pi.extensions entries`,
352
- { code: 'orchestration_failed', childCreated: false },
353
- );
354
- }
355
- const [entry] = extensions;
356
- if (typeof entry !== 'string' || entry.trim().length === 0) {
357
- throw new FusionError(
358
- `Anthropic sanitizer manifest ${manifestPath} pi.extensions[0] must be a non-blank string`,
359
- { code: 'orchestration_failed', childCreated: false },
360
- );
361
- }
362
- return entry;
363
- }
364
-
365
- /**
366
- * Resolve the sanitizer extension file shipped by the sanitizer package.
367
- *
368
- * The package intentionally publishes no `main`/`exports`, so the entry cannot be
369
- * required directly; its manifest is resolved and the declared `pi.extensions[0]`
370
- * path is joined against the package root. Every failure is loud: a Claude child
371
- * launched without the sanitizer would fail at the provider with a far less
372
- * actionable error, so silently omitting it is never correct.
373
- */
374
- export function resolveAnthropicSanitizerExtensionPath(
375
- dependencies: FusionSanitizerDependencies = {},
376
- ): string {
377
- const resolvePackageJson =
378
- dependencies.resolvePackageJson ?? createRequire(import.meta.url).resolve;
379
- const readManifest = dependencies.readManifest ?? ((path: string) => readFileSync(path, 'utf8'));
380
- const pathExists = dependencies.pathExists ?? existsSync;
381
- let manifestPath: string;
382
- try {
383
- manifestPath = resolvePackageJson(FUSION_ANTHROPIC_SANITIZER_MANIFEST);
384
- } catch (error) {
385
- throw new FusionError(
386
- `Anthropic sanitizer package ${FUSION_ANTHROPIC_SANITIZER_PACKAGE} could not be resolved: ${error instanceof Error ? error.message : String(error)}. Claude children cannot be launched without it.`,
387
- { code: 'orchestration_failed', childCreated: false },
388
- );
389
- }
390
- let manifestText: string;
391
- try {
392
- manifestText = readManifest(manifestPath);
393
- } catch (error) {
394
- throw new FusionError(
395
- `Anthropic sanitizer manifest ${manifestPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
396
- { code: 'orchestration_failed', childCreated: false },
397
- );
398
- }
399
- const entry = manifestExtensionEntry(manifestText, manifestPath);
400
- const extensionPath = resolve(dirname(manifestPath), entry);
401
- if (!pathExists(extensionPath)) {
402
- throw new FusionError(
403
- `Anthropic sanitizer extension is missing: ${extensionPath} (declared by ${manifestPath})`,
404
- { code: 'orchestration_failed', childCreated: false },
405
- );
406
- }
407
- return extensionPath;
408
- }
409
295
 
410
296
  export function assertFusionToolPolicyDisjoint(
411
297
  allowlist: readonly string[] = FUSION_INSPECT_TOOLS,
@@ -468,11 +354,10 @@ function fusionToolArgv(capability: FusionCapability): string[] {
468
354
  export function fusionChildExtensionPaths(
469
355
  model: ResolvedFusionModel,
470
356
  childExtensionPath: string,
471
- resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
472
- resolveAttribution: () => string = resolveFusionAnthropicAttributionExtensionPath,
357
+ resolveAttribution: () => string = resolveAnthropicAttributionExtensionPath,
473
358
  ): readonly string[] {
474
359
  if (model.provider !== FUSION_SANITIZED_PROVIDER) return [childExtensionPath];
475
- return [resolveAttribution(), resolveSanitizer(), childExtensionPath];
360
+ return [resolveAttribution(), childExtensionPath];
476
361
  }
477
362
 
478
363
  export function buildFusionPiChildArgv(
@@ -480,13 +365,11 @@ export function buildFusionPiChildArgv(
480
365
  systemPrompt: string,
481
366
  childExtensionPath = resolveFusionChildExtensionPath(),
482
367
  capability: FusionCapability = FUSION_NO_TOOLS_CAPABILITY,
483
- resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
484
- resolveAttribution: () => string = resolveFusionAnthropicAttributionExtensionPath,
368
+ resolveAttribution: () => string = resolveAnthropicAttributionExtensionPath,
485
369
  ): string[] {
486
370
  const extensionArgs = fusionChildExtensionPaths(
487
371
  model,
488
372
  childExtensionPath,
489
- resolveSanitizer,
490
373
  resolveAttribution,
491
374
  ).flatMap((path) => ['--extension', path]);
492
375
  return [
@@ -58,6 +58,7 @@ import {
58
58
  resolvePiLaunch,
59
59
  type PiLaunchSpec,
60
60
  } from './pi-launch.js';
61
+ import { resolveAnthropicAttributionExtensionPath } from './anthropic-attribution-path.js';
61
62
  import {
62
63
  runWindowsTaskkill,
63
64
  type TaskkillOutcome,
@@ -1234,7 +1235,9 @@ export class BackgroundTaskRegistry {
1234
1235
  if (this.shuttingDown)
1235
1236
  throw new Error('Cannot start an attested Pi task while Pi is shutting down');
1236
1237
 
1237
- const argv = buildAttestedPiArgv(request);
1238
+ const attributionExtensionPath =
1239
+ request.provider === 'anthropic' ? resolveAnthropicAttributionExtensionPath() : undefined;
1240
+ const argv = buildAttestedPiArgv(request, attributionExtensionPath);
1238
1241
  const attestedPiLaunch = resolvePiLaunch({ platform: this.platform });
1239
1242
  assertWindowsCommandLineWithinLimit(
1240
1243
  attestedPiLaunch,