@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.
- package/README.md +21 -27
- package/package.json +3 -4
- package/src/cards.ts +76 -0
- package/src/child-process.ts +1 -1
- package/src/config.ts +0 -42
- package/src/context-visible-conversation-v2.ts +1 -1
- package/src/delegate/artifacts.ts +1 -1
- package/src/delegate/launch.ts +17 -30
- package/src/delegate/result-package.ts +1 -1
- package/src/delegate/runner.ts +1 -20
- package/src/delegate/seed.ts +1 -1
- package/src/delegate-extension.ts +16 -168
- package/src/index.ts +53 -25
- package/src/json-utils.ts +56 -0
- package/src/package-assets.ts +51 -0
- package/src/registry.ts +8 -459
- package/src/task-manager.ts +13 -2
- package/src/tools.ts +4 -189
- package/src/types.ts +17 -70
- package/extensions/anthropic-attribution.ts +0 -1
- package/extensions/fusion-child.ts +0 -1
- package/src/anthropic-attribution-path.ts +0 -21
- package/src/anthropic-attribution.ts +0 -1983
- package/src/attested-pi-run.ts +0 -612
- package/src/fixtures/fusion-golden-bytes.json +0 -310
- package/src/fixtures/fusion-validate-golden-bytes.json +0 -282
- package/src/fusion/artifacts.ts +0 -967
- package/src/fusion/budget.ts +0 -1162
- package/src/fusion/child-protocol.ts +0 -305
- package/src/fusion/claude-cache.ts +0 -207
- package/src/fusion/clean-context.ts +0 -91
- package/src/fusion/config.ts +0 -449
- package/src/fusion/context.ts +0 -265
- package/src/fusion/evaluation.ts +0 -800
- package/src/fusion/orchestrator.ts +0 -1288
- package/src/fusion/output-contract.ts +0 -34
- package/src/fusion/pi-child.ts +0 -2373
- package/src/fusion/prompts.ts +0 -345
- package/src/fusion/result-package.ts +0 -959
- package/src/fusion/source-policy.ts +0 -257
- package/src/fusion/types.ts +0 -1139
- package/src/fusion/web-fetch.ts +0 -1060
- package/src/fusion/workflows.ts +0 -184
- package/src/fusion-child-extension.ts +0 -1052
- package/src/fusion-extension.ts +0 -1293
- package/src/ui/fusion-model-selector.ts +0 -322
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
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 './
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 ${
|
|
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
|
|
521
|
-
promptSnippet: 'Retrieve the verified answer from a completed delegate
|
|
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
|
|
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
|
|
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',
|
|
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',
|
|
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
|
|
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
|
-
|
|
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 ? " ·
|
|
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
|
|
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.
|
|
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
|
+
}
|