pi-background-tasks 2.1.4 → 2.3.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 (40) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/PUBLISHING.md +2 -0
  3. package/README.md +15 -8
  4. package/TESTING.md +15 -10
  5. package/TEST_PLAN.md +10 -9
  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/getting-started.md +3 -0
  11. package/docs/manifest.json +84 -19
  12. package/docs/operations/configuration.md +15 -1
  13. package/docs/operations/releasing.md +6 -3
  14. package/docs/read-before-edit.md +4 -1
  15. package/docs/reference/runtime-contracts.md +52 -52
  16. package/docs/subsystems/anthropic-attribution.md +63 -0
  17. package/docs/subsystems/attested-pi-runs.md +2 -2
  18. package/docs/subsystems/child-launch-durability-and-safety.md +3 -3
  19. package/docs/subsystems/delegation.md +12 -4
  20. package/docs/subsystems/docs-freshness-gate.md +5 -5
  21. package/docs/subsystems/fusion.md +2 -2
  22. package/docs/tools/bg_delegate.md +21 -9
  23. package/docs/tools/bg_result.md +1 -1
  24. package/docs/tools/bg_run.md +5 -0
  25. package/extensions/anthropic-attribution.ts +1 -0
  26. package/package.json +4 -2
  27. package/src/core/anthropic-attribution-path.ts +26 -0
  28. package/src/core/{fusion/anthropic-attribution.ts → anthropic-attribution.ts} +61 -8
  29. package/src/core/attested-pi-run.ts +10 -1
  30. package/src/core/common.ts +2 -1
  31. package/src/core/delegate/artifacts.ts +4 -0
  32. package/src/core/delegate/launch.ts +44 -14
  33. package/src/core/delegate/runner.ts +24 -0
  34. package/src/core/delegate/seed.ts +12 -0
  35. package/src/core/delegate/types.ts +10 -2
  36. package/src/core/fusion/config.ts +1 -1
  37. package/src/core/fusion/pi-child.ts +7 -124
  38. package/src/core/registry.ts +4 -1
  39. package/src/delegate-child-extension.ts +9 -2
  40. package/src/delegate-extension.ts +53 -4
@@ -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(
@@ -138,6 +161,7 @@ export async function prepareDelegateLaunch(
138
161
  family: preflight.plan.route.family,
139
162
  rate_source: preflight.plan.route.rate_source,
140
163
  },
164
+ extensionMode: input.extensionMode,
141
165
  autoDeliver: input.autoDeliver,
142
166
  };
143
167
  const stdinBytes = Buffer.from(preflight.childPrompt, 'utf8');
@@ -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
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;
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
 
@@ -110,6 +117,7 @@ export interface DelegateSeedV1 {
110
117
  launch_nonce: string;
111
118
  cwd: string;
112
119
  capability: DelegateCapability;
120
+ extension_mode: DelegateExtensionMode;
113
121
  route: DelegatePinnedRoute;
114
122
  parent_system_prompt: string;
115
123
  parent_leaf_id: string | null;
@@ -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,
@@ -7,6 +7,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
7
7
  import type { Usage } from '@earendil-works/pi-ai';
8
8
  import {
9
9
  DELEGATE_RECEIPT_SCHEMA_VERSION,
10
+ DELEGATE_CAPABILITIES,
10
11
  type DelegateRouteAttestation,
11
12
  type DelegateSeedV1,
12
13
  type DelegateSpillReceipt,
@@ -23,8 +24,10 @@ import {
23
24
  /**
24
25
  * Package-owned delegate child extension.
25
26
  *
26
- * This runs inside the delegate child Pi process and is the only extension it
27
- * loads. It is responsible for every guarantee that cannot be enforced from the
27
+ * This runs inside every delegate child Pi process and is the package-owned
28
+ * child guard. Anthropic routes load attribution first, and ambient mode may
29
+ * also execute discovered extensions; this guard remains
30
+ * responsible for every isolation guarantee that cannot be enforced from the
28
31
  * parent:
29
32
  *
30
33
  * - verifying the frozen seed bytes before the first model call;
@@ -289,6 +292,10 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
289
292
  launchNonce: expectedNonce,
290
293
  });
291
294
 
295
+ if (!DELEGATE_CAPABILITIES.includes(seed.capability)) {
296
+ throw new Error(`delegate child cannot enforce capability ${seed.capability}`);
297
+ }
298
+
292
299
  const state: GuardState = {
293
300
  seed,
294
301
  artifactDirAbs,
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  DELEGATE_AUTO_DELIVER_MODES,
23
23
  DELEGATE_CAPABILITIES,
24
+ DELEGATE_EXTENSION_MODES,
24
25
  DELEGATE_RESULT_TOOL_NAME,
25
26
  DELEGATE_TOOL_NAME,
26
27
  DelegateError,
@@ -28,6 +29,7 @@ import {
28
29
  type DelegateCapability,
29
30
  type DelegateDeliveryMode,
30
31
  type DelegateBudgetRouteSource,
32
+ type DelegateExtensionMode,
31
33
  type DelegateRoute,
32
34
  } from './core/delegate/types.js';
33
35
  import {
@@ -67,7 +69,7 @@ const HOOK_EVIDENCE_PATH = fileURLToPath(
67
69
  new URL('./core/delegate/hook-contract-evidence.json', import.meta.url),
68
70
  );
69
71
 
70
- const DelegateParams = Type.Object(
72
+ export const DelegateParams = Type.Object(
71
73
  {
72
74
  name: Type.String({
73
75
  description: 'Short human-readable task name shown in the bg footer dock. Use 2-6 words.',
@@ -93,6 +95,12 @@ const DelegateParams = Type.Object(
93
95
  description: `Capability profile. Only "inspect" (read/search/list, no shell, no writes, no network, no recursion) is supported.`,
94
96
  }),
95
97
  ),
98
+ extensionMode: Type.Optional(
99
+ Type.String({
100
+ description:
101
+ 'Extension discovery: isolated | ambient. Default isolated. Ambient is for extension-registered providers and executes arbitrary discovered extension code, weakening process isolation.',
102
+ }),
103
+ ),
96
104
  maxTurns: Type.Optional(
97
105
  Type.Number({
98
106
  description: `Maximum agent turns. Default ${String(DELEGATE_DEFAULT_MAX_TURNS)}.`,
@@ -142,6 +150,20 @@ const ResultParams = Type.Object(
142
150
  type DelegateParamsValue = Static<typeof DelegateParams>;
143
151
  type ResultParamsValue = Static<typeof ResultParams>;
144
152
 
153
+ const DELEGATE_PARAM_KEYS = new Set([
154
+ 'name',
155
+ 'prompt',
156
+ 'route',
157
+ 'capability',
158
+ 'extensionMode',
159
+ 'maxTurns',
160
+ 'maxToolCalls',
161
+ 'timeoutSeconds',
162
+ 'autoDeliver',
163
+ 'notifyOnCompletion',
164
+ 'triggerOnCompletion',
165
+ ]);
166
+
145
167
  export interface DelegateLaunchDetails {
146
168
  schema_version: 'pi-background-tasks.delegate-launch.v1';
147
169
  task: BgTaskSnapshot;
@@ -151,6 +173,7 @@ export interface DelegateLaunchDetails {
151
173
  seed_sha256: string;
152
174
  seed_utf8_bytes: number;
153
175
  budget: DelegateBudgetRouteSource;
176
+ extension_mode: DelegateExtensionMode;
154
177
  auto_deliver: DelegateAutoDeliverMode;
155
178
  notify_on_completion: boolean;
156
179
  trigger_on_completion: boolean;
@@ -187,6 +210,7 @@ export interface DelegateResultDetails {
187
210
  delivery: DelegateDeliveryMode | 'none';
188
211
  route?: { provider: string; model: string } | undefined;
189
212
  budget?: DelegateBudgetRouteSource | undefined;
213
+ extension_mode?: DelegateExtensionMode | undefined;
190
214
  answer_bytes?: number | undefined;
191
215
  answer_sha256?: string | undefined;
192
216
  turns?: number | undefined;
@@ -213,6 +237,15 @@ function requireCapability(value: unknown): DelegateCapability {
213
237
  );
214
238
  }
215
239
 
240
+ function requireExtensionMode(value: unknown): DelegateExtensionMode {
241
+ if (value === undefined) return 'isolated';
242
+ if (value === 'isolated' || value === 'ambient') return value;
243
+ throw new DelegateError(
244
+ `bg_delegate extensionMode must be one of ${DELEGATE_EXTENSION_MODES.join(', ')}`,
245
+ { code: 'invalid_arguments', childCreated: false },
246
+ );
247
+ }
248
+
216
249
  function requireAutoDeliver(value: unknown): DelegateAutoDeliverMode {
217
250
  if (value === undefined) return 'never';
218
251
  if (value === 'never' || value === 'when_small' || value === 'always') return value;
@@ -308,13 +341,15 @@ export function registerDelegateExtension(
308
341
  name: DELEGATE_TOOL_NAME,
309
342
  label: 'Background Delegate',
310
343
  description:
311
- 'Launch one background Pi agent seeded with a frozen projection of the current conversation, then return a launch receipt immediately. The child has its own session, a route pinned at launch that is never substituted, and read-only tools. Retrieve its verified answer with bg_result.',
344
+ 'Launch one background Pi agent seeded with a frozen projection of the current conversation, then return a launch receipt immediately. The child has its own session, a route pinned at launch that is never substituted, and read-only tools. Extension discovery is isolated by default; ambient mode supports extension-registered providers but executes arbitrary discovered extension code. Retrieve its verified answer with bg_result.',
312
345
  promptSnippet:
313
346
  'Delegate an investigation to a background agent that already has this conversation as context',
314
347
  promptGuidelines: [
315
348
  'Use bg_delegate when work should continue in the background and the worker needs what you already know: it is seeded with a projection of this conversation.',
316
349
  'The prompt is authoritative. State exactly what you want investigated and what the answer should contain.',
317
- 'The delegate is inspect-only: it can read, search, and list files, but cannot run shell commands, edit or write files, use the network, or delegate further.',
350
+ 'The delegate is inspect-only at the model-visible tool boundary: it can read, search, and list files, but cannot run shell commands, edit or write files, use the network, or delegate further.',
351
+ 'Extension discovery is isolated by default. Use extensionMode:"ambient" only when the pinned provider is registered by an ambient user/project extension.',
352
+ 'Ambient mode executes arbitrary discovered extension code in the child process. Tool allowlists do not sandbox extension code, so ambient mode weakens inspect-only process isolation.',
318
353
  'Facts that exist only inside omitted tool output are not available to the delegate. Restate such findings in the prompt.',
319
354
  'bg_delegate returns immediately. Do not poll; retrieve the answer with bg_result after the terminal notification arrives.',
320
355
  ],
@@ -325,6 +360,13 @@ export function registerDelegateExtension(
325
360
  code: 'invalid_arguments',
326
361
  childCreated: false,
327
362
  });
363
+ const unknownKeys = Object.keys(args).filter((key) => !DELEGATE_PARAM_KEYS.has(key));
364
+ if (unknownKeys.length > 0) {
365
+ throw new DelegateError(
366
+ `bg_delegate contains unsupported key(s): ${unknownKeys.sort().join(', ')}`,
367
+ { code: 'invalid_arguments', childCreated: false },
368
+ );
369
+ }
328
370
  const name = args['name'];
329
371
  const prompt = args['prompt'];
330
372
  if (typeof name !== 'string' || name.trim().length === 0)
@@ -341,6 +383,7 @@ export function registerDelegateExtension(
341
383
  const route = requireRoute(args['route']);
342
384
  if (route !== undefined) prepared.route = route;
343
385
  prepared.capability = requireCapability(args['capability']);
386
+ prepared.extensionMode = requireExtensionMode(args['extensionMode']);
344
387
  prepared.autoDeliver = requireAutoDeliver(args['autoDeliver']);
345
388
  const maxTurns = optionalPositiveInteger(args['maxTurns'], 'maxTurns');
346
389
  if (maxTurns !== undefined) prepared.maxTurns = maxTurns;
@@ -356,6 +399,7 @@ export function registerDelegateExtension(
356
399
  },
357
400
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
358
401
  const capability = requireCapability(params.capability);
402
+ const extensionMode = requireExtensionMode(params.extensionMode);
359
403
  const autoDeliver = requireAutoDeliver(params.autoDeliver);
360
404
  const hookEvidence = await loadEvidence();
361
405
  const route = resolveDelegateRoute({
@@ -385,6 +429,7 @@ export function registerDelegateExtension(
385
429
  toolCallId,
386
430
  prompt: params.prompt,
387
431
  capability,
432
+ extensionMode,
388
433
  route,
389
434
  limitOverrides: {
390
435
  maxTurns: params.maxTurns,
@@ -423,6 +468,7 @@ export function registerDelegateExtension(
423
468
  seed_sha256: prepared.facts.seedSha256,
424
469
  seed_utf8_bytes: prepared.preflight.plan.seed_utf8_bytes,
425
470
  budget: prepared.facts.budget,
471
+ extension_mode: extensionMode,
426
472
  auto_deliver: autoDeliver,
427
473
  notify_on_completion: launchOptions.notifyOnCompletion,
428
474
  trigger_on_completion: launchOptions.triggerOnCompletion,
@@ -437,6 +483,7 @@ export function registerDelegateExtension(
437
483
  `Seed: ${String(prepared.preflight.plan.seed_utf8_bytes)} bytes, sha256 ${prepared.facts.seedSha256}`,
438
484
  `Estimator: family ${prepared.facts.budget.family}, source ${prepared.facts.budget.rate_source.source}, rate ${String(prepared.facts.budget.rate_source.effective_rate_bytes_per_token_x100)}/100 B/tok + ${String(prepared.facts.budget.rate_source.affine_f_tokens)} tokens${prepared.facts.budget.rate_source.warning === null ? '' : `; warning: ${prepared.facts.budget.rate_source.warning}`}`,
439
485
  `Capability: ${capability} (read/search/list only)`,
486
+ `Extension mode: ${extensionMode}${extensionMode === 'ambient' ? ' — WARNING: arbitrary discovered extension code executes in the child; the tool allowlist does not sandbox it, so inspect-only process isolation is weakened.' : ' (ambient extension discovery disabled)'}`,
440
487
  `Limits: ${String(prepared.preflight.limits.max_turns)} turns, ${String(prepared.preflight.limits.max_tool_calls)} tool calls, ${String(prepared.preflight.limits.timeout_seconds)}s`,
441
488
  `Auto-deliver: ${autoDeliver}`,
442
489
  launchOptions.notifyOnCompletion
@@ -458,7 +505,7 @@ export function registerDelegateExtension(
458
505
  renderResult(result, _options, theme) {
459
506
  const details = result.details;
460
507
  return new Text(
461
- `${theme.fg('success', '✓ delegated')} ${theme.fg('accent', details.task.id)}\n${theme.fg('dim', `route ${details.route.qualified_id} · seed ${String(details.seed_utf8_bytes)}B · ${details.artifact_dir}`)}`,
508
+ `${theme.fg('success', '✓ delegated')} ${theme.fg('accent', details.task.id)}\n${theme.fg('dim', `route ${details.route.qualified_id} · extensions ${details.extension_mode} · seed ${String(details.seed_utf8_bytes)}B · ${details.artifact_dir}`)}`,
462
509
  0,
463
510
  0,
464
511
  );
@@ -633,6 +680,7 @@ export function registerDelegateExtension(
633
680
  delivery: 'none',
634
681
  artifact_dir: facts.artifactDir,
635
682
  budget: facts.budget,
683
+ extension_mode: facts.extensionMode,
636
684
  };
637
685
  return {
638
686
  content: textContent(
@@ -686,6 +734,7 @@ export function registerDelegateExtension(
686
734
  delivery: decision.mode,
687
735
  route: verified.package.route,
688
736
  budget: facts.budget,
737
+ extension_mode: facts.extensionMode,
689
738
  answer_bytes: verified.package.answer.byte_length,
690
739
  answer_sha256: verified.package.answer.sha256,
691
740
  turns: verified.package.turns,