pi-background-tasks 1.0.6 → 2.0.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 (49) hide show
  1. package/README.md +7 -7
  2. package/TESTING.md +3 -3
  3. package/TEST_PLAN.md +2 -2
  4. package/docs/INDEX.md +25 -25
  5. package/docs/choose-a-workflow.md +4 -4
  6. package/docs/commands/bg-clear.md +1 -1
  7. package/docs/commands/bg-update.md +1 -1
  8. package/docs/commands/bg.md +1 -1
  9. package/docs/commands/fusion-models.md +1 -1
  10. package/docs/commands/fusion.md +5 -8
  11. package/docs/commands/jobs.md +1 -1
  12. package/docs/commands/kill.md +1 -1
  13. package/docs/commands/logs.md +1 -1
  14. package/docs/commands/task-manager.md +2 -2
  15. package/docs/concepts/completion-delivery.md +1 -0
  16. package/docs/getting-started.md +1 -1
  17. package/docs/manifest.json +59 -50
  18. package/docs/read-before-edit.md +1 -0
  19. package/docs/reference/runtime-contracts.md +55 -51
  20. package/docs/reference/shortcuts-and-dock.md +2 -2
  21. package/docs/subsystems/background-task-runtime.md +7 -1
  22. package/docs/subsystems/docs-freshness-gate.md +4 -4
  23. package/docs/subsystems/fusion.md +15 -11
  24. package/docs/subsystems/host-ui-and-telemetry.md +1 -1
  25. package/docs/tools/bg_delegate.md +1 -1
  26. package/docs/tools/bg_kill.md +1 -1
  27. package/docs/tools/bg_logs.md +1 -1
  28. package/docs/tools/bg_result.md +14 -10
  29. package/docs/tools/bg_run.md +1 -1
  30. package/docs/tools/bg_run_pi_attested.md +1 -1
  31. package/docs/tools/bg_status.md +1 -1
  32. package/docs/tools/fusion_investigate.md +6 -4
  33. package/docs/tools/fusion_reason.md +5 -5
  34. package/docs/tools/fusion_research.md +6 -2
  35. package/docs/tools/fusion_validate.md +5 -3
  36. package/package.json +1 -1
  37. package/src/core/common.ts +50 -2
  38. package/src/core/fusion/artifacts.ts +106 -13
  39. package/src/core/fusion/budget.ts +12 -4
  40. package/src/core/fusion/evaluation.ts +61 -0
  41. package/src/core/fusion/orchestrator.ts +270 -73
  42. package/src/core/fusion/pi-child.ts +6 -0
  43. package/src/core/fusion/prompts.ts +1 -0
  44. package/src/core/fusion/result-package.ts +385 -0
  45. package/src/core/fusion/types.ts +19 -1
  46. package/src/core/registry.ts +187 -20
  47. package/src/delegate-extension.ts +130 -24
  48. package/src/extension.ts +17 -6
  49. package/src/fusion-extension.ts +308 -154
@@ -28,6 +28,7 @@ import {
28
28
  type KillKind,
29
29
  type StartAttestedPiTaskOptions,
30
30
  type StartDelegateTaskOptions,
31
+ type StartManagedTaskOptions,
31
32
  type StartTaskOptions,
32
33
  type TaskContextUsage,
33
34
  type TaskStatus,
@@ -740,11 +741,13 @@ export class BackgroundTaskRegistry {
740
741
  this.killProcess = options.killProcess ?? process.kill.bind(process);
741
742
  this.platform = options.platform ?? process.platform;
742
743
  this.env = options.env ?? process.env;
743
- this.killTree = options.killTree ?? ((pid, phase, signal) => {
744
- const taskkillOptions: WindowsTaskkillOptions =
745
- signal === undefined ? { env: this.env } : { env: this.env, signal };
746
- return runWindowsTaskkill(pid, phase, taskkillOptions);
747
- });
744
+ this.killTree =
745
+ options.killTree ??
746
+ ((pid, phase, signal) => {
747
+ const taskkillOptions: WindowsTaskkillOptions =
748
+ signal === undefined ? { env: this.env } : { env: this.env, signal };
749
+ return runWindowsTaskkill(pid, phase, taskkillOptions);
750
+ });
748
751
  this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
749
752
  this.now = options.now ?? Date.now;
750
753
  this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
@@ -869,7 +872,8 @@ export class BackgroundTaskRegistry {
869
872
  let commandToSpawn = normalizedCommand;
870
873
  if (piTelemetryRequested) {
871
874
  if (baseInvocation.dialect === 'posix') {
872
- if (piTelemetryLaunch === undefined) throw new Error('Pi telemetry launch spec was not resolved');
875
+ if (piTelemetryLaunch === undefined)
876
+ throw new Error('Pi telemetry launch spec was not resolved');
873
877
  const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
874
878
  await writeFile(
875
879
  wrapperAbsPath,
@@ -962,6 +966,128 @@ export class BackgroundTaskRegistry {
962
966
  }
963
967
  }
964
968
 
969
+ /**
970
+ * Track an in-process asynchronous workflow through the same durable task,
971
+ * notification, status, log, and cancellation surfaces as child processes.
972
+ * The supplied completion promise must own all workflow cleanup before it
973
+ * settles; terminal publication happens only after that settlement.
974
+ */
975
+ async startManagedTask(
976
+ ctx: BackgroundTaskContext,
977
+ request: StartManagedTaskOptions,
978
+ ): Promise<BgTask> {
979
+ if (this.shuttingDown)
980
+ throw new Error('Cannot start a managed background task while Pi is shutting down');
981
+ if (!/^[a-zA-Z0-9_.-]+$/u.test(request.id))
982
+ throw new Error(`Managed background task id is invalid: ${request.id}`);
983
+ if (this.tasks.has(request.id))
984
+ throw new Error(`Background task id already exists: ${request.id}`);
985
+
986
+ const dir = await this.ensureRuntimeDir(ctx);
987
+ const outputAbsPath = join(dir.abs, `${request.id}.output`);
988
+ const metadataAbsPath = join(dir.abs, `${request.id}.json`);
989
+ const outputPath = join(dir.display, `${request.id}.output`);
990
+ const task: BgTask = {
991
+ id: request.id,
992
+ name: normalizeTaskName(request.name) ?? 'Managed background task',
993
+ command: request.command,
994
+ description: request.description,
995
+ status: 'running',
996
+ outputPath,
997
+ outputAbsPath,
998
+ metadataAbsPath,
999
+ cwd: ctx.cwd,
1000
+ startTime: this.now(),
1001
+ exitCode: undefined,
1002
+ pid: undefined,
1003
+ bytesWritten: 0,
1004
+ isAgent: request.isAgent,
1005
+ notified: false,
1006
+ notifyOnCompletion: request.notifyOnCompletion,
1007
+ triggerOnCompletion: request.triggerOnCompletion,
1008
+ fusion: request.fusion,
1009
+ managedCancel: request.cancel,
1010
+ managedStopWaitMs: request.stopWaitMs,
1011
+ terminalPublicationGate: request.terminalPublicationGate,
1012
+ waiters: [],
1013
+ };
1014
+ this.tasks.set(task.id, task);
1015
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1016
+ task.stream = stream;
1017
+ stream.on('error', (error) => {
1018
+ task.error = `Output file write failed: ${error.message}`;
1019
+ if (task.status === 'running' && !task.managedCancelRequested) {
1020
+ task.managedCancelRequested = true;
1021
+ try {
1022
+ request.cancel();
1023
+ } catch (cancelError) {
1024
+ task.error = `${task.error}; cancellation failed: ${BackgroundTaskRegistry.errorMessage(cancelError)}`;
1025
+ }
1026
+ }
1027
+ });
1028
+
1029
+ try {
1030
+ await this.writeMetadata(task);
1031
+ this.onChange();
1032
+ } catch (error) {
1033
+ this.tasks.delete(task.id);
1034
+ if (!stream.destroyed) stream.destroy();
1035
+ try {
1036
+ request.cancel();
1037
+ } catch (cancelError) {
1038
+ this.logger.error(
1039
+ `[background-tasks] managed task cancellation after metadata failure also failed for ${task.id}:`,
1040
+ cancelError,
1041
+ );
1042
+ }
1043
+ throw new Error(
1044
+ `Failed to register managed background task: ${BackgroundTaskRegistry.errorMessage(error)}`,
1045
+ );
1046
+ }
1047
+
1048
+ void request.completion
1049
+ .then(
1050
+ () => this.finalizeTask(task, 'completed', 0),
1051
+ (error: unknown) => {
1052
+ const message = BackgroundTaskRegistry.errorMessage(error);
1053
+ const killed = task.killKind === 'user' || task.killKind === 'shutdown';
1054
+ return this.finalizeTask(task, killed ? 'killed' : 'failed', null, undefined, message);
1055
+ },
1056
+ )
1057
+ .catch((error: unknown) => {
1058
+ this.logger.error(
1059
+ `[background-tasks] managed task finalization failed for ${task.id}:`,
1060
+ error,
1061
+ );
1062
+ });
1063
+ return task;
1064
+ }
1065
+
1066
+ async updateManagedTask(task: BgTask, state: string, line?: string): Promise<void> {
1067
+ if (task.status !== 'running' || task.fusion === undefined) return;
1068
+ task.fusion.state = state;
1069
+ if (line !== undefined && line.length > 0) this.writeNotice(task, `${line}\n`);
1070
+ await this.writeMetadata(task);
1071
+ this.onChange();
1072
+ }
1073
+
1074
+ /** Claim deferred Fusion usage exactly once before returning it from bg_result. */
1075
+ async claimFusionUsage(task: BgTask): Promise<boolean> {
1076
+ if (task.fusion === undefined) throw new Error(`Task ${task.id} is not a Fusion task`);
1077
+ let claimed = false;
1078
+ const write = async () => {
1079
+ if (!task.fusion || task.fusion.usageDelivered) return;
1080
+ task.fusion.usageDelivered = true;
1081
+ await writeJsonAtomic(task.metadataAbsPath, snapshot(task));
1082
+ claimed = true;
1083
+ };
1084
+ const previous = task.metadataWriteChain ?? Promise.resolve();
1085
+ const next = previous.then(write, write);
1086
+ task.metadataWriteChain = next.catch(() => undefined);
1087
+ await next;
1088
+ return claimed;
1089
+ }
1090
+
965
1091
  /**
966
1092
  * Start a prepared delegate child.
967
1093
  *
@@ -1420,15 +1546,16 @@ export class BackgroundTaskRegistry {
1420
1546
  task.killKind = kind;
1421
1547
  if (reason) task.error = reason;
1422
1548
  this.requestKill(task, 'SIGTERM');
1549
+ const stopWaitMs = task.managedStopWaitMs ?? this.stopWaitMs;
1423
1550
  const stopped =
1424
- this.platform === 'win32'
1425
- ? await this.waitForEndOrWindowsForceFailure(task, this.stopWaitMs)
1426
- : await this.waitForEnd(task, this.stopWaitMs);
1551
+ this.platform === 'win32' && task.managedCancel === undefined
1552
+ ? await this.waitForEndOrWindowsForceFailure(task, stopWaitMs)
1553
+ : await this.waitForEnd(task, stopWaitMs);
1427
1554
  const forceFailure = this.windowsKillStates.get(task)?.forceFailure;
1428
1555
  if (forceFailure !== undefined) throw forceFailure;
1429
1556
  if (!stopped) {
1430
1557
  throw new Error(
1431
- `Task ${task.id} did not exit within ${formatDuration(this.stopWaitMs)} after SIGTERM/SIGKILL`,
1558
+ `Task ${task.id} did not exit within ${formatDuration(stopWaitMs)} after cancellation`,
1432
1559
  );
1433
1560
  }
1434
1561
  return task;
@@ -1880,7 +2007,10 @@ export class BackgroundTaskRegistry {
1880
2007
  const rejectForceReady = rejectForce;
1881
2008
  state.forcePromise = forcePromise;
1882
2009
  void forcePromise.catch((error: unknown) => {
1883
- this.logger.error(`[background-tasks] Windows force tree termination failed for ${task.id}:`, error);
2010
+ this.logger.error(
2011
+ `[background-tasks] Windows force tree termination failed for ${task.id}:`,
2012
+ error,
2013
+ );
1884
2014
  });
1885
2015
 
1886
2016
  this.clearKillEscalationTimer(task);
@@ -1922,7 +2052,11 @@ export class BackgroundTaskRegistry {
1922
2052
  resolveForceReady();
1923
2053
  return;
1924
2054
  }
1925
- const failure = this.makeWindowsForceFailure(task, pid, BackgroundTaskRegistry.errorMessage(error));
2055
+ const failure = this.makeWindowsForceFailure(
2056
+ task,
2057
+ pid,
2058
+ BackgroundTaskRegistry.errorMessage(error),
2059
+ );
1926
2060
  this.recordWindowsForceFailure(task, failure);
1927
2061
  rejectForceReady(failure);
1928
2062
  },
@@ -1965,6 +2099,19 @@ export class BackgroundTaskRegistry {
1965
2099
  if (task.status !== 'running') {
1966
2100
  throw new Error(`Task ${task.id} is ${task.status}, not running`);
1967
2101
  }
2102
+ if (task.managedCancel !== undefined) {
2103
+ if (task.managedCancelRequested) return;
2104
+ task.managedCancelRequested = true;
2105
+ try {
2106
+ task.managedCancel();
2107
+ } catch (error) {
2108
+ throw new Error(
2109
+ `Could not cancel managed task ${task.id}: ${BackgroundTaskRegistry.errorMessage(error)}`,
2110
+ );
2111
+ }
2112
+ task.killSignalSent = true;
2113
+ return;
2114
+ }
1968
2115
  if (!task.child) {
1969
2116
  throw new Error(`Task ${task.id} has no child process handle`);
1970
2117
  }
@@ -2145,6 +2292,12 @@ export class BackgroundTaskRegistry {
2145
2292
  task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
2146
2293
  const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
2147
2294
  const taskName = taskDisplayName(task);
2295
+ const guidance =
2296
+ task.fusion === undefined
2297
+ ? 'Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.'
2298
+ : task.status === 'completed'
2299
+ ? `Fusion result is durably committed at ${task.fusion.artifactDir}. Call bg_result({taskId:${JSON.stringify(task.id)}}) once to retrieve it; do not poll.`
2300
+ : `Fusion ended ${task.status}. Inspect the preserved artifacts at ${task.fusion.artifactDir}; do not poll.`;
2148
2301
  const content = [
2149
2302
  '<background-task-notification>',
2150
2303
  ` <task-id>${task.id}</task-id>`,
@@ -2154,7 +2307,7 @@ export class BackgroundTaskRegistry {
2154
2307
  error,
2155
2308
  ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
2156
2309
  ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
2157
- ' <guidance>Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.</guidance>',
2310
+ ` <guidance>${escapeXml(guidance)}</guidance>`,
2158
2311
  '</background-task-notification>',
2159
2312
  ]
2160
2313
  .filter(Boolean)
@@ -2250,13 +2403,27 @@ export class BackgroundTaskRegistry {
2250
2403
  for (const waiter of task.waiters.splice(0)) waiter();
2251
2404
  this.onChange();
2252
2405
  this.publishTerminal(task);
2253
- try {
2254
- this.notifyCompletion(task);
2255
- } catch (notificationError) {
2256
- this.logger.error(
2257
- `[background-tasks] notification failed for ${task.id}:`,
2258
- notificationError,
2259
- );
2406
+ let deliveryGateReady = true;
2407
+ if (task.terminalPublicationGate !== undefined) {
2408
+ try {
2409
+ await task.terminalPublicationGate;
2410
+ } catch (error) {
2411
+ deliveryGateReady = false;
2412
+ this.logger.error(
2413
+ `[background-tasks] completion delivery gate failed for ${task.id}:`,
2414
+ error,
2415
+ );
2416
+ }
2417
+ }
2418
+ if (deliveryGateReady) {
2419
+ try {
2420
+ this.notifyCompletion(task);
2421
+ } catch (notificationError) {
2422
+ this.logger.error(
2423
+ `[background-tasks] notification failed for ${task.id}:`,
2424
+ notificationError,
2425
+ );
2426
+ }
2260
2427
  }
2261
2428
  try {
2262
2429
  await this.writeMetadata(task);
@@ -10,6 +10,9 @@ import { Text } from '@earendil-works/pi-tui';
10
10
  import { Type, type Static } from 'typebox';
11
11
  import type { BgTask, BgTaskSnapshot, StartDelegateTaskOptions } from './core/common.js';
12
12
  import { truncateChars } from './core/common.js';
13
+ import { sha256Buffer } from './core/attested-pi-run.js';
14
+ import { readFusionCommittedResult } from './core/fusion/result-package.js';
15
+ import { cloneFusionUsage, type FusionUsage, type FusionWorkflowId } from './core/fusion/types.js';
13
16
  import {
14
17
  DELEGATE_AUTO_DELIVER_MODES,
15
18
  DELEGATE_CAPABILITIES,
@@ -74,7 +77,10 @@ const DelegateParams = Type.Object(
74
77
  provider: Type.String({ description: 'Exact provider name to pin.' }),
75
78
  model: Type.String({ description: 'Exact provider-local model id to pin.' }),
76
79
  },
77
- { additionalProperties: false, description: 'Explicit route. Defaults to the current model.' },
80
+ {
81
+ additionalProperties: false,
82
+ description: 'Explicit route. Defaults to the current model.',
83
+ },
78
84
  ),
79
85
  ),
80
86
  capability: Type.Optional(
@@ -83,7 +89,9 @@ const DelegateParams = Type.Object(
83
89
  }),
84
90
  ),
85
91
  maxTurns: Type.Optional(
86
- Type.Number({ description: `Maximum agent turns. Default ${String(DELEGATE_DEFAULT_MAX_TURNS)}.` }),
92
+ Type.Number({
93
+ description: `Maximum agent turns. Default ${String(DELEGATE_DEFAULT_MAX_TURNS)}.`,
94
+ }),
87
95
  ),
88
96
  maxToolCalls: Type.Optional(
89
97
  Type.Number({
@@ -113,7 +121,9 @@ const DelegateParams = Type.Object(
113
121
 
114
122
  const ResultParams = Type.Object(
115
123
  {
116
- taskId: Type.String({ description: 'Delegate task id returned by bg_delegate.' }),
124
+ taskId: Type.String({
125
+ description: 'Background delegate or Fusion task id returned by its launch tool.',
126
+ }),
117
127
  delivery: Type.Optional(
118
128
  Type.String({
119
129
  description:
@@ -141,6 +151,20 @@ export interface DelegateLaunchDetails {
141
151
  trigger_on_completion: boolean;
142
152
  }
143
153
 
154
+ export interface FusionBackgroundResultDetails {
155
+ schema_version: 'pi-background-tasks.fusion-result-view.v1';
156
+ task_id: string;
157
+ state: 'running' | 'committed' | 'failed' | 'cancelled';
158
+ delivery: DelegateDeliveryMode | 'none';
159
+ workflow: FusionWorkflowId;
160
+ artifact_dir: string;
161
+ answer_bytes?: number | undefined;
162
+ answer_sha256?: string | undefined;
163
+ usage_delivered?: boolean | undefined;
164
+ }
165
+
166
+ export type BackgroundResultDetails = DelegateResultDetails | FusionBackgroundResultDetails;
167
+
144
168
  export interface DelegateResultDetails {
145
169
  schema_version: 'pi-background-tasks.delegate-result-view.v1';
146
170
  task_id: string;
@@ -217,7 +241,12 @@ function requireRoute(value: unknown): DelegateRoute | undefined {
217
241
 
218
242
  function optionalPositiveInteger(value: unknown, label: string): number | undefined {
219
243
  if (value === undefined) return undefined;
220
- if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0)
244
+ if (
245
+ typeof value !== 'number' ||
246
+ !Number.isFinite(value) ||
247
+ !Number.isInteger(value) ||
248
+ value <= 0
249
+ )
221
250
  throw new DelegateError(`bg_delegate ${label} must be a positive integer`, {
222
251
  code: 'invalid_arguments',
223
252
  childCreated: false,
@@ -226,12 +255,10 @@ function optionalPositiveInteger(value: unknown, label: string): number | undefi
226
255
  }
227
256
 
228
257
  export interface DelegateExtensionDependencies {
229
- startDelegateTask: (
230
- ctx: ExtensionContext,
231
- options: StartDelegateTaskOptions,
232
- ) => Promise<BgTask>;
258
+ startDelegateTask: (ctx: ExtensionContext, options: StartDelegateTaskOptions) => Promise<BgTask>;
233
259
  snapshot: (task: BgTask) => BgTaskSnapshot;
234
260
  resolveTask: (idOrPrefix: string) => BgTask;
261
+ claimFusionUsage: (task: BgTask) => Promise<boolean>;
235
262
  /** Overridable so tests can supply observed evidence without touching disk. */
236
263
  loadHookEvidence?: (() => Promise<DelegateHookContractEvidence>) | undefined;
237
264
  }
@@ -326,13 +353,11 @@ export function registerDelegateExtension(
326
353
  id: ctx.model.id,
327
354
  contextWindow: ctx.model.contextWindow,
328
355
  },
329
- availableModels: ctx.modelRegistry
330
- .getAll()
331
- .map((model) => ({
332
- provider: model.provider,
333
- id: model.id,
334
- contextWindow: model.contextWindow,
335
- })),
356
+ availableModels: ctx.modelRegistry.getAll().map((model) => ({
357
+ provider: model.provider,
358
+ id: model.id,
359
+ contextWindow: model.contextWindow,
360
+ })),
336
361
  thinkingLevel: pi.getThinkingLevel(),
337
362
  });
338
363
 
@@ -425,15 +450,15 @@ export function registerDelegateExtension(
425
450
  },
426
451
  });
427
452
 
428
- pi.registerTool<typeof ResultParams, DelegateResultDetails>({
453
+ pi.registerTool<typeof ResultParams, BackgroundResultDetails>({
429
454
  name: DELEGATE_RESULT_TOOL_NAME,
430
- label: 'Delegate Result',
455
+ label: 'Background Result',
431
456
  description:
432
- 'Retrieve the hash-verified answer from a bg_delegate task. Never blocks: a running task returns a typed not-ready result. A completed answer is verified against its recorded SHA-256 before it is returned, and an oversized answer is never truncated.',
433
- promptSnippet: 'Retrieve the verified answer from a completed bg_delegate task',
457
+ 'Retrieve a hash-verified result from a bg_delegate or background Fusion task. Never blocks: a running task returns a typed not-ready result. Oversized answers are never truncated.',
458
+ promptSnippet: 'Retrieve the verified answer from a completed delegate or Fusion task',
434
459
  promptGuidelines: [
435
- 'Call bg_result once the delegate terminal notification has arrived. It never blocks and must not be polled.',
436
- 'A not-ready result means the delegate is still running; end the turn and wait for the notification.',
460
+ 'Call bg_result once the delegate or Fusion terminal notification has arrived. It never blocks and must not be polled.',
461
+ 'A not-ready result means the task is still running; end the turn and wait for the notification.',
437
462
  ],
438
463
  parameters: ResultParams,
439
464
  prepareArguments(args): ResultParamsValue {
@@ -463,10 +488,86 @@ export function registerDelegateExtension(
463
488
  { code: 'task_unknown', childCreated: false },
464
489
  );
465
490
  }
491
+ const fusion = task.fusion;
492
+ if (fusion !== undefined) {
493
+ const requestedDelivery = requireDelivery(params.delivery);
494
+ if (task.status === 'running') {
495
+ const details: FusionBackgroundResultDetails = {
496
+ schema_version: 'pi-background-tasks.fusion-result-view.v1',
497
+ task_id: task.id,
498
+ state: 'running',
499
+ delivery: 'none',
500
+ workflow: fusion.workflow,
501
+ artifact_dir: fusion.artifactDir,
502
+ };
503
+ return {
504
+ content: textContent(
505
+ `Fusion ${task.id} is still running. bg_result never blocks. End this turn; the terminal notification will wake you, then call bg_result again.`,
506
+ ),
507
+ details,
508
+ };
509
+ }
510
+ if (task.status !== 'completed' || fusion.outcome?.status !== 'committed') {
511
+ throw new Error(
512
+ `Fusion ${task.id} did not commit a result (${task.status}): ${fusion.outcome?.error ?? task.error ?? 'no terminal detail'}`,
513
+ );
514
+ }
515
+ const verified = await readFusionCommittedResult({
516
+ artifactDirAbs: fusion.artifactDirAbs,
517
+ artifactDir: fusion.artifactDir,
518
+ runId: fusion.runId,
519
+ workflow: fusion.workflow,
520
+ });
521
+ const answerBytes = Buffer.byteLength(verified.mergedText, 'utf8');
522
+ const answerSha256 = sha256Buffer(Buffer.from(verified.mergedText, 'utf8'));
523
+ const useArtifact =
524
+ requestedDelivery === 'artifact' ||
525
+ (requestedDelivery === undefined && answerBytes > DELEGATE_INLINE_ANSWER_BYTES);
526
+ if (requestedDelivery === 'inline' && answerBytes > DELEGATE_INLINE_ANSWER_BYTES) {
527
+ throw new Error(
528
+ `Fusion result ${task.id} is ${String(answerBytes)} bytes, above the ${String(DELEGATE_INLINE_ANSWER_BYTES)}-byte inline limit. Use delivery:"artifact"; nothing was truncated.`,
529
+ );
530
+ }
531
+ const usageDelivered = await deps.claimFusionUsage(task);
532
+ const details: FusionBackgroundResultDetails = {
533
+ schema_version: 'pi-background-tasks.fusion-result-view.v1',
534
+ task_id: task.id,
535
+ state: 'committed',
536
+ delivery: useArtifact ? 'artifact' : 'inline',
537
+ workflow: fusion.workflow,
538
+ artifact_dir: fusion.artifactDir,
539
+ answer_bytes: answerBytes,
540
+ answer_sha256: answerSha256,
541
+ usage_delivered: usageDelivered,
542
+ };
543
+ const header = [
544
+ `Fusion ${task.id} completed (${fusion.workflow}).`,
545
+ `Answer: ${String(answerBytes)} bytes, ${answerSha256} (verified).`,
546
+ `Artifacts: ${fusion.artifactDir}`,
547
+ usageDelivered
548
+ ? 'Usage: attached to this retrieval exactly once.'
549
+ : 'Usage: already attached by an earlier retrieval; not counted again.',
550
+ ].join('\n');
551
+ const result = useArtifact
552
+ ? {
553
+ content: textContent(
554
+ `${header}\nDelivery: artifact. The complete answer is ${fusion.artifactDir}/merged.md; it was not truncated.`,
555
+ ),
556
+ details,
557
+ }
558
+ : { content: textContent(`${header}\n\n${verified.mergedText}`), details };
559
+ if (!usageDelivered) return result;
560
+ const resultWithUsage: typeof result & { usage: FusionUsage } = {
561
+ ...result,
562
+ usage: cloneFusionUsage(verified.details.usage),
563
+ };
564
+ return resultWithUsage;
565
+ }
566
+
466
567
  const facts = task.delegate;
467
568
  if (facts === undefined) {
468
569
  throw new DelegateError(
469
- `bg_result task ${task.id} is not a bg_delegate task; use bg_logs for ordinary background tasks`,
570
+ `bg_result task ${task.id} has no retrievable delegate or Fusion result; use bg_logs for ordinary background tasks`,
470
571
  { code: 'task_unknown', childCreated: false },
471
572
  );
472
573
  }
@@ -565,10 +666,15 @@ export function registerDelegateExtension(
565
666
  renderResult(result, options: ToolRenderResultOptions, theme: Theme) {
566
667
  void options;
567
668
  const details = result.details;
669
+ const fusion = details.schema_version === 'pi-background-tasks.fusion-result-view.v1';
568
670
  if (details.state === 'running')
569
- return new Text(theme.fg('warning', `delegate ${details.task_id} still running`), 0, 0);
671
+ return new Text(
672
+ theme.fg('warning', `${fusion ? 'fusion' : 'delegate'} ${details.task_id} still running`),
673
+ 0,
674
+ 0,
675
+ );
570
676
  return new Text(
571
- `${theme.fg('success', '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
677
+ `${theme.fg('success', fusion ? '✓ fusion answer' : '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
572
678
  0,
573
679
  0,
574
680
  );
package/src/extension.ts CHANGED
@@ -204,8 +204,6 @@ function renderPlainResult(result: TextToolResult, options: ToolRenderResultOpti
204
204
  }
205
205
 
206
206
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
207
- registerFusionExtension(pi);
208
-
209
207
  const seenTaskIds = new Set<string>();
210
208
  let currentCtx: ExtensionContext | undefined;
211
209
  let dockOpen = false;
@@ -213,7 +211,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
213
211
  let latestKnownVersion: string | undefined;
214
212
  let updateCheckStarted = false;
215
213
 
216
- let eventService: BackgroundTaskExtensionService | undefined;
217
214
  const registry = new BackgroundTaskRegistry({
218
215
  onChange: () => {
219
216
  updateUi();
@@ -222,17 +219,25 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
222
219
  pi.sendMessage(message, options);
223
220
  },
224
221
  publishTerminal: (task) => {
225
- if (!eventService) throw new Error('Background task EventBus service is not installed');
226
222
  eventService.publishTerminal(task);
227
223
  },
228
224
  });
229
- eventService = installBackgroundTaskExtensionApi({
225
+ const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
230
226
  events: pi.events,
231
227
  registry,
232
228
  getContext: () => currentCtx,
233
229
  isShuttingDown: () => registry.isShuttingDown(),
234
230
  });
235
231
 
232
+ registerFusionExtension(pi, {
233
+ startManagedTask: async (ctx, options) => {
234
+ currentCtx = ctx;
235
+ return registry.startManagedTask(ctx, options);
236
+ },
237
+ snapshot: (task) => registry.snapshot(task),
238
+ updateManagedTask: (task, state, line) => registry.updateManagedTask(task, state, line),
239
+ });
240
+
236
241
  registerDelegateExtension(pi, {
237
242
  startDelegateTask: async (ctx, options) => {
238
243
  currentCtx = ctx;
@@ -240,6 +245,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
240
245
  },
241
246
  snapshot: (task) => registry.snapshot(task),
242
247
  resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
248
+ claimFusionUsage: (task) => registry.claimFusionUsage(task),
243
249
  });
244
250
 
245
251
  function unseenFinishedTasks(): BgTask[] {
@@ -359,6 +365,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
359
365
  return result;
360
366
  },
361
367
  rerunTask: async (task: BackgroundTaskForUi) => {
368
+ if (task.fusion !== undefined || task.delegate !== undefined) {
369
+ throw new Error(
370
+ 'Only shell-command tasks can be rerun from the dock; relaunch this typed workflow through its owning tool.',
371
+ );
372
+ }
362
373
  const rerunOptions: StartTaskOptions = {
363
374
  name: taskDisplayName(task),
364
375
  isAgent: task.isAgent,
@@ -499,7 +510,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
499
510
  ctx.ui.notify(`Background task cleanup failed:\n${failures.join('\n')}`, 'error');
500
511
  }
501
512
  } finally {
502
- eventService?.close();
513
+ eventService.close();
503
514
  }
504
515
  });
505
516