pi-background-tasks 2.5.0 → 2.6.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 (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +4 -4
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +23 -11
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +293 -113
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -0,0 +1,121 @@
1
+ export const PI_BG_FEATURE_VALUES = Object.freeze([
2
+ 'process',
3
+ 'delegate',
4
+ 'fusion',
5
+ 'attested',
6
+ 'attribution',
7
+ ] as const);
8
+ export type PiBackgroundFeature = (typeof PI_BG_FEATURE_VALUES)[number];
9
+
10
+ export const PI_BG_DEFAULT_FEATURES = PI_BG_FEATURE_VALUES;
11
+
12
+ export const PI_BG_DOCK_SHORTCUT_VALUES = Object.freeze([
13
+ 'shift+down',
14
+ 'ctrl+alt+b',
15
+ 'off',
16
+ ] as const);
17
+ export type PiBackgroundDockShortcut = (typeof PI_BG_DOCK_SHORTCUT_VALUES)[number];
18
+
19
+ export const PI_BG_DEFAULT_DOCK_SHORTCUT: PiBackgroundDockShortcut = 'shift+down';
20
+
21
+ export interface PiBackgroundFeatureSelection {
22
+ readonly process: true;
23
+ readonly delegate: boolean;
24
+ readonly fusion: boolean;
25
+ readonly attested: boolean;
26
+ readonly attribution: boolean;
27
+ }
28
+
29
+ export interface PiBackgroundConfig {
30
+ readonly features: PiBackgroundFeatureSelection;
31
+ readonly dockShortcut: PiBackgroundDockShortcut;
32
+ }
33
+
34
+ const CONFIG_VALUE_EXCERPT_CHARS = 96;
35
+
36
+ function boundedConfigValue(value: string): string {
37
+ if (value.length <= CONFIG_VALUE_EXCERPT_CHARS) return JSON.stringify(value);
38
+ return `${JSON.stringify(value.slice(0, CONFIG_VALUE_EXCERPT_CHARS))}… (${String(value.length)} chars)`;
39
+ }
40
+
41
+ function invalidConfig(variable: string, reason: string, value: string): never {
42
+ throw new Error(
43
+ `pi_bg_config_invalid: ${variable} ${reason}; received ${boundedConfigValue(value)}`,
44
+ );
45
+ }
46
+
47
+ function parseFeatures(rawValue: string | undefined): PiBackgroundFeatureSelection {
48
+ const raw = rawValue ?? PI_BG_DEFAULT_FEATURES.join(',');
49
+ const accepted = PI_BG_FEATURE_VALUES.join(',');
50
+ if (raw.length === 0) {
51
+ invalidConfig('PI_BG_FEATURES', `must not be empty; accepted tokens: ${accepted}`, raw);
52
+ }
53
+ if (/\s/u.test(raw)) {
54
+ invalidConfig(
55
+ 'PI_BG_FEATURES',
56
+ `must contain no whitespace; accepted tokens: ${accepted}`,
57
+ raw,
58
+ );
59
+ }
60
+ const tokens = raw.split(',');
61
+ if (tokens.some((token) => token.length === 0)) {
62
+ invalidConfig(
63
+ 'PI_BG_FEATURES',
64
+ `contains a blank comma token; accepted tokens: ${accepted}`,
65
+ raw,
66
+ );
67
+ }
68
+
69
+ const selected = new Set<PiBackgroundFeature>();
70
+ for (const token of tokens) {
71
+ if (!(PI_BG_FEATURE_VALUES as readonly string[]).includes(token)) {
72
+ invalidConfig(
73
+ 'PI_BG_FEATURES',
74
+ `contains unknown token ${boundedConfigValue(token)}; accepted tokens: ${accepted}; bg_result is derived from delegate or fusion`,
75
+ raw,
76
+ );
77
+ }
78
+ const feature = token as PiBackgroundFeature;
79
+ if (selected.has(feature)) {
80
+ invalidConfig('PI_BG_FEATURES', `contains duplicate token ${feature}`, raw);
81
+ }
82
+ selected.add(feature);
83
+ }
84
+ if (!selected.has('process')) {
85
+ invalidConfig('PI_BG_FEATURES', 'must include mandatory token process', raw);
86
+ }
87
+
88
+ return Object.freeze({
89
+ process: true,
90
+ delegate: selected.has('delegate'),
91
+ fusion: selected.has('fusion'),
92
+ attested: selected.has('attested'),
93
+ attribution: selected.has('attribution'),
94
+ });
95
+ }
96
+
97
+ function parseDockShortcut(rawValue: string | undefined): PiBackgroundDockShortcut {
98
+ const raw = rawValue ?? PI_BG_DEFAULT_DOCK_SHORTCUT;
99
+ if (!(PI_BG_DOCK_SHORTCUT_VALUES as readonly string[]).includes(raw)) {
100
+ invalidConfig(
101
+ 'PI_BG_DOCK_SHORTCUT',
102
+ `accepted values are exactly ${PI_BG_DOCK_SHORTCUT_VALUES.join(',')}`,
103
+ raw,
104
+ );
105
+ }
106
+ return raw as PiBackgroundDockShortcut;
107
+ }
108
+
109
+ export function parseBackgroundTasksConfig(
110
+ env: Readonly<Record<string, string | undefined>> = process.env,
111
+ ): PiBackgroundConfig {
112
+ const features = parseFeatures(env['PI_BG_FEATURES']);
113
+ const dockShortcut = parseDockShortcut(env['PI_BG_DOCK_SHORTCUT']);
114
+ return Object.freeze({ features, dockShortcut });
115
+ }
116
+
117
+ export function dockShortcutFooterHint(shortcut: PiBackgroundDockShortcut): string {
118
+ if (shortcut === 'shift+down') return 'Shift↓';
119
+ if (shortcut === 'ctrl+alt+b') return 'CtrlAltB';
120
+ return '/tasks';
121
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import type { Message } from '@earendil-works/pi-ai';
3
- import { canonicalJson } from '../attested-pi-run.js';
3
+ import { canonicalJson } from '../canonical-json.js';
4
4
 
5
5
  /**
6
6
  * Frozen `visible-conversation-ledger-v2` transform.
@@ -22,8 +22,7 @@ import { canonicalJson } from '../attested-pi-run.js';
22
22
  */
23
23
  export const VISIBLE_CONVERSATION_TRANSFORM_ID = 'visible-conversation-ledger-v2';
24
24
 
25
- export const CONVERSATION_IMAGE_OMISSION_PREFIX =
26
- '[Image omitted from fusion text transcript: ';
25
+ export const CONVERSATION_IMAGE_OMISSION_PREFIX = '[Image omitted from fusion text transcript: ';
27
26
 
28
27
  export const OMITTED_EVENT_KINDS = [
29
28
  'assistant_thinking',
@@ -141,9 +140,7 @@ export class UnsupportedConversationBlockError extends Error {
141
140
  readonly label: string;
142
141
 
143
142
  constructor(label: string) {
144
- super(
145
- `fusion context projection encountered an unsupported conversation block: ${label}`,
146
- );
143
+ super(`fusion context projection encountered an unsupported conversation block: ${label}`);
147
144
  this.name = 'UnsupportedConversationBlockError';
148
145
  this.label = label;
149
146
  }
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
4
4
  import { basename, isAbsolute, join, relative, sep } from 'node:path';
5
- import { canonicalJson } from '../attested-pi-run.js';
5
+ import { canonicalJson } from '../canonical-json.js';
6
6
  import { sanitizePathSegment } from '../common.js';
7
7
  import { replaceFileDurable, writeFileDurable } from '../durable-fs.js';
8
8
  import {
@@ -92,11 +92,14 @@ function sha256Bytes(bytes: Buffer): string {
92
92
 
93
93
  function pathInside(parent: string, child: string): boolean {
94
94
  const rel = relative(parent, child);
95
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
95
+ return (
96
+ rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'))
97
+ );
96
98
  }
97
99
 
98
100
  function artifactError(message: string, cause?: unknown): DelegateError {
99
- const suffix = cause === undefined ? '' : `: ${cause instanceof Error ? cause.message : String(cause)}`;
101
+ const suffix =
102
+ cause === undefined ? '' : `: ${cause instanceof Error ? cause.message : String(cause)}`;
100
103
  return new DelegateError(`${message}${suffix}`, {
101
104
  code: 'artifact_error',
102
105
  childCreated: true,
@@ -150,9 +153,7 @@ export class DelegateArtifactStore {
150
153
  * directory is created with `recursive: false` so a pre-existing directory is
151
154
  * a loud failure rather than a silent reuse.
152
155
  */
153
- static async create(
154
- options: CreateDelegateArtifactStoreOptions,
155
- ): Promise<DelegateArtifactStore> {
156
+ static async create(options: CreateDelegateArtifactStoreOptions): Promise<DelegateArtifactStore> {
156
157
  const sessionSegment = sanitizePathSegment(
157
158
  options.sessionId ?? `session-${String(process.pid)}`,
158
159
  );
@@ -260,9 +261,12 @@ export class DelegateArtifactStore {
260
261
  taskId: this.manifest.task_id,
261
262
  artifactDir: this.rootDisplay,
262
263
  preserved: diagnosticNames,
263
- remediation: diagnosticNames.length === 0
264
- ? ['No diagnostic control artifact exists; inspect the background task merged output if one was created.']
265
- : [`Inspect the existing delegate control artifacts: ${diagnosticNames.join(', ')}.`],
264
+ remediation:
265
+ diagnosticNames.length === 0
266
+ ? [
267
+ 'No diagnostic control artifact exists; inspect the background task merged output if one was created.',
268
+ ]
269
+ : [`Inspect the existing delegate control artifacts: ${diagnosticNames.join(', ')}.`],
266
270
  },
267
271
  );
268
272
  }
@@ -14,6 +14,12 @@ import {
14
14
  type TokenBudgetFamilyCalibration,
15
15
  type TokenBudgetRateSource,
16
16
  } from '../context/token-budget.js';
17
+ import {
18
+ DELEGATE_DEFAULT_MAX_TOOL_CALLS,
19
+ DELEGATE_DEFAULT_MAX_TURNS,
20
+ DELEGATE_DEFAULT_TIMEOUT_SECONDS,
21
+ DELEGATE_INLINE_ANSWER_BYTES,
22
+ } from './facade-contract.js';
17
23
  import {
18
24
  DELEGATE_BUDGET_PLAN_SCHEMA_VERSION,
19
25
  DelegateError,
@@ -21,6 +27,13 @@ import {
21
27
  type DelegatePinnedRoute,
22
28
  } from './types.js';
23
29
 
30
+ export {
31
+ DELEGATE_DEFAULT_MAX_TOOL_CALLS,
32
+ DELEGATE_DEFAULT_MAX_TURNS,
33
+ DELEGATE_DEFAULT_TIMEOUT_SECONDS,
34
+ DELEGATE_INLINE_ANSWER_BYTES,
35
+ } from './facade-contract.js';
36
+
24
37
  /**
25
38
  * Delegate budgeting.
26
39
  *
@@ -51,9 +64,6 @@ export const DELEGATE_MIN_CONTEXT_WINDOW_TOKENS =
51
64
  DELEGATE_FRAMING_RESERVE_TOKENS +
52
65
  DELEGATE_SAFETY_RESERVE_TOKENS;
53
66
 
54
- export const DELEGATE_DEFAULT_MAX_TURNS = 24;
55
- export const DELEGATE_DEFAULT_MAX_TOOL_CALLS = 120;
56
- export const DELEGATE_DEFAULT_TIMEOUT_SECONDS = 1200;
57
67
  export const DELEGATE_MAX_TOOL_RESULT_BYTES = 64 * 1024;
58
68
  export const DELEGATE_MAX_TOTAL_TOOL_OUTPUT_BYTES = 64 * 1024 * 1024;
59
69
  export const DELEGATE_MAX_ANSWER_BYTES = 4 * 1024 * 1024;
@@ -61,9 +71,6 @@ export const DELEGATE_MAX_ANSWER_BYTES = 4 * 1024 * 1024;
61
71
  export const DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS = 32 * 1024;
62
72
  /** Remaining retained-growth runway at which the child disables tools. */
63
73
  export const DELEGATE_FINALIZATION_TRIGGER_TOKENS = 8 * 1024;
64
- /** Answers at or under this serialize inline; larger ones degrade explicitly. */
65
- export const DELEGATE_INLINE_ANSWER_BYTES = 48 * 1024;
66
-
67
74
  export const DELEGATE_BUDGET_POLICY_ID = 'delegate-budget-policy-v3';
68
75
 
69
76
  export interface DelegateBudgetPolicyDescriptor {
@@ -0,0 +1,6 @@
1
+ /** Light registration constants shared by the delegate facade and budget engine. */
2
+ export const DELEGATE_DEFAULT_MAX_TURNS = 24;
3
+ export const DELEGATE_DEFAULT_MAX_TOOL_CALLS = 120;
4
+ export const DELEGATE_DEFAULT_TIMEOUT_SECONDS = 1200;
5
+ /** Answers at or under this serialize inline; larger ones degrade explicitly. */
6
+ export const DELEGATE_INLINE_ANSWER_BYTES = 48 * 1024;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { canonicalJson } from '../attested-pi-run.js';
2
+ import { canonicalJson } from '../canonical-json.js';
3
3
  import {
4
4
  DELEGATE_RECEIPT_SCHEMA_VERSION,
5
5
  DELEGATE_RESULT_PACKAGE_SCHEMA_VERSION,
@@ -87,9 +87,7 @@ export function buildDelegateResultPackage(
87
87
  data_base64: bytes.toString('base64'),
88
88
  };
89
89
  });
90
- const aggregate = Buffer.concat(
91
- blocks.map((block) => Buffer.from(block.data_base64, 'base64')),
92
- );
90
+ const aggregate = Buffer.concat(blocks.map((block) => Buffer.from(block.data_base64, 'base64')));
93
91
  return {
94
92
  schema_version: DELEGATE_RESULT_PACKAGE_SCHEMA_VERSION,
95
93
  task_id: input.taskId,
@@ -183,7 +181,11 @@ function parseUsage(value: unknown, taskId: string): DelegateUsageReport {
183
181
  );
184
182
  const usage = value['usage'];
185
183
  if (!isRecord(usage))
186
- fail('delegate result package observed usage must be an object', 'child_result_invalid', taskId);
184
+ fail(
185
+ 'delegate result package observed usage must be an object',
186
+ 'child_result_invalid',
187
+ taskId,
188
+ );
187
189
  const cost = usage['cost'];
188
190
  if (!isRecord(cost))
189
191
  fail(
@@ -220,10 +222,7 @@ function parseUsage(value: unknown, taskId: string): DelegateUsageReport {
220
222
  };
221
223
  }
222
224
 
223
- function parseAttestations(
224
- value: unknown,
225
- taskId: string,
226
- ): readonly DelegateRouteAttestation[] {
225
+ function parseAttestations(value: unknown, taskId: string): readonly DelegateRouteAttestation[] {
227
226
  if (!Array.isArray(value))
228
227
  fail(
229
228
  'delegate result package route_attestations must be an array',
@@ -264,7 +263,11 @@ function parseSpillContentFormat(
264
263
 
265
264
  function parseSpillReceipts(value: unknown, taskId: string): readonly DelegateSpillReceipt[] {
266
265
  if (!Array.isArray(value))
267
- fail('delegate result package spilled_artifacts must be an array', 'child_result_invalid', taskId);
266
+ fail(
267
+ 'delegate result package spilled_artifacts must be an array',
268
+ 'child_result_invalid',
269
+ taskId,
270
+ );
268
271
  return value.map((entry) => {
269
272
  if (!isRecord(entry))
270
273
  fail('delegate spill receipt must be an object', 'child_result_invalid', taskId);
@@ -332,7 +335,11 @@ export function verifyDelegateResultPackage(
332
335
 
333
336
  const routeRecord = parsed['route'];
334
337
  if (!isRecord(routeRecord))
335
- fail('delegate result package route must be an object', 'child_result_invalid', expected.taskId);
338
+ fail(
339
+ 'delegate result package route must be an object',
340
+ 'child_result_invalid',
341
+ expected.taskId,
342
+ );
336
343
  const provider = requireString(routeRecord, 'provider', expected.taskId);
337
344
  const model = requireString(routeRecord, 'model', expected.taskId);
338
345
  if (provider !== expected.route.provider || model !== expected.route.model)
@@ -343,7 +350,10 @@ export function verifyDelegateResultPackage(
343
350
  );
344
351
  const attestations = parseAttestations(parsed['route_attestations'], expected.taskId);
345
352
  for (const attestation of attestations) {
346
- if (attestation.provider !== expected.route.provider || attestation.model !== expected.route.model)
353
+ if (
354
+ attestation.provider !== expected.route.provider ||
355
+ attestation.model !== expected.route.model
356
+ )
347
357
  fail(
348
358
  `delegate child produced an assistant message on ${attestation.provider}/${attestation.model}, but the pinned route is ${expected.route.provider}/${expected.route.model}`,
349
359
  'route_mismatch',
@@ -353,7 +363,11 @@ export function verifyDelegateResultPackage(
353
363
 
354
364
  const answerRecord = parsed['answer'];
355
365
  if (!isRecord(answerRecord))
356
- fail('delegate result package answer must be an object', 'child_result_invalid', expected.taskId);
366
+ fail(
367
+ 'delegate result package answer must be an object',
368
+ 'child_result_invalid',
369
+ expected.taskId,
370
+ );
357
371
  if (answerRecord['encoding'] !== 'utf-8')
358
372
  fail(
359
373
  'delegate result package answer encoding must be utf-8',
@@ -364,7 +378,11 @@ export function verifyDelegateResultPackage(
364
378
  const declaredSha = requireSha256(answerRecord, 'sha256', expected.taskId);
365
379
  const blocksValue = answerRecord['blocks'];
366
380
  if (!Array.isArray(blocksValue))
367
- fail('delegate result package answer blocks must be an array', 'child_result_invalid', expected.taskId);
381
+ fail(
382
+ 'delegate result package answer blocks must be an array',
383
+ 'child_result_invalid',
384
+ expected.taskId,
385
+ );
368
386
 
369
387
  const blocks: DelegateAnswerBlock[] = [];
370
388
  const buffers: Buffer[] = [];
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
3
- import { canonicalJson } from '../attested-pi-run.js';
2
+ import { readFile, rmdir } from 'node:fs/promises';
3
+ import { canonicalJson } from '../canonical-json.js';
4
4
  import { replaceFileDurable } from '../durable-fs.js';
5
5
  import { resolveAnthropicAttributionExtensionPath } from '../anthropic-attribution-path.js';
6
- import { join } from 'node:path';
6
+ import { dirname, join } from 'node:path';
7
7
  import { DelegateArtifactStore, discardDelegateArtifactRoot } from './artifacts.js';
8
8
  import {
9
9
  buildDelegateChildArgv,
@@ -14,9 +14,7 @@ import {
14
14
  type DelegatePreflightInput,
15
15
  type DelegatePreflightResult,
16
16
  } from './launch.js';
17
- import {
18
- DELEGATE_INLINE_ANSWER_BYTES,
19
- } from './budget.js';
17
+ import { DELEGATE_INLINE_ANSWER_BYTES } from './budget.js';
20
18
  import { verifyDelegateResultPackage, type VerifiedDelegateResult } from './result-package.js';
21
19
  import {
22
20
  DelegateError,
@@ -44,6 +42,8 @@ export interface PreparedDelegateLaunch {
44
42
  seedPathAbs: string;
45
43
  /** Exact prompt bytes delivered to the child over stdin. */
46
44
  stdinBytes: Buffer;
45
+ /** Remove this complete preparation while it is still unowned by a task. */
46
+ rollback: () => Promise<void>;
47
47
  }
48
48
 
49
49
  export interface PrepareDelegateLaunchInput extends DelegatePreflightInput {
@@ -55,6 +55,57 @@ export interface PrepareDelegateLaunchInput extends DelegatePreflightInput {
55
55
  attributionExtensionPath?: string | undefined;
56
56
  env?: NodeJS.ProcessEnv | undefined;
57
57
  now?: (() => Date) | undefined;
58
+ /** Activation closure cancels preparation before task ownership transfers. */
59
+ signal?: AbortSignal | undefined;
60
+ }
61
+
62
+ const PREPARATION_CLEANUP_ERROR_MAX_CHARS = 320;
63
+
64
+ function boundedPreparationError(error: unknown): string {
65
+ const text = (error instanceof Error ? error.message : String(error))
66
+ .replace(/\s+/gu, ' ')
67
+ .trim();
68
+ if (text.length <= PREPARATION_CLEANUP_ERROR_MAX_CHARS) return text;
69
+ return `${text.slice(0, PREPARATION_CLEANUP_ERROR_MAX_CHARS)}…`;
70
+ }
71
+
72
+ function filesystemErrorCode(error: unknown): string | undefined {
73
+ if (typeof error !== 'object' || error === null) return undefined;
74
+ const code: unknown = Reflect.get(error, 'code');
75
+ return typeof code === 'string' ? code : undefined;
76
+ }
77
+
78
+ async function removeEmptyDirectory(path: string): Promise<boolean> {
79
+ try {
80
+ await rmdir(path);
81
+ return true;
82
+ } catch (error) {
83
+ const code = filesystemErrorCode(error);
84
+ if (code === 'ENOENT') return true;
85
+ if (code === 'ENOTEMPTY' || code === 'EEXIST') return false;
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ async function discardUnownedDelegatePreparation(rootAbs: string): Promise<void> {
91
+ await discardDelegateArtifactRoot(rootAbs);
92
+ const runParent = dirname(rootAbs);
93
+ if (!(await removeEmptyDirectory(runParent))) return;
94
+ await removeEmptyDirectory(dirname(runParent));
95
+ }
96
+
97
+ function throwIfPreparationAborted(signal: AbortSignal | undefined): void {
98
+ if (signal?.aborted !== true) return;
99
+ const reason: unknown = signal.reason;
100
+ if (reason instanceof Error) throw reason;
101
+ throw new Error('delegate launch preparation was cancelled before task registration');
102
+ }
103
+
104
+ function preparationCleanupFailure(original: unknown, cleanup: unknown): AggregateError {
105
+ return new AggregateError(
106
+ [original, cleanup],
107
+ `delegate launch preparation failed and rollback also failed: ${boundedPreparationError(cleanup)}`,
108
+ );
58
109
  }
59
110
 
60
111
  /**
@@ -62,16 +113,16 @@ export interface PrepareDelegateLaunchInput extends DelegatePreflightInput {
62
113
  *
63
114
  * Preflight runs first and completes entirely before the artifact directory is
64
115
  * created, so every admission refusal leaves zero children AND zero artifacts.
65
- * When a step after directory creation fails, the partially created directory
66
- * is removed, so a refused launch never leaves a half-formed run behind.
116
+ * When a step after directory creation fails or activation cancellation is
117
+ * observed, the producer removes the unowned directory before rejecting.
67
118
  */
68
119
  export async function prepareDelegateLaunch(
69
120
  input: PrepareDelegateLaunchInput,
70
121
  ): Promise<PreparedDelegateLaunch> {
122
+ throwIfPreparationAborted(input.signal);
71
123
  // Resolve the guard extension before anything is created: a package missing
72
124
  // its child guard must refuse rather than spawn an unguarded child.
73
- const childExtensionPath =
74
- input.childExtensionPath ?? resolveDelegateChildExtensionPath();
125
+ const childExtensionPath = input.childExtensionPath ?? resolveDelegateChildExtensionPath();
75
126
  let attributionExtensionPath: string | undefined;
76
127
  if (input.route.provider === 'anthropic') {
77
128
  try {
@@ -89,41 +140,46 @@ export async function prepareDelegateLaunch(
89
140
  }
90
141
  }
91
142
 
143
+ throwIfPreparationAborted(input.signal);
92
144
  const preflight = preflightDelegateLaunch(input);
145
+ throwIfPreparationAborted(input.signal);
93
146
 
94
- const store = await DelegateArtifactStore.create({
95
- cwd: input.cwd,
96
- taskId: preflight.taskId,
97
- launchNonce: preflight.launchNonce,
98
- sessionId: input.sessionId,
99
- childSessionId: preflight.childSessionId,
100
- childSessionDir: '',
101
- extensionMode: input.extensionMode,
102
- route: input.route,
103
- limits: preflight.limits,
104
- seedSha256: preflight.seed.sha256,
105
- ...(input.now === undefined ? {} : { now: input.now }),
106
- });
107
-
147
+ let store: DelegateArtifactStore | undefined;
108
148
  try {
149
+ store = await DelegateArtifactStore.create({
150
+ cwd: input.cwd,
151
+ taskId: preflight.taskId,
152
+ launchNonce: preflight.launchNonce,
153
+ sessionId: input.sessionId,
154
+ childSessionId: preflight.childSessionId,
155
+ childSessionDir: '',
156
+ extensionMode: input.extensionMode,
157
+ route: input.route,
158
+ limits: preflight.limits,
159
+ seedSha256: preflight.seed.sha256,
160
+ ...(input.now === undefined ? {} : { now: input.now }),
161
+ });
162
+ throwIfPreparationAborted(input.signal);
163
+
109
164
  const seedRef = await store.writeSeed(preflight.seed.serialized);
165
+ throwIfPreparationAborted(input.signal);
110
166
  // The persisted seed bytes are the bytes the child reads. Nothing
111
167
  // re-serializes them between here and the child, and the child verifies the
112
168
  // hash before its first model call.
113
169
  if (seedRef.sha256 !== preflight.seed.sha256) {
114
- throw new DelegateError(
115
- 'delegate seed hash changed between construction and persistence',
116
- {
117
- code: 'seed_persist_failed',
118
- childCreated: false,
119
- taskId: preflight.taskId,
120
- artifactDir: store.artifactDir,
121
- },
122
- );
170
+ throw new DelegateError('delegate seed hash changed between construction and persistence', {
171
+ code: 'seed_persist_failed',
172
+ childCreated: false,
173
+ taskId: preflight.taskId,
174
+ artifactDir: store.artifactDir,
175
+ });
123
176
  }
124
177
  await store.writeLedger(preflight.seed.ledger);
178
+ throwIfPreparationAborted(input.signal);
125
179
  await store.writeBudgetPlan(preflight.plan);
180
+ throwIfPreparationAborted(input.signal);
126
181
  const childSessionDirAbs = await ensureDelegateChildSessionDir(store.artifactDirAbs);
182
+ throwIfPreparationAborted(input.signal);
127
183
  const seedPathAbs = join(store.artifactDirAbs, 'seed.json');
128
184
  const argv = buildDelegateChildArgv({
129
185
  route: input.route,
@@ -169,6 +225,13 @@ export async function prepareDelegateLaunch(
169
225
  // The persisted prompt bytes must equal the bytes sent to the child, so the
170
226
  // artifact is evidence of what the child actually received.
171
227
  await store.writeChildPrompt(stdinBytes);
228
+ throwIfPreparationAborted(input.signal);
229
+ const artifactDirAbs = store.artifactDirAbs;
230
+ let rollbackPromise: Promise<void> | undefined;
231
+ const rollback = (): Promise<void> => {
232
+ rollbackPromise ??= discardUnownedDelegatePreparation(artifactDirAbs);
233
+ return rollbackPromise;
234
+ };
172
235
  return {
173
236
  preflight,
174
237
  store,
@@ -178,10 +241,15 @@ export async function prepareDelegateLaunch(
178
241
  childSessionDirAbs,
179
242
  seedPathAbs,
180
243
  stdinBytes,
244
+ rollback,
181
245
  };
182
246
  } catch (error) {
183
- // A failure after directory creation must not leave a half-formed run.
184
- await discardDelegateArtifactRoot(store.artifactDirAbs);
247
+ if (store === undefined) throw error;
248
+ try {
249
+ await discardUnownedDelegatePreparation(store.artifactDirAbs);
250
+ } catch (cleanupError) {
251
+ throw preparationCleanupFailure(error, cleanupError);
252
+ }
185
253
  throw error;
186
254
  }
187
255
  }
@@ -246,17 +314,19 @@ async function adjudicateDelegateTerminal(
246
314
  const resultPath = join(input.artifactDirAbs, 'result.json');
247
315
  const terminalPath = join(input.artifactDirAbs, 'child-terminal.json');
248
316
  if (!existsSync(resultPath)) {
249
- const recorded = existsSync(terminalPath)
250
- ? await readChildTerminal(terminalPath)
251
- : undefined;
317
+ const recorded = existsSync(terminalPath) ? await readChildTerminal(terminalPath) : undefined;
252
318
  const cancelled = input.taskStatus === 'killed';
253
319
  const code = recorded?.code ?? (cancelled ? 'child_cancelled' : 'child_exited_without_commit');
254
320
  const detail =
255
321
  recorded?.message ??
256
322
  input.taskError ??
257
323
  '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)));
324
+ const preserved = [
325
+ 'seed.json',
326
+ 'budget-plan.json',
327
+ 'child-terminal.json',
328
+ 'runtime-budget.json',
329
+ ].filter((name) => existsSync(join(input.artifactDirAbs, name)));
260
330
  if (
261
331
  input.taskOutputPath !== undefined &&
262
332
  input.taskOutputAbsPath !== undefined &&
@@ -265,25 +335,26 @@ async function adjudicateDelegateTerminal(
265
335
  preserved.push(input.taskOutputPath);
266
336
  }
267
337
  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(', ')}.`;
273
- const error = new DelegateError(
274
- `bg_delegate produced no committed answer: ${detail}`,
275
- {
276
- code: isDelegateErrorCode(code) ? code : 'child_exited_without_commit',
277
- childCreated: true,
278
- taskId: input.taskId,
279
- artifactDir: input.artifactDirAbs,
280
- preserved,
281
- remediation: [
282
- diagnostic,
283
- 'No partial answer is returned; nothing was truncated to look like success.',
284
- ],
285
- },
338
+ (name) =>
339
+ name === 'child-terminal.json' ||
340
+ name === 'runtime-budget.json' ||
341
+ name === input.taskOutputPath,
286
342
  );
343
+ const diagnostic =
344
+ diagnosticTargets.length === 0
345
+ ? 'No child terminal record or merged task output exists; inspect the preserved launch artifacts listed above.'
346
+ : `Inspect the preserved diagnostic evidence: ${diagnosticTargets.join(', ')}.`;
347
+ const error = new DelegateError(`bg_delegate produced no committed answer: ${detail}`, {
348
+ code: isDelegateErrorCode(code) ? code : 'child_exited_without_commit',
349
+ childCreated: true,
350
+ taskId: input.taskId,
351
+ artifactDir: input.artifactDirAbs,
352
+ preserved,
353
+ remediation: [
354
+ diagnostic,
355
+ 'No partial answer is returned; nothing was truncated to look like success.',
356
+ ],
357
+ });
287
358
  const outcome: DelegateTaskOutcome = {
288
359
  status: cancelled ? 'cancelled' : 'failed',
289
360
  errorCode: error.code,
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { canonicalJson } from '../attested-pi-run.js';
2
+ import { canonicalJson } from '../canonical-json.js';
3
3
  import {
4
4
  UnsupportedConversationBlockError,
5
5
  projectVisibleConversationV2,
@@ -241,11 +241,7 @@ export function verifyDelegateSeedBytes(
241
241
  return rebuildSeed(parsed, expected.taskId);
242
242
  }
243
243
 
244
- function requireString(
245
- record: Record<PropertyKey, unknown>,
246
- key: string,
247
- taskId: string,
248
- ): string {
244
+ function requireString(record: Record<PropertyKey, unknown>, key: string, taskId: string): string {
249
245
  const value = record[key];
250
246
  if (typeof value !== 'string') {
251
247
  throw new DelegateError(`delegate seed field ${key} must be a string`, {
@@ -404,7 +400,9 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
404
400
  * so it is carried through without re-deriving values that would only duplicate
405
401
  * the parent's computation.
406
402
  */
407
- function readDelegateProjection(record: Record<PropertyKey, unknown>): DelegateConversationProjection {
403
+ function readDelegateProjection(
404
+ record: Record<PropertyKey, unknown>,
405
+ ): DelegateConversationProjection {
408
406
  const projection: unknown = record;
409
407
  if (!isDelegateProjection(projection)) {
410
408
  throw new DelegateError('delegate seed conversation_projection is malformed', {