@pi-unipi/background-tasks 2.16.1 → 2.17.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 (46) hide show
  1. package/README.md +21 -27
  2. package/package.json +3 -4
  3. package/src/cards.ts +76 -0
  4. package/src/child-process.ts +1 -1
  5. package/src/config.ts +0 -42
  6. package/src/context-visible-conversation-v2.ts +1 -1
  7. package/src/delegate/artifacts.ts +1 -1
  8. package/src/delegate/launch.ts +17 -30
  9. package/src/delegate/result-package.ts +1 -1
  10. package/src/delegate/runner.ts +1 -20
  11. package/src/delegate/seed.ts +1 -1
  12. package/src/delegate-extension.ts +16 -168
  13. package/src/index.ts +53 -25
  14. package/src/json-utils.ts +56 -0
  15. package/src/package-assets.ts +51 -0
  16. package/src/registry.ts +8 -459
  17. package/src/task-manager.ts +13 -2
  18. package/src/tools.ts +4 -189
  19. package/src/types.ts +17 -70
  20. package/extensions/anthropic-attribution.ts +0 -1
  21. package/extensions/fusion-child.ts +0 -1
  22. package/src/anthropic-attribution-path.ts +0 -21
  23. package/src/anthropic-attribution.ts +0 -1983
  24. package/src/attested-pi-run.ts +0 -612
  25. package/src/fixtures/fusion-golden-bytes.json +0 -310
  26. package/src/fixtures/fusion-validate-golden-bytes.json +0 -282
  27. package/src/fusion/artifacts.ts +0 -967
  28. package/src/fusion/budget.ts +0 -1162
  29. package/src/fusion/child-protocol.ts +0 -305
  30. package/src/fusion/claude-cache.ts +0 -207
  31. package/src/fusion/clean-context.ts +0 -91
  32. package/src/fusion/config.ts +0 -449
  33. package/src/fusion/context.ts +0 -265
  34. package/src/fusion/evaluation.ts +0 -800
  35. package/src/fusion/orchestrator.ts +0 -1288
  36. package/src/fusion/output-contract.ts +0 -34
  37. package/src/fusion/pi-child.ts +0 -2373
  38. package/src/fusion/prompts.ts +0 -345
  39. package/src/fusion/result-package.ts +0 -959
  40. package/src/fusion/source-policy.ts +0 -257
  41. package/src/fusion/types.ts +0 -1139
  42. package/src/fusion/web-fetch.ts +0 -1060
  43. package/src/fusion/workflows.ts +0 -184
  44. package/src/fusion-child-extension.ts +0 -1052
  45. package/src/fusion-extension.ts +0 -1293
  46. package/src/ui/fusion-model-selector.ts +0 -322
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { fileURLToPath } from 'node:url';
2
+ import { packageAssetSearchHint, resolvePackageAsset } from './package-assets.js';
3
3
  import type {
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
@@ -10,14 +10,7 @@ import { Text } from '@earendil-works/pi-tui';
10
10
  import { Type, type Static } from 'typebox';
11
11
  import type { BgTask, BgTaskSnapshot, StartDelegateTaskOptions } from './types.js';
12
12
  import { truncateChars } from './types.js';
13
- import { sha256Buffer } from './attested-pi-run.js';
14
- import { readFusionCommittedResult, readFusionFailureResult } from './fusion/result-package.js';
15
- import {
16
- cloneFusionUsage,
17
- type FusionFailureResultView,
18
- type FusionUsage,
19
- type FusionWorkflowId,
20
- } from './fusion/types.js';
13
+ import { sha256Buffer } from './json-utils.js';
21
14
  import {
22
15
  DELEGATE_AUTO_DELIVER_MODES,
23
16
  DELEGATE_CAPABILITIES,
@@ -65,9 +58,7 @@ import type { DelegateHookContractEvidence } from './delegate/hook-contract.js';
65
58
  * the shipped copy is byte-identical to the recorded one, so the runtime gate
66
59
  * and the gate that proved it can never drift apart.
67
60
  */
68
- const HOOK_EVIDENCE_PATH = fileURLToPath(
69
- new URL('./delegate/hook-contract-evidence.json', import.meta.url),
70
- );
61
+ const HOOK_EVIDENCE_RELATIVE_PATH = 'src/delegate/hook-contract-evidence.json';
71
62
 
72
63
  export const DelegateParams = Type.Object(
73
64
  {
@@ -135,7 +126,7 @@ export const DelegateParams = Type.Object(
135
126
  const ResultParams = Type.Object(
136
127
  {
137
128
  taskId: Type.String({
138
- description: 'Background delegate or Fusion task id returned by its launch tool.',
129
+ description: 'Background delegate task id returned by bg_delegate.',
139
130
  }),
140
131
  delivery: Type.Optional(
141
132
  Type.String({
@@ -179,29 +170,7 @@ export interface DelegateLaunchDetails {
179
170
  trigger_on_completion: boolean;
180
171
  }
181
172
 
182
- export interface FusionBackgroundResultDetails {
183
- schema_version: 'unipi-background-tasks.fusion-result-view.v1';
184
- task_id: string;
185
- state: 'running' | 'committed' | 'failed' | 'cancelled';
186
- delivery: DelegateDeliveryMode | 'none';
187
- workflow: FusionWorkflowId;
188
- artifact_dir: string;
189
- answer_bytes?: number | undefined;
190
- answer_sha256?: string | undefined;
191
- usage_delivered?: boolean | undefined;
192
- answer?: { present: false; reason: 'run_did_not_commit' } | undefined;
193
- summary_status?: FusionFailureResultView['summary_status'] | undefined;
194
- failure_summary_ref?: FusionFailureResultView['failure_summary_ref'] | undefined;
195
- failure?: FusionFailureResultView['failure'] | undefined;
196
- progress?: FusionFailureResultView['progress'] | undefined;
197
- usage_so_far?: FusionFailureResultView['usage_so_far'] | undefined;
198
- attempts?: FusionFailureResultView['attempts'] | undefined;
199
- evidence_artifacts?: FusionFailureResultView['evidence_artifacts'] | undefined;
200
- remediation_ids?: FusionFailureResultView['remediation_ids'] | undefined;
201
- summary_unavailable_reason?: FusionFailureResultView['summary_unavailable_reason'] | undefined;
202
- }
203
-
204
- export type BackgroundResultDetails = DelegateResultDetails | FusionBackgroundResultDetails;
173
+ export type BackgroundResultDetails = DelegateResultDetails;
205
174
 
206
175
  export interface DelegateResultDetails {
207
176
  schema_version: 'unipi-background-tasks.delegate-result-view.v1';
@@ -306,18 +275,20 @@ export interface DelegateExtensionDependencies {
306
275
  startDelegateTask: (ctx: ExtensionContext, options: StartDelegateTaskOptions) => Promise<BgTask>;
307
276
  snapshot: (task: BgTask) => BgTaskSnapshot;
308
277
  resolveTask: (idOrPrefix: string) => BgTask;
309
- claimFusionUsage: (task: BgTask) => Promise<boolean>;
310
278
  /** Overridable so tests can supply observed evidence without touching disk. */
311
279
  loadHookEvidence?: (() => Promise<DelegateHookContractEvidence>) | undefined;
312
280
  }
313
281
 
314
282
  async function defaultHookEvidence(): Promise<DelegateHookContractEvidence> {
315
283
  let raw: string;
284
+ const evidencePath =
285
+ resolvePackageAsset(HOOK_EVIDENCE_RELATIVE_PATH) ??
286
+ packageAssetSearchHint(HOOK_EVIDENCE_RELATIVE_PATH);
316
287
  try {
317
- raw = await readFile(HOOK_EVIDENCE_PATH, 'utf8');
288
+ raw = await readFile(evidencePath, 'utf8');
318
289
  } catch (error) {
319
290
  throw new DelegateError(
320
- `bg_delegate cannot verify the Pi hook contract: the recorded evidence at ${HOOK_EVIDENCE_PATH} is unreadable (${error instanceof Error ? error.message : String(error)}). No child was created.`,
291
+ `bg_delegate cannot verify the Pi hook contract: the recorded evidence at ${evidencePath} is unreadable (${error instanceof Error ? error.message : String(error)}). No child was created.`,
321
292
  {
322
293
  code: 'delegate_hook_contract_unsupported',
323
294
  childCreated: false,
@@ -517,10 +488,10 @@ export function registerDelegateExtension(
517
488
  name: DELEGATE_RESULT_TOOL_NAME,
518
489
  label: 'Background Result',
519
490
  description:
520
- '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.',
521
- promptSnippet: 'Retrieve the verified answer from a completed delegate or Fusion task',
491
+ 'Retrieve a hash-verified result from a bg_delegate task. Never blocks: a running task returns a typed not-ready result. Oversized answers are never truncated.',
492
+ promptSnippet: 'Retrieve the verified answer from a completed delegate task',
522
493
  promptGuidelines: [
523
- 'Call bg_result once the delegate or Fusion terminal notification has arrived. It never blocks and must not be polled.',
494
+ 'Call bg_result once the delegate terminal notification has arrived. It never blocks and must not be polled.',
524
495
  'A not-ready result means the task is still running; end the turn and wait for the notification.',
525
496
  ],
526
497
  parameters: ResultParams,
@@ -551,125 +522,10 @@ export function registerDelegateExtension(
551
522
  { code: 'task_unknown', childCreated: false },
552
523
  );
553
524
  }
554
- const fusion = task.fusion;
555
- if (fusion !== undefined) {
556
- const requestedDelivery = requireDelivery(params.delivery);
557
- if (task.status === 'running') {
558
- const details: FusionBackgroundResultDetails = {
559
- schema_version: 'unipi-background-tasks.fusion-result-view.v1',
560
- task_id: task.id,
561
- state: 'running',
562
- delivery: 'none',
563
- workflow: fusion.workflow,
564
- artifact_dir: fusion.artifactDir,
565
- };
566
- return {
567
- content: textContent(
568
- `Fusion ${task.id} is still running. bg_result never blocks. End this turn; the terminal notification will wake you, then call bg_result again.`,
569
- ),
570
- details,
571
- };
572
- }
573
- if (task.status !== 'completed' || fusion.outcome?.status !== 'committed') {
574
- const terminal = await readFusionFailureResult({
575
- artifactDirAbs: fusion.artifactDirAbs,
576
- artifactDir: fusion.artifactDir,
577
- runId: fusion.runId,
578
- workflow: fusion.workflow,
579
- });
580
- const state =
581
- fusion.outcome?.status === 'cancelled' || task.status === 'killed'
582
- ? 'cancelled'
583
- : 'failed';
584
- const details: FusionBackgroundResultDetails = {
585
- schema_version: 'unipi-background-tasks.fusion-result-view.v1',
586
- task_id: task.id,
587
- state,
588
- delivery: 'none',
589
- workflow: fusion.workflow,
590
- artifact_dir: fusion.artifactDir,
591
- answer: terminal.answer,
592
- summary_status: terminal.summary_status,
593
- ...(terminal.failure_summary_ref === undefined
594
- ? {}
595
- : { failure_summary_ref: terminal.failure_summary_ref }),
596
- ...(terminal.failure === undefined ? {} : { failure: terminal.failure }),
597
- ...(terminal.progress === undefined ? {} : { progress: terminal.progress }),
598
- ...(terminal.usage_so_far === undefined ? {} : { usage_so_far: terminal.usage_so_far }),
599
- ...(terminal.attempts === undefined ? {} : { attempts: terminal.attempts }),
600
- ...(terminal.evidence_artifacts === undefined
601
- ? {}
602
- : { evidence_artifacts: terminal.evidence_artifacts }),
603
- ...(terminal.remediation_ids === undefined
604
- ? {}
605
- : { remediation_ids: terminal.remediation_ids }),
606
- ...(terminal.summary_unavailable_reason === undefined
607
- ? {}
608
- : { summary_unavailable_reason: terminal.summary_unavailable_reason }),
609
- };
610
- return {
611
- content: textContent(
612
- `Fusion ${task.id} ${state}; no answer was committed. Terminal evidence status: ${terminal.summary_status}. Delivery is none; use only the manifest-bound artifact references in details.`,
613
- ),
614
- details,
615
- };
616
- }
617
- const verified = await readFusionCommittedResult({
618
- artifactDirAbs: fusion.artifactDirAbs,
619
- artifactDir: fusion.artifactDir,
620
- runId: fusion.runId,
621
- workflow: fusion.workflow,
622
- });
623
- const answerBytes = Buffer.byteLength(verified.mergedText, 'utf8');
624
- const answerSha256 = sha256Buffer(Buffer.from(verified.mergedText, 'utf8'));
625
- const useArtifact =
626
- requestedDelivery === 'artifact' ||
627
- (requestedDelivery === undefined && answerBytes > DELEGATE_INLINE_ANSWER_BYTES);
628
- if (requestedDelivery === 'inline' && answerBytes > DELEGATE_INLINE_ANSWER_BYTES) {
629
- throw new Error(
630
- `Fusion result ${task.id} is ${String(answerBytes)} bytes, above the ${String(DELEGATE_INLINE_ANSWER_BYTES)}-byte inline limit. Use delivery:"artifact"; nothing was truncated.`,
631
- );
632
- }
633
- const usageDelivered = await deps.claimFusionUsage(task);
634
- const details: FusionBackgroundResultDetails = {
635
- schema_version: 'unipi-background-tasks.fusion-result-view.v1',
636
- task_id: task.id,
637
- state: 'committed',
638
- delivery: useArtifact ? 'artifact' : 'inline',
639
- workflow: fusion.workflow,
640
- artifact_dir: fusion.artifactDir,
641
- answer_bytes: answerBytes,
642
- answer_sha256: answerSha256,
643
- usage_delivered: usageDelivered,
644
- };
645
- const header = [
646
- `Fusion ${task.id} completed (${fusion.workflow}).`,
647
- `Answer: ${String(answerBytes)} bytes, ${answerSha256} (verified).`,
648
- `Artifacts: ${fusion.artifactDir}`,
649
- usageDelivered
650
- ? 'Usage: attached to this retrieval exactly once.'
651
- : 'Usage: already attached by an earlier retrieval; not counted again.',
652
- ].join('\n');
653
- const result = useArtifact
654
- ? {
655
- content: textContent(
656
- `${header}\nDelivery: artifact. The complete answer is ${fusion.artifactDir}/merged.md; it was not truncated.`,
657
- ),
658
- details,
659
- }
660
- : { content: textContent(`${header}\n\n${verified.mergedText}`), details };
661
- if (!usageDelivered) return result;
662
- const resultWithUsage: typeof result & { usage: FusionUsage } = {
663
- ...result,
664
- usage: cloneFusionUsage(verified.details.usage),
665
- };
666
- return resultWithUsage;
667
- }
668
-
669
525
  const facts = task.delegate;
670
526
  if (facts === undefined) {
671
527
  throw new DelegateError(
672
- `bg_result task ${task.id} has no retrievable delegate or Fusion result; use bg_logs for ordinary background tasks`,
528
+ `bg_result task ${task.id} has no retrievable delegate result; use bg_logs for ordinary background tasks`,
673
529
  { code: 'task_unknown', childCreated: false },
674
530
  );
675
531
  }
@@ -772,22 +628,14 @@ export function registerDelegateExtension(
772
628
  renderResult(result, options: ToolRenderResultOptions, theme: Theme) {
773
629
  void options;
774
630
  const details = result.details;
775
- const fusion = details.schema_version === 'unipi-background-tasks.fusion-result-view.v1';
776
631
  if (details.state === 'running')
777
632
  return new Text(
778
- theme.fg('warning', `${fusion ? 'fusion' : 'delegate'} ${details.task_id} still running`),
633
+ theme.fg('warning', `delegate ${details.task_id} still running`),
779
634
  0,
780
635
  0,
781
636
  );
782
- if (fusion && (details.state === 'failed' || details.state === 'cancelled')) {
783
- return new Text(
784
- theme.fg('warning', `${details.state} fusion; no committed answer · ${details.summary_status ?? 'unavailable'}`),
785
- 0,
786
- 0,
787
- );
788
- }
789
637
  return new Text(
790
- `${theme.fg('success', fusion ? '✓ fusion answer' : '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
638
+ `${theme.fg('success', '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
791
639
  0,
792
640
  0,
793
641
  );
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { createSpinnerLine } from "@pi-unipi/core";
13
14
  import { loadBackgroundTasksConfig } from "./config.js";
14
15
  import { BackgroundTaskRegistry } from "./registry.js";
15
16
  import {
@@ -17,16 +18,32 @@ import {
17
18
  type BackgroundTaskExtensionService,
18
19
  } from "./extension-api.js";
19
20
  import { registerToolsAndCommands } from "./tools.js";
20
- import { registerFusionExtension } from "./fusion-extension.js";
21
21
  import { registerDelegateExtension } from "./delegate-extension.js";
22
22
  import { setSharedTaskRegistry, clearSharedTaskRegistry } from "./registry-shared.js";
23
- import { taskDisplayName, type BgTask, type StartAttestedPiTaskOptions, type StartTaskOptions } from "./types.js";
23
+ import { formatDuration, taskDisplayName, type BgTask, type StartTaskOptions } from "./types.js";
24
24
 
25
25
  const STATUS_INTERVAL_MS = 1000;
26
26
 
27
27
  // Direct synchronous access for sibling extensions (footer process one-liner).
28
28
  export { getSharedTaskRegistry } from "./registry-shared.js";
29
29
 
30
+ /** Live line above the editor: the agent is idle but a task will wake it. */
31
+ function pendingWakeText(registry: BackgroundTaskRegistry, isIdle: () => boolean): string | undefined {
32
+ if (!isIdle()) return undefined;
33
+ const pendingWake = registry
34
+ .allTasks()
35
+ .filter((task) => task.status === "running" && task.triggerOnCompletion);
36
+ if (pendingWake.length === 0) return undefined;
37
+ const now = Date.now();
38
+ const first = pendingWake[0];
39
+ const detail =
40
+ first === undefined
41
+ ? ""
42
+ : ` · ${taskDisplayName(first)} ${formatDuration(now - first.startTime)}${pendingWake.length > 1 ? ` +${String(pendingWake.length - 1)} more` : ""}`;
43
+ const count = pendingWake.length === 1 ? "1 bg task" : `${String(pendingWake.length)} bg tasks`;
44
+ return `waiting on ${count}${detail} — agent resumes automatically when done`;
45
+ }
46
+
30
47
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
31
48
  const { config, warnings } = loadBackgroundTasksConfig(process.cwd());
32
49
 
@@ -41,6 +58,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
41
58
  const seenTaskIds = new Set<string>();
42
59
  let currentCtx: ExtensionContext | undefined;
43
60
  let dockOpen = false;
61
+ let wakeLineInstalled = false;
44
62
  let statusInterval: NodeJS.Timeout | undefined;
45
63
 
46
64
  const registry = new BackgroundTaskRegistry({
@@ -92,7 +110,34 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
92
110
  const unseenDone = allTasks.filter((task) => task.status === "completed" && !seenTaskIds.has(task.id));
93
111
  const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
94
112
 
95
- target.ui.setWidget("background-tasks", undefined);
113
+ // Pending-wake indicator. When the agent is idle but a bg task that will
114
+ // wake it is still running, the UI otherwise looks finished and users
115
+ // assume the turn is over. Install a self-animating spinner line above
116
+ // the editor ONCE while any such task exists (the widget owns its 80 ms
117
+ // frame timer and re-reads the registry on every frame, so this 1 s
118
+ // poll only decides whether the widget exists — never its animation).
119
+ const isIdle = () => {
120
+ try {
121
+ return target.isIdle();
122
+ } catch {
123
+ return true;
124
+ }
125
+ };
126
+ const wantWakeLine = pendingWakeText(registry, isIdle) !== undefined;
127
+ if (wantWakeLine && !wakeLineInstalled) {
128
+ target.ui.setWidget(
129
+ "background-tasks",
130
+ createSpinnerLine({
131
+ text: () => pendingWakeText(registry, isIdle),
132
+ colorSpinner: (g: string) => `\x1b[38;5;82m${g}\x1b[0m`,
133
+ }),
134
+ { placement: "aboveEditor" },
135
+ );
136
+ wakeLineInstalled = true;
137
+ } else if (!wantWakeLine && wakeLineInstalled) {
138
+ target.ui.setWidget("background-tasks", undefined);
139
+ wakeLineInstalled = false;
140
+ }
96
141
  if (running.length === 0 && unseenFinishedCount === 0) {
97
142
  target.ui.setStatus("background-tasks", undefined);
98
143
  return;
@@ -103,7 +148,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
103
148
  if (unseenFailed.length > 0) parts.push(`${String(unseenFailed.length)} failed`);
104
149
  if (unseenStopped.length > 0) parts.push(`${String(unseenStopped.length)} stopped`);
105
150
  if (unseenDone.length > 0) parts.push(`${String(unseenDone.length)} done`);
106
- const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · /unipi:bg-clear" : ""}`;
151
+ const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · Ctrl+Alt+C clear" : ""}`;
107
152
  const label = ` bg ${[...parts, entryHint].join(" · ")} `;
108
153
  target.ui.setStatus("background-tasks", label);
109
154
  } catch (error) {
@@ -119,19 +164,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
119
164
  return registry.startTask(ctx, command, opts);
120
165
  }
121
166
 
122
- async function startAttestedPiTask(
123
- ctx: ExtensionContext,
124
- opts: StartAttestedPiTaskOptions,
125
- ): Promise<BgTask> {
126
- currentCtx = ctx;
127
- return registry.startAttestedPiTask(ctx, opts);
128
- }
129
-
130
167
  async function openTaskManager(ctx: ExtensionContext, initialTaskId?: string): Promise<void> {
131
168
  currentCtx = ctx;
132
169
  if (!ctx.hasUI) {
133
170
  ctx.ui.notify(
134
- "Background task manager requires an interactive UI. Use /unipi:jobs, /unipi:logs, or the bg_status/bg_logs tools in non-interactive mode.",
171
+ "Background task manager requires an interactive UI. Use the bg_status/bg_logs tools in non-interactive mode.",
135
172
  "error",
136
173
  );
137
174
  return;
@@ -156,7 +193,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
156
193
  return result;
157
194
  },
158
195
  rerunTask: async (task) => {
159
- if (task.fusion !== undefined || task.delegate !== undefined) {
196
+ if (task.delegate !== undefined) {
160
197
  throw new Error(
161
198
  "Only shell-command tasks can be rerun from the dock; relaunch this typed workflow through its owning tool.",
162
199
  );
@@ -232,21 +269,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
232
269
  pi,
233
270
  registry,
234
271
  startTask,
235
- startAttestedPiTask,
236
272
  openTaskManager,
237
273
  clearFinishedNotices,
238
274
  openSettings,
239
275
  });
240
276
 
241
- registerFusionExtension(pi, {
242
- startManagedTask: async (ctx, options) => {
243
- currentCtx = ctx;
244
- return registry.startManagedTask(ctx, options);
245
- },
246
- snapshot: (task) => registry.snapshot(task),
247
- updateManagedTask: (task, state, line) => registry.updateManagedTask(task, state, line),
248
- });
249
-
250
277
  registerDelegateExtension(pi, {
251
278
  startDelegateTask: async (ctx, options) => {
252
279
  currentCtx = ctx;
@@ -254,11 +281,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
254
281
  },
255
282
  snapshot: (task) => registry.snapshot(task),
256
283
  resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
257
- claimFusionUsage: (task) => registry.claimFusionUsage(task),
258
284
  });
259
285
 
260
286
  pi.on("session_start", async (_event, ctx) => {
261
287
  registry.setShuttingDown(false);
288
+ wakeLineInstalled = false; // pi clears extension widgets on reload/new session
262
289
  setSharedTaskRegistry(registry);
263
290
  currentCtx = ctx;
264
291
  await registry.ensureRuntimeDir(ctx);
@@ -271,6 +298,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
271
298
 
272
299
  pi.on("session_shutdown", async (_event, ctx) => {
273
300
  registry.setShuttingDown(true);
301
+ wakeLineInstalled = false;
274
302
  clearSharedTaskRegistry();
275
303
  currentCtx = undefined;
276
304
  if (statusInterval) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @pi-unipi/background-tasks — small hashing / canonical-JSON helpers shared by
3
+ * the delegate artifact store and registry metadata writes.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { isJsonObject } from './types.js';
7
+ import { replaceFileDurable } from './durable-fs.js';
8
+
9
+ export function sha256Buffer(buffer: Buffer): string {
10
+ return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
11
+ }
12
+
13
+ export function canonicalJson(value: unknown): string {
14
+ return JSON.stringify(sortJson(value));
15
+ }
16
+
17
+ function sortJson(value: unknown): unknown {
18
+ if (Array.isArray(value)) return value.map(sortJson);
19
+ if (!isJsonObject(value)) return value;
20
+ return Object.fromEntries(
21
+ Object.keys(value)
22
+ .sort()
23
+ .map((key) => [key, sortJson(value[key])]),
24
+ );
25
+ }
26
+
27
+ export async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
28
+ await replaceFileDurable(path, `${JSON.stringify(value, null, 2)}\n`);
29
+ }
30
+
31
+ export async function closeAndFsyncOutputStream(
32
+ stream: NodeJS.WritableStream | undefined,
33
+ ): Promise<void> {
34
+ if (!stream) return;
35
+ await new Promise<void>((resolvePromise, reject) => {
36
+ let settled = false;
37
+ const finish = () => {
38
+ if (settled) return;
39
+ settled = true;
40
+ stream.off('error', fail);
41
+ stream.off('close', finish);
42
+ stream.off('finish', finish);
43
+ resolvePromise();
44
+ };
45
+ const fail = (error: Error) => {
46
+ if (settled) return;
47
+ settled = true;
48
+ stream.off('close', finish);
49
+ reject(error);
50
+ };
51
+ stream.once('close', finish);
52
+ stream.once('finish', finish);
53
+ stream.once('error', fail);
54
+ stream.end();
55
+ });
56
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @pi-unipi/background-tasks — locate package-owned assets from any entry.
3
+ *
4
+ * The delegate child guard (`extensions/delegate-child.ts`) and the recorded
5
+ * hook-contract evidence (`src/delegate/hook-contract-evidence.json`) must be
6
+ * found both when this package runs from source (`src/…`) and when it runs from
7
+ * the umbrella bundle (`packages/unipi/bundled.js`, where `import.meta.url` no
8
+ * longer points inside this package). We therefore walk upward from the calling
9
+ * module until we find a `packages/background-tasks/` (or the package root
10
+ * itself) that contains the asset.
11
+ */
12
+ import { existsSync } from 'node:fs';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ export interface ResolvePackageAssetOptions {
17
+ moduleUrl?: string | undefined;
18
+ pathExists?: ((path: string) => boolean) | undefined;
19
+ }
20
+
21
+ const PACKAGE_DIR_NAME = 'background-tasks';
22
+
23
+ /**
24
+ * Resolve `relativePath` (relative to this package's root, e.g.
25
+ * `extensions/delegate-child.ts`) or return undefined when no candidate exists.
26
+ */
27
+ export function resolvePackageAsset(
28
+ relativePath: string,
29
+ options: ResolvePackageAssetOptions = {},
30
+ ): string | undefined {
31
+ const pathExists = options.pathExists ?? existsSync;
32
+ const modulePath = fileURLToPath(options.moduleUrl ?? import.meta.url);
33
+ let dir = dirname(modulePath);
34
+ for (let depth = 0; depth < 8; depth++) {
35
+ const candidates = [
36
+ resolve(dir, relativePath),
37
+ resolve(dir, PACKAGE_DIR_NAME, relativePath),
38
+ resolve(dir, 'packages', PACKAGE_DIR_NAME, relativePath),
39
+ resolve(dir, 'node_modules', '@pi-unipi', PACKAGE_DIR_NAME, relativePath),
40
+ ];
41
+ for (const candidate of candidates) if (pathExists(candidate)) return candidate;
42
+ const parent = dirname(dir);
43
+ if (parent === dir) break;
44
+ dir = parent;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function packageAssetSearchHint(relativePath: string): string {
50
+ return join('packages', PACKAGE_DIR_NAME, relativePath);
51
+ }