pi-background-tasks 0.9.0 → 1.0.3

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 (72) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
  2. package/PUBLISHING.md +43 -29
  3. package/README.md +233 -441
  4. package/TESTING.md +15 -9
  5. package/TEST_PLAN.md +43 -17
  6. package/docs/INDEX.md +157 -0
  7. package/docs/api/eventbus-v1.md +166 -0
  8. package/docs/assets/architecture.svg +78 -0
  9. package/docs/assets/footer-dock.svg +47 -0
  10. package/docs/assets/logo.svg +49 -0
  11. package/docs/attestations.json +189 -0
  12. package/docs/choose-a-workflow.md +98 -0
  13. package/docs/commands/bg-clear.md +70 -0
  14. package/docs/commands/bg-update.md +82 -0
  15. package/docs/commands/bg.md +90 -0
  16. package/docs/commands/fusion-models.md +70 -0
  17. package/docs/commands/fusion.md +69 -0
  18. package/docs/commands/jobs.md +74 -0
  19. package/docs/commands/kill.md +82 -0
  20. package/docs/commands/logs.md +90 -0
  21. package/docs/commands/task-manager.md +109 -0
  22. package/docs/concepts/completion-delivery.md +66 -0
  23. package/docs/concepts/context-projection-and-budgeting.md +79 -0
  24. package/docs/getting-started.md +122 -0
  25. package/docs/manifest.json +1825 -0
  26. package/docs/operations/configuration.md +110 -0
  27. package/docs/operations/releasing.md +67 -0
  28. package/docs/operations/testing.md +101 -0
  29. package/docs/operations/troubleshooting.md +38 -0
  30. package/docs/read-before-edit.md +94 -0
  31. package/docs/reference/runtime-contracts.md +213 -0
  32. package/docs/reference/shortcuts-and-dock.md +70 -0
  33. package/docs/subsystems/attested-pi-runs.md +141 -0
  34. package/docs/subsystems/background-task-runtime.md +85 -0
  35. package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
  36. package/docs/subsystems/delegation.md +190 -0
  37. package/docs/subsystems/docs-freshness-gate.md +26 -0
  38. package/docs/subsystems/fusion.md +121 -0
  39. package/docs/subsystems/host-ui-and-telemetry.md +83 -0
  40. package/docs/tools/bg_delegate.md +193 -0
  41. package/docs/tools/bg_kill.md +114 -0
  42. package/docs/tools/bg_logs.md +133 -0
  43. package/docs/tools/bg_result.md +120 -0
  44. package/docs/tools/bg_run.md +168 -0
  45. package/docs/tools/bg_run_pi_attested.md +170 -0
  46. package/docs/tools/bg_status.md +111 -0
  47. package/docs/tools/fusion_investigate.md +116 -0
  48. package/docs/tools/fusion_reason.md +75 -0
  49. package/docs/tools/fusion_research.md +162 -0
  50. package/docs/tools/fusion_validate.md +206 -0
  51. package/logo.png +0 -0
  52. package/package.json +25 -7
  53. package/src/core/delegate/budget.ts +1 -1
  54. package/src/core/delegate/launch.ts +5 -0
  55. package/src/core/fusion/artifacts.ts +34 -4
  56. package/src/core/fusion/budget.ts +112 -20
  57. package/src/core/fusion/child-protocol.ts +82 -0
  58. package/src/core/fusion/clean-context.ts +91 -0
  59. package/src/core/fusion/config.ts +124 -35
  60. package/src/core/fusion/context.ts +29 -7
  61. package/src/core/fusion/evaluation.ts +392 -15
  62. package/src/core/fusion/orchestrator.ts +217 -23
  63. package/src/core/fusion/pi-child.ts +183 -23
  64. package/src/core/fusion/prompts.ts +39 -26
  65. package/src/core/fusion/source-policy.ts +257 -0
  66. package/src/core/fusion/types.ts +156 -11
  67. package/src/core/fusion/web-fetch.ts +104 -15
  68. package/src/core/fusion/workflows.ts +119 -65
  69. package/src/extension.ts +3 -3
  70. package/src/fusion-child-extension.ts +159 -120
  71. package/src/fusion-extension.ts +585 -240
  72. package/src/testing/normalize.ts +0 -22
@@ -1,6 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { closeSync, fsyncSync, openSync, writeSync } from 'node:fs';
3
- import type { Usage } from '@earendil-works/pi-ai';
2
+ import { closeSync, constants, fstatSync, fsyncSync, openSync, readFileSync, writeSync } from 'node:fs';
4
3
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
5
4
  import { Type, type Static } from 'typebox';
6
5
  import {
@@ -13,22 +12,40 @@ import {
13
12
  FusionWebFetchError,
14
13
  FUSION_WEB_FETCH_TIMEOUT_MS,
15
14
  } from './core/fusion/web-fetch.js';
15
+ import {
16
+ canonicalizeFusionPublicUrl,
17
+ parseFusionSourcePolicy,
18
+ } from './core/fusion/source-policy.js';
19
+ import {
20
+ FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
21
+ FUSION_CHILD_RESULT_PREFIX,
22
+ FUSION_RESEARCH_ENABLED_ENV,
23
+ FUSION_SOURCE_POLICY_PATH_ENV,
24
+ FUSION_SOURCE_POLICY_SHA256_ENV,
25
+ FUSION_TOOL_CALL_LOG_PATH_ENV,
26
+ FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
27
+ FUSION_TOOL_CALL_SEAL_SUFFIX,
28
+ buildFusionChildResultMetadata,
29
+ type FusionChildResultMetadata,
30
+ } from './core/fusion/child-protocol.js';
16
31
 
17
- export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
18
- 'pi-background-tasks.fusion-child-result.v2' as const;
19
- export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
20
- export const FUSION_TOOL_CALL_LOG_PATH_ENV = 'PI_FUSION_TOOL_CALL_LOG_PATH';
21
- export const FUSION_RESEARCH_ENABLED_ENV = 'PI_FUSION_RESEARCH_ENABLED';
32
+ export {
33
+ FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
34
+ FUSION_CHILD_RESULT_PREFIX,
35
+ FUSION_CHILD_RESULT_SCHEMA_VERSION,
36
+ FUSION_RESEARCH_ENABLED_ENV,
37
+ FUSION_SOURCE_POLICY_PATH_ENV,
38
+ FUSION_SOURCE_POLICY_SHA256_ENV,
39
+ FUSION_TOOL_CALL_LOG_PATH_ENV,
40
+ FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
41
+ FUSION_TOOL_CALL_SEAL_SUFFIX,
42
+ buildFusionChildResultMetadata,
43
+ type FusionChildResultMetadata,
44
+ type FusionChildResultUsageMetadata,
45
+ type FusionChildTextBlockMetadata,
46
+ } from './core/fusion/child-protocol.js';
22
47
 
23
- /**
24
- * Aggregate ceiling on tool-result bytes a single candidate child may accumulate.
25
- *
26
- * v1 deliberately has no tool-CALL cap, so this byte budget is the only bound on how much
27
- * a read-only candidate can pull into its context. 8 MiB is generous for targeted
28
- * grep/read investigation while still preventing an unbounded read loop from degrading
29
- * into an opaque provider-side context failure.
30
- */
31
- export const FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES = 8 * 1024 * 1024;
48
+ const FUSION_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
32
49
 
33
50
  const FusionWebFetchParams = Type.Object(
34
51
  {
@@ -58,30 +75,14 @@ interface FusionWebFetchDetails {
58
75
  }
59
76
 
60
77
  interface FusionWebFetchAuditMetadata {
61
- url: string;
78
+ url?: string | undefined;
79
+ rejected_url_sha256?: string | undefined;
62
80
  final_url?: string | undefined;
63
81
  http_status?: number | undefined;
64
82
  response_bytes?: number | undefined;
65
83
  content_sha256?: string | undefined;
66
84
  }
67
85
 
68
- export interface FusionChildTextBlockMetadata {
69
- utf8_bytes: number;
70
- sha256: string;
71
- }
72
-
73
- export type FusionChildResultUsageMetadata = Usage;
74
-
75
- export interface FusionChildResultMetadata {
76
- schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
77
- provider: string;
78
- model: string;
79
- stop_reason: string;
80
- text_blocks: FusionChildTextBlockMetadata[];
81
- text_sha256: string;
82
- usage: FusionChildResultUsageMetadata;
83
- }
84
-
85
86
  function sha256(value: string | Buffer): string {
86
87
  return createHash('sha256').update(value).digest('hex');
87
88
  }
@@ -119,42 +120,32 @@ function appendToolCallLogLine(path: string, record: FusionToolCallLogRecord): v
119
120
  }
120
121
  }
121
122
 
122
- export function buildFusionChildResultMetadata(message: {
123
- provider: string;
124
- model: string;
125
- stopReason: string;
126
- content: ReadonlyArray<{ type: string; text?: string }>;
127
- usage: Usage;
128
- }): FusionChildResultMetadata {
129
- const textBlocks = message.content.flatMap((part) =>
130
- part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
131
- );
132
- const usage: FusionChildResultUsageMetadata = {
133
- input: message.usage.input,
134
- output: message.usage.output,
135
- cacheRead: message.usage.cacheRead,
136
- cacheWrite: message.usage.cacheWrite,
137
- totalTokens: message.usage.totalTokens,
138
- cost: {
139
- input: message.usage.cost.input,
140
- output: message.usage.cost.output,
141
- cacheRead: message.usage.cost.cacheRead,
142
- cacheWrite: message.usage.cost.cacheWrite,
143
- total: message.usage.cost.total,
144
- },
145
- };
146
- return {
147
- schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
148
- provider: message.provider,
149
- model: message.model,
150
- stop_reason: message.stopReason,
151
- text_blocks: textBlocks.map((text) => ({
152
- utf8_bytes: Buffer.byteLength(text, 'utf8'),
153
- sha256: sha256(text),
154
- })),
155
- text_sha256: sha256(textBlocks.join('')),
156
- usage,
157
- };
123
+ function writeToolCallLogSeal(
124
+ path: string,
125
+ recordCount: number,
126
+ totalResultBytes: number,
127
+ complete: boolean,
128
+ ): void {
129
+ const logBytes = readFileSync(path);
130
+ const seal = {
131
+ schema_version: FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
132
+ status: complete ? 'complete' : 'failed',
133
+ record_count: recordCount,
134
+ total_result_bytes: totalResultBytes,
135
+ log_sha256: sha256(logBytes),
136
+ } as const;
137
+ const bytes = Buffer.from(`${JSON.stringify(seal)}\n`, 'utf8');
138
+ let fd: number | undefined;
139
+ try {
140
+ fd = openSync(`${path}${FUSION_TOOL_CALL_SEAL_SUFFIX}`, 'wx', 0o600);
141
+ const written = writeSync(fd, bytes);
142
+ if (written !== bytes.length) {
143
+ throw new Error(`fusion tool-call seal short write: ${String(written)} of ${String(bytes.length)} bytes`);
144
+ }
145
+ fsyncSync(fd);
146
+ } finally {
147
+ if (fd !== undefined) closeSync(fd);
148
+ }
158
149
  }
159
150
 
160
151
  async function writeMetadata(record: FusionChildResultMetadata): Promise<void> {
@@ -199,7 +190,9 @@ function stringField(value: object, key: string): string | undefined {
199
190
  }
200
191
 
201
192
  function fetchAuditMetadataFromObject(value: object, fallbackUrl: string): FusionWebFetchAuditMetadata {
202
- const metadata: FusionWebFetchAuditMetadata = { url: stringField(value, 'url') ?? fallbackUrl };
193
+ const metadata: FusionWebFetchAuditMetadata = {
194
+ url: stringField(value, 'url') ?? canonicalizeFusionPublicUrl(fallbackUrl),
195
+ };
203
196
  const finalUrl = stringField(value, 'final_url');
204
197
  if (finalUrl !== undefined) metadata.final_url = finalUrl;
205
198
  const status = numberField(value, 'status');
@@ -211,15 +204,48 @@ function fetchAuditMetadataFromObject(value: object, fallbackUrl: string): Fusio
211
204
  return metadata;
212
205
  }
213
206
 
214
- function fetchAuditMetadataFromError(error: unknown, fallbackUrl: string): FusionWebFetchAuditMetadata {
215
- if (!(error instanceof FusionWebFetchError) || typeof error !== 'object' || error === null) {
216
- return { url: fallbackUrl };
207
+ function fetchAuditMetadataFromError(error: unknown, attemptedUrl: string): FusionWebFetchAuditMetadata {
208
+ const metadata: FusionWebFetchAuditMetadata = { rejected_url_sha256: sha256(attemptedUrl) };
209
+ if (error instanceof FusionWebFetchError && typeof error === 'object' && error !== null) {
210
+ const status = numberField(error, 'status');
211
+ if (status !== undefined) metadata.http_status = status;
212
+ }
213
+ return metadata;
214
+ }
215
+
216
+
217
+ function readRegularFileNoSymlinkSync(path: string, label: string): Buffer {
218
+ let fd: number | undefined;
219
+ try {
220
+ fd = openSync(path, constants.O_RDONLY | FUSION_CHILD_O_NOFOLLOW);
221
+ } catch (error) {
222
+ if (typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ELOOP') {
223
+ throw new Error(`${label} at ${path} is a symlink; refusing to follow it`);
224
+ }
225
+ throw error;
226
+ }
227
+ try {
228
+ const stats = fstatSync(fd);
229
+ if (!stats.isFile()) throw new Error(`${label} at ${path} is not a regular file`);
230
+ return readFileSync(fd);
231
+ } finally {
232
+ if (fd !== undefined) closeSync(fd);
217
233
  }
218
- const result = Reflect.get(error, 'result');
219
- if (typeof result === 'object' && result !== null) {
220
- return fetchAuditMetadataFromObject(result, fallbackUrl);
234
+ }
235
+
236
+ function loadDeclaredResearchUrls(): ReadonlySet<string> {
237
+ const policyPath = process.env[FUSION_SOURCE_POLICY_PATH_ENV];
238
+ const expectedHash = process.env[FUSION_SOURCE_POLICY_SHA256_ENV];
239
+ if (policyPath === undefined || expectedHash === undefined) {
240
+ throw new Error(`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires source policy path and sha256`);
221
241
  }
222
- return fetchAuditMetadataFromObject(error, fallbackUrl);
242
+ if (!/^[0-9a-f]{64}$/.test(expectedHash)) throw new Error('fusion source policy hash is malformed');
243
+ const bytes = readRegularFileNoSymlinkSync(policyPath, 'fusion source policy');
244
+ if (sha256(bytes) !== expectedHash) throw new Error('fusion source policy hash mismatch');
245
+ const text = bytes.toString('utf8');
246
+ if (!Buffer.from(text, 'utf8').equals(bytes)) throw new Error('fusion source policy is not UTF-8');
247
+ const parsed = parseFusionSourcePolicy(JSON.parse(text));
248
+ return new Set(parsed.sources.map((source) => source.canonical_url));
223
249
  }
224
250
 
225
251
  function fusionWebFetchResultText(result: Awaited<ReturnType<typeof fusionWebFetch>>): string {
@@ -255,6 +281,7 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
255
281
  if (researchEnabled === '1' && toolCallLogPath === undefined) {
256
282
  throw new Error(`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires ${FUSION_TOOL_CALL_LOG_PATH_ENV}`);
257
283
  }
284
+ const declaredResearchUrls = researchEnabled === '1' ? loadDeclaredResearchUrls() : undefined;
258
285
  const fetchAuditMetadata = new Map<string, FusionWebFetchAuditMetadata>();
259
286
  if (toolCallLogPath !== undefined) {
260
287
  // Create the log immediately, before tools can run. Without this, an absent file
@@ -264,54 +291,59 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
264
291
  closeSync(openSync(toolCallLogPath, 'a', 0o600));
265
292
  let ordinal = 0;
266
293
  let totalToolResultBytes = 0;
294
+ let auditFailed = false;
267
295
  const starts = new Map<string, number>();
268
296
  pi.on('tool_call', (event) => {
269
297
  starts.set(event.toolCallId, Date.now());
270
298
  });
271
299
  pi.on('tool_result', (event) => {
272
- const start = starts.get(event.toolCallId);
273
- if (start === undefined) {
274
- throw new Error(`fusion tool-call log missing start for ${event.toolCallId}`);
275
- }
276
- starts.delete(event.toolCallId);
277
- const argumentsBytes = utf8JsonBytes(event.input, 'arguments');
278
- const resultBytes = utf8JsonBytes(
279
- {
280
- content: event.content,
281
- details: event.details,
282
- isError: event.isError,
283
- usage: event.usage,
284
- },
285
- 'result',
286
- );
287
- const fetchMetadata = fetchAuditMetadata.get(event.toolCallId);
288
- fetchAuditMetadata.delete(event.toolCallId);
289
- const record: FusionToolCallLogRecord = {
290
- schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
291
- ordinal,
292
- tool_name: event.toolName,
293
- arguments_sha256: sha256(argumentsBytes),
294
- arguments_bytes: argumentsBytes.length,
295
- result_bytes: resultBytes.length,
296
- result_sha256: sha256(resultBytes),
297
- status: event.isError === true ? 'error' : 'ok',
298
- duration_ms: Math.max(0, Date.now() - start),
299
- ...(fetchMetadata === undefined ? {} : fetchMetadata),
300
- };
301
- ordinal += 1;
302
- appendToolCallLogLine(toolCallLogPath, record);
303
- // Aggregate output ceiling. There is no tool-CALL cap in v1 by design, so bytes are
304
- // the only bound on how much a read-only candidate can pull into its context. The
305
- // record is durable before this check, so the offending call stays auditable; the
306
- // failure is loud rather than a truncation, because a silently shortened tool result
307
- // would corrupt the candidate's reasoning with no signal at all.
308
- totalToolResultBytes += resultBytes.length;
309
- if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
310
- throw new Error(
311
- `fusion candidate exceeded the aggregate tool-output budget: ${String(totalToolResultBytes)} bytes across ${String(ordinal)} calls exceeds ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
300
+ try {
301
+ const start = starts.get(event.toolCallId);
302
+ if (start === undefined) {
303
+ throw new Error(`fusion tool-call log missing start for ${event.toolCallId}`);
304
+ }
305
+ starts.delete(event.toolCallId);
306
+ const argumentsBytes = utf8JsonBytes(event.input, 'arguments');
307
+ const resultBytes = utf8JsonBytes(
308
+ {
309
+ content: event.content,
310
+ details: event.details,
311
+ isError: event.isError,
312
+ usage: event.usage,
313
+ },
314
+ 'result',
312
315
  );
316
+ const fetchMetadata = fetchAuditMetadata.get(event.toolCallId);
317
+ fetchAuditMetadata.delete(event.toolCallId);
318
+ const record: FusionToolCallLogRecord = {
319
+ schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
320
+ ordinal,
321
+ tool_name: event.toolName,
322
+ arguments_sha256: sha256(argumentsBytes),
323
+ arguments_bytes: argumentsBytes.length,
324
+ result_bytes: resultBytes.length,
325
+ result_sha256: sha256(resultBytes),
326
+ status: event.isError === true ? 'error' : 'ok',
327
+ duration_ms: Math.max(0, Date.now() - start),
328
+ ...(fetchMetadata === undefined ? {} : fetchMetadata),
329
+ };
330
+ ordinal += 1;
331
+ appendToolCallLogLine(toolCallLogPath, record);
332
+ totalToolResultBytes += resultBytes.length;
333
+ if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
334
+ throw new Error(
335
+ `fusion candidate exceeded the aggregate tool-output budget: ${String(totalToolResultBytes)} bytes across ${String(ordinal)} calls exceeds ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
336
+ );
337
+ }
338
+ } catch (error) {
339
+ auditFailed = true;
340
+ throw error;
313
341
  }
314
342
  });
343
+ pi.on('agent_end', () => {
344
+ const complete = !auditFailed && starts.size === 0;
345
+ writeToolCallLogSeal(toolCallLogPath, ordinal, totalToolResultBytes, complete);
346
+ });
315
347
  }
316
348
 
317
349
  if (researchEnabled === '1') {
@@ -332,10 +364,17 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
332
364
  },
333
365
  async execute(toolCallId, params) {
334
366
  try {
367
+ const canonicalUrl = canonicalizeFusionPublicUrl(params.url);
368
+ if (params.url !== canonicalUrl) {
369
+ throw new Error(`${FUSION_WEB_FETCH_TOOL_NAME} URL must exactly match its declared canonical URL`);
370
+ }
371
+ if (declaredResearchUrls === undefined || !declaredResearchUrls.has(canonicalUrl)) {
372
+ throw new Error(`${FUSION_WEB_FETCH_TOOL_NAME} URL was not declared in the research source policy`);
373
+ }
335
374
  const result = await fusionWebFetch(
336
375
  params.extract === undefined
337
- ? { url: params.url }
338
- : { url: params.url, extract: params.extract },
376
+ ? { url: canonicalUrl }
377
+ : { url: canonicalUrl, extract: params.extract },
339
378
  );
340
379
  fetchAuditMetadata.set(toolCallId, fetchAuditMetadataFromObject(result, params.url));
341
380
  return {