pi-background-tasks 0.7.0 → 0.7.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.
@@ -1,10 +1,12 @@
1
+ import type { Usage } from '@earendil-works/pi-ai';
2
+
1
3
  export type FusionThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
2
4
 
3
5
  export const FUSION_MODEL_CONFIG_SCHEMA_VERSION = 'pi-background-tasks.fusion-models.v1';
4
6
  export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v1';
5
7
  export const FUSION_EVALUATION_SCHEMA_VERSION = 'pi-background-tasks.fusion-evaluation.v1';
6
- export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v1';
7
- export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v1';
8
+ export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v2';
9
+ export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v2';
8
10
 
9
11
  export const FUSION_CANDIDATE_IDS = ['A', 'B', 'C'] as const;
10
12
  export type FusionCandidateId = (typeof FUSION_CANDIDATE_IDS)[number];
@@ -124,14 +126,16 @@ export interface FusionEvaluationV1 {
124
126
  synthesis_plan: FusionSynthesisPlan;
125
127
  }
126
128
 
127
- export interface FusionUsage {
128
- input: number;
129
- output: number;
130
- cacheRead: number;
131
- cacheWrite: number;
132
- totalTokens: number;
133
- costTotal?: number;
134
- }
129
+ /** Exact Pi usage contract used at the child, artifact, and host tool-result boundaries. */
130
+ export type FusionUsage = Usage;
131
+
132
+ const EMPTY_FUSION_COST: Usage['cost'] = Object.freeze({
133
+ input: 0,
134
+ output: 0,
135
+ cacheRead: 0,
136
+ cacheWrite: 0,
137
+ total: 0,
138
+ });
135
139
 
136
140
  export const EMPTY_FUSION_USAGE: FusionUsage = Object.freeze({
137
141
  input: 0,
@@ -139,8 +143,43 @@ export const EMPTY_FUSION_USAGE: FusionUsage = Object.freeze({
139
143
  cacheRead: 0,
140
144
  cacheWrite: 0,
141
145
  totalTokens: 0,
146
+ cost: EMPTY_FUSION_COST,
142
147
  });
143
148
 
149
+ export function createEmptyFusionUsage(): FusionUsage {
150
+ return cloneFusionUsage(EMPTY_FUSION_USAGE);
151
+ }
152
+
153
+ export function cloneFusionUsage(usage: FusionUsage): FusionUsage {
154
+ return {
155
+ input: usage.input,
156
+ output: usage.output,
157
+ cacheRead: usage.cacheRead,
158
+ cacheWrite: usage.cacheWrite,
159
+ totalTokens: usage.totalTokens,
160
+ cost: {
161
+ input: usage.cost.input,
162
+ output: usage.cost.output,
163
+ cacheRead: usage.cost.cacheRead,
164
+ cacheWrite: usage.cost.cacheWrite,
165
+ total: usage.cost.total,
166
+ },
167
+ };
168
+ }
169
+
170
+ export function addFusionUsage(target: FusionUsage, delta: FusionUsage): void {
171
+ target.input += delta.input;
172
+ target.output += delta.output;
173
+ target.cacheRead += delta.cacheRead;
174
+ target.cacheWrite += delta.cacheWrite;
175
+ target.totalTokens += delta.totalTokens;
176
+ target.cost.input += delta.cost.input;
177
+ target.cost.output += delta.cost.output;
178
+ target.cost.cacheRead += delta.cost.cacheRead;
179
+ target.cost.cacheWrite += delta.cost.cacheWrite;
180
+ target.cost.total += delta.cost.total;
181
+ }
182
+
144
183
  export interface FusionResultDetails {
145
184
  schema_version: typeof FUSION_RESULT_SCHEMA_VERSION;
146
185
  run_id: string;
@@ -232,7 +271,7 @@ export interface FusionChildRunResult {
232
271
  qualifiedId: string;
233
272
  text: string;
234
273
  usage: FusionUsage;
235
- stdout: Buffer;
274
+ events: Buffer;
236
275
  stderr: Buffer;
237
276
  exitCode: number;
238
277
  signal: NodeJS.Signals | null;
@@ -247,6 +286,7 @@ export interface FusionAttemptArtifactRecord {
247
286
  events_path?: string;
248
287
  stderr_path?: string;
249
288
  response_path?: string;
289
+ partial_response_path?: string;
250
290
  provider?: string;
251
291
  model?: string;
252
292
  qualifiedId?: string;
@@ -1538,6 +1538,7 @@ export class BackgroundTaskRegistry {
1538
1538
  error,
1539
1539
  ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
1540
1540
  ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
1541
+ ' <guidance>Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.</guidance>',
1541
1542
  '</background-task-notification>',
1542
1543
  ]
1543
1544
  .filter(Boolean)
package/src/extension.ts CHANGED
@@ -12,6 +12,7 @@ import { Type, type Static } from 'typebox';
12
12
  import {
13
13
  DEFAULT_LOG_BYTES,
14
14
  MAX_LOG_BYTES,
15
+ deriveCompletionDeliveryGuidance,
15
16
  deriveTaskNameFromCommand,
16
17
  formatSnapshotList,
17
18
  formatUpdateSegment,
@@ -125,12 +126,15 @@ const BgRunParams = Type.Object({
125
126
  Type.Number({ description: 'Optional timeout; task is failed and killed when exceeded' }),
126
127
  ),
127
128
  notifyOnCompletion: Type.Optional(
128
- Type.Boolean({ description: 'Whether to show a completion notification. Default: true.' }),
129
+ Type.Boolean({
130
+ description:
131
+ 'Whether to deliver the durable terminal notification. Default: true; disable only when deliberately taking over completion monitoring.',
132
+ }),
129
133
  ),
130
134
  triggerOnCompletion: Type.Optional(
131
135
  Type.Boolean({
132
136
  description:
133
- 'Whether completion should trigger a follow-up agent turn. Default: true for bg_run.',
137
+ 'Whether that notification should automatically trigger a follow-up agent turn. Default: true for bg_run; requires notifyOnCompletion.',
134
138
  }),
135
139
  ),
136
140
  });
@@ -657,15 +661,18 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
657
661
  pi.registerTool<typeof BgRunParams, BgRunDetails>({
658
662
  name: 'bg_run',
659
663
  label: 'Background Run',
660
- description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
664
+ description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. By default, completed, failed, or killed terminal state is delivered automatically as <background-task-notification> and starts a follow-up agent turn; do not sleep or poll merely to wait. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
661
665
  promptSnippet:
662
- 'Start named long-running shell commands in the background and return a task ID plus output file path',
666
+ 'Start a named long-running shell command; default terminal notification wakes a follow-up turn, so yield instead of polling',
663
667
  promptGuidelines: [
664
- 'Use bg_run instead of bash for commands expected to run for a long time, such as test suites, dev servers, watchers, builds, or sleeps.',
668
+ 'Use bg_run instead of bash for commands expected to run for a long time, such as test suites, dev servers, watchers, or builds.',
665
669
  'Always set isAgent: true only when the background task launches an LLM/agent process; set isAgent: false for scripts, tests, dev servers, sleeps, and ordinary shell commands.',
666
670
  'When using bg_run, always set name to a concise 2-6 word human-readable label for the footer task dock; do not use the raw command as the name unless it is already short and meaningful.',
667
- 'After bg_run, use bg_status and bg_logs to inspect progress; do not assume the background task completed until status says completed, failed, or killed.',
668
- 'When a <background-task-notification> appears, react to it: inspect bg_status/bg_logs as needed, then report completion, failure, or next steps to the user.',
671
+ 'bg_run returns immediately. With notifyOnCompletion:true and triggerOnCompletion:true (both defaults), completed, failed, or killed terminal state is delivered as <background-task-notification> and automatically starts a follow-up agent turn.',
672
+ 'After a default bg_run launch, continue only independent useful work that does not merely wait for the task; otherwise briefly acknowledge it if useful, then end the current turn. Do not call sleep, bg_status, or bg_logs merely to wait; the terminal notification will wake you.',
673
+ 'Treat <background-task-notification> as durable terminal truth. Do not call bg_status to reconfirm it; call bg_logs only when the task output is needed.',
674
+ 'Use bg_status/bg_logs only when the user explicitly requests an update, automatic notification or wake-up was deliberately disabled, there is concrete evidence the task is hung, or a terminal notification arrived and output details are needed.',
675
+ 'Do not set notifyOnCompletion:false or triggerOnCompletion:false unless intentionally opting out of automatic completion handling.',
669
676
  ],
670
677
  parameters: BgRunParams,
671
678
  prepareArguments(args): BgRunParamsValue {
@@ -708,9 +715,13 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
708
715
  if (params.description !== undefined) taskOptions.description = params.description;
709
716
  if (params.timeoutSeconds !== undefined) taskOptions.timeoutSeconds = params.timeoutSeconds;
710
717
  const task = await startTask(ctx, params.command, taskOptions);
718
+ const completionDelivery = deriveCompletionDeliveryGuidance(
719
+ task.notifyOnCompletion,
720
+ task.triggerOnCompletion,
721
+ );
711
722
  return {
712
723
  content: textContent(
713
- `Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}`,
724
+ `Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}\n${completionDelivery.text}`,
714
725
  ),
715
726
  details: { task: registry.snapshot(task) },
716
727
  };
@@ -800,10 +811,14 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
800
811
  pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
801
812
  name: 'bg_status',
802
813
  label: 'Background Status',
803
- description: 'Inspect one background task or list all running/recent background tasks.',
804
- promptSnippet: 'Inspect status for one or all background tasks',
814
+ description:
815
+ 'Inspect one background task or list all running/recent background tasks. This is a point-in-time inspection tool, not a waiting primitive.',
816
+ promptSnippet:
817
+ 'Inspect point-in-time status for one or all background tasks; never poll it as a wait loop',
805
818
  promptGuidelines: [
806
- 'Use bg_status before bg_logs when you need to know whether a background task is still running or has finished.',
819
+ 'Use bg_status for deliberate point-in-time inspection, not as a waiting primitive.',
820
+ 'A running result is not an instruction to poll again. Do not repeatedly call bg_status while an automatic terminal notification is pending.',
821
+ 'Use bg_status when the user explicitly requests an update, automatic completion handling was disabled, or concrete evidence suggests a task is hung; terminal notifications do not need reconfirmation.',
807
822
  ],
808
823
  parameters: BgStatusParams,
809
824
  execute(_toolCallId, params) {
@@ -827,10 +842,12 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
827
842
  pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
828
843
  name: 'bg_logs',
829
844
  label: 'Background Logs',
830
- description: `Read bounded output from a background task. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
831
- promptSnippet: 'Read bounded output from a background task log',
845
+ description: `Read bounded output from a background task for deliberate inspection; this is not a waiting primitive. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
846
+ promptSnippet: 'Read bounded task output when needed; never tail it repeatedly as a wait loop',
832
847
  promptGuidelines: [
833
- 'Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context.',
848
+ 'Use bg_logs with a modest maxBytes value only when task output is needed, without flooding context.',
849
+ 'Do not repeatedly call bg_logs to wait for completion while an automatic terminal notification is pending.',
850
+ 'Use bg_status first only when a deliberate inspection requires the current task state; do not reconfirm a terminal notification.',
834
851
  ],
835
852
  parameters: BgLogsParams,
836
853
  async execute(_toolCallId, params) {
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Usage } from '@earendil-works/pi-ai';
3
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
+
5
+ export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
6
+ 'pi-background-tasks.fusion-child-result.v2' as const;
7
+ export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
8
+
9
+ export interface FusionChildTextBlockMetadata {
10
+ utf8_bytes: number;
11
+ sha256: string;
12
+ }
13
+
14
+ export type FusionChildResultUsageMetadata = Usage;
15
+
16
+ export interface FusionChildResultMetadata {
17
+ schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
18
+ provider: string;
19
+ model: string;
20
+ stop_reason: string;
21
+ text_blocks: FusionChildTextBlockMetadata[];
22
+ text_sha256: string;
23
+ usage: FusionChildResultUsageMetadata;
24
+ }
25
+
26
+ function sha256(value: string): string {
27
+ return createHash('sha256').update(value, 'utf8').digest('hex');
28
+ }
29
+
30
+ export function buildFusionChildResultMetadata(message: {
31
+ provider: string;
32
+ model: string;
33
+ stopReason: string;
34
+ content: ReadonlyArray<{ type: string; text?: string }>;
35
+ usage: Usage;
36
+ }): FusionChildResultMetadata {
37
+ const textBlocks = message.content.flatMap((part) =>
38
+ part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
39
+ );
40
+ const usage: FusionChildResultUsageMetadata = {
41
+ input: message.usage.input,
42
+ output: message.usage.output,
43
+ cacheRead: message.usage.cacheRead,
44
+ cacheWrite: message.usage.cacheWrite,
45
+ totalTokens: message.usage.totalTokens,
46
+ cost: {
47
+ input: message.usage.cost.input,
48
+ output: message.usage.cost.output,
49
+ cacheRead: message.usage.cost.cacheRead,
50
+ cacheWrite: message.usage.cost.cacheWrite,
51
+ total: message.usage.cost.total,
52
+ },
53
+ };
54
+ return {
55
+ schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
56
+ provider: message.provider,
57
+ model: message.model,
58
+ stop_reason: message.stopReason,
59
+ text_blocks: textBlocks.map((text) => ({
60
+ utf8_bytes: Buffer.byteLength(text, 'utf8'),
61
+ sha256: sha256(text),
62
+ })),
63
+ text_sha256: sha256(textBlocks.join('')),
64
+ usage,
65
+ };
66
+ }
67
+
68
+ async function writeMetadata(record: FusionChildResultMetadata): Promise<void> {
69
+ const line = `${FUSION_CHILD_RESULT_PREFIX}${JSON.stringify(record)}\n`;
70
+ await new Promise<void>((resolve, reject) => {
71
+ process.stderr.write(line, (error) => {
72
+ if (error) reject(error);
73
+ else resolve();
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Private Fusion child extension.
80
+ *
81
+ * Pi print mode writes only the final full text to stdout. This extension adds
82
+ * one compact, reasoning-free metadata record to stderr for each finalized
83
+ * assistant message so the parent can validate model identity, stop reason,
84
+ * exact text bytes, and usage without consuming cumulative JSON stream events.
85
+ */
86
+ export default function fusionChildExtension(pi: ExtensionAPI): void {
87
+ pi.on('message_end', async (event) => {
88
+ if (event.message.role !== 'assistant') return;
89
+ await writeMetadata(buildFusionChildResultMetadata(event.message));
90
+ });
91
+ }
@@ -1,3 +1,4 @@
1
+ import type { Usage } from '@earendil-works/pi-ai';
1
2
  import type {
2
3
  AgentToolResult,
3
4
  ExtensionAPI,
@@ -26,6 +27,7 @@ import { FusionOrchestrator } from './core/fusion/orchestrator.js';
26
27
  import {
27
28
  FUSION_RESULT_SCHEMA_VERSION,
28
29
  FusionError,
30
+ cloneFusionUsage,
29
31
  type FusionModelConfigV1,
30
32
  type FusionModelSelection,
31
33
  type FusionProgressEvent,
@@ -49,7 +51,7 @@ const FUSION_MODEL_COMMAND_NAME = 'fusion-models';
49
51
 
50
52
  type FusionToolDetails = FusionResultDetails | FusionProgressDetails;
51
53
  type FusionToolResultWithUsage = AgentToolResult<FusionToolDetails> & {
52
- usage: FusionResultDetails['usage'];
54
+ usage: Usage;
53
55
  };
54
56
 
55
57
  type CommandDialogResult =
@@ -124,6 +126,17 @@ function errorArtifactSuffix(error: unknown): string {
124
126
  : '';
125
127
  }
126
128
 
129
+ function toolFailureMessage(error: unknown): string {
130
+ const coordinates: string[] = [];
131
+ if (error instanceof FusionError) {
132
+ if (error.stage !== undefined) coordinates.push(`stage=${error.stage}`);
133
+ if (error.slot !== undefined) coordinates.push(`slot=${String(error.slot)}`);
134
+ if (error.attempt !== undefined) coordinates.push(`attempt=${String(error.attempt)}`);
135
+ }
136
+ const location = coordinates.length === 0 ? '' : ` (${coordinates.join(', ')})`;
137
+ return `Fusion failed${location}: ${errorMessage(error)}${errorArtifactSuffix(error)}`;
138
+ }
139
+
127
140
  function progressText(event: FusionProgressEvent): string {
128
141
  if (event.type === 'state') return `fusion: ${event.state.replace(/_/g, ' ')}`;
129
142
  if (event.type === 'candidate_started') return `fusion: candidate ${String(event.slot)} starting`;
@@ -149,8 +162,7 @@ function makeProgressDetails(event: FusionProgressEvent): FusionProgressDetails
149
162
 
150
163
  function usageSummary(details: FusionResultDetails): string {
151
164
  const tokens = details.usage.totalTokens;
152
- const cost =
153
- details.usage.costTotal === undefined ? '' : ` · $${details.usage.costTotal.toFixed(4)}`;
165
+ const cost = ` · $${details.usage.cost.total.toFixed(4)}`;
154
166
  return `${String(tokens)} tokens${cost}`;
155
167
  }
156
168
 
@@ -546,23 +558,28 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
546
558
  prepareArguments: prepareFusionArguments,
547
559
  async execute(toolCallId, params, signal, onUpdate, ctx) {
548
560
  const prompt = normalizeToolPrompt(params.prompt);
549
- const result = await runFusion({
550
- source: 'tool',
551
- ctx,
552
- request: prompt,
553
- signal,
554
- toolCallId,
555
- onProgress: (event) => {
556
- onUpdate?.({
557
- content: textContent(progressText(event)),
558
- details: makeProgressDetails(event),
559
- });
560
- },
561
- });
561
+ let result: FusionRunResult;
562
+ try {
563
+ result = await runFusion({
564
+ source: 'tool',
565
+ ctx,
566
+ request: prompt,
567
+ signal,
568
+ toolCallId,
569
+ onProgress: (event) => {
570
+ onUpdate?.({
571
+ content: textContent(progressText(event)),
572
+ details: makeProgressDetails(event),
573
+ });
574
+ },
575
+ });
576
+ } catch (error) {
577
+ throw new Error(toolFailureMessage(error), { cause: error });
578
+ }
562
579
  const toolResult: FusionToolResultWithUsage = {
563
580
  content: textContent(result.mergedText),
564
581
  details: result.details,
565
- usage: result.details.usage,
582
+ usage: cloneFusionUsage(result.details.usage),
566
583
  };
567
584
  return toolResult;
568
585
  },