pi-background-tasks 0.7.2 → 0.7.4

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.
@@ -0,0 +1,250 @@
1
+ import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ export type WindowsKillPhase = 'terminate' | 'force';
5
+
6
+ export interface TaskkillOutcome {
7
+ readonly exitCode: number | null;
8
+ readonly signal: NodeJS.Signals | null;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ readonly stdoutTruncated: boolean;
12
+ readonly stderrTruncated: boolean;
13
+ }
14
+
15
+ interface TaskkillOutputStream {
16
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown;
17
+ }
18
+
19
+ interface WindowsTaskkillChildProcess {
20
+ readonly stdout?: TaskkillOutputStream | null | undefined;
21
+ readonly stderr?: TaskkillOutputStream | null | undefined;
22
+ kill(signal?: NodeJS.Signals): boolean;
23
+ on(event: 'error', listener: (error: Error) => void): unknown;
24
+ on(
25
+ event: 'close',
26
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
27
+ ): unknown;
28
+ }
29
+
30
+ type WindowsTaskkillSpawn = (
31
+ command: string,
32
+ args: string[],
33
+ options: SpawnOptions,
34
+ ) => WindowsTaskkillChildProcess;
35
+
36
+ export interface WindowsTaskkillOptions {
37
+ readonly spawn?: WindowsTaskkillSpawn;
38
+ readonly env?: NodeJS.ProcessEnv;
39
+ readonly timeoutMs?: number;
40
+ readonly signal?: AbortSignal;
41
+ readonly maxCaptureBytes?: number;
42
+ }
43
+
44
+ const DEFAULT_TIMEOUT_MS = 5000;
45
+ const DEFAULT_MAX_CAPTURE_BYTES = 8 * 1024;
46
+
47
+ class BoundedCapture {
48
+ private readonly chunks: Buffer[] = [];
49
+ private capturedBytes = 0;
50
+ private truncated = false;
51
+
52
+ constructor(private readonly maxBytes: number) {}
53
+
54
+ append(data: Buffer | string): void {
55
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
56
+ if (buffer.length === 0) return;
57
+ const remaining = this.maxBytes - this.capturedBytes;
58
+ if (remaining > 0) {
59
+ const kept = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
60
+ this.chunks.push(kept);
61
+ this.capturedBytes += kept.length;
62
+ }
63
+ if (buffer.length > Math.max(0, remaining)) this.truncated = true;
64
+ }
65
+
66
+ text(): string {
67
+ return Buffer.concat(this.chunks, this.capturedBytes).toString('utf8');
68
+ }
69
+
70
+ isTruncated(): boolean {
71
+ return this.truncated;
72
+ }
73
+ }
74
+
75
+ function lookupEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
76
+ const direct = env[name];
77
+ if (direct !== undefined) return direct;
78
+ const lowerName = name.toLowerCase();
79
+ for (const key of Object.keys(env)) {
80
+ if (key.toLowerCase() === lowerName) return env[key];
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function validateWindowsRoot(raw: string, label: string): string {
86
+ const value = raw.trim();
87
+ if (value.length === 0) throw new Error(`${label} is empty; cannot resolve taskkill.exe`);
88
+ if (value.includes('\0')) throw new Error(`${label} contains a NUL byte; cannot resolve taskkill.exe`);
89
+ if (!win32.isAbsolute(value)) {
90
+ throw new Error(`${label} must be an absolute Windows path; cannot resolve taskkill.exe`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ export function resolveTaskkillPath(env: NodeJS.ProcessEnv = process.env): string {
96
+ const systemRoot = lookupEnv(env, 'SystemRoot');
97
+ if (systemRoot !== undefined) {
98
+ return win32.join(validateWindowsRoot(systemRoot, 'SystemRoot'), 'System32', 'taskkill.exe');
99
+ }
100
+
101
+ const windir = lookupEnv(env, 'WINDIR');
102
+ if (windir !== undefined) {
103
+ return win32.join(validateWindowsRoot(windir, 'WINDIR'), 'System32', 'taskkill.exe');
104
+ }
105
+
106
+ throw new Error('Cannot resolve taskkill.exe: SystemRoot is missing and WINDIR fallback is missing');
107
+ }
108
+
109
+ function validatePid(pid: number): void {
110
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
111
+ throw new Error(`Invalid Windows taskkill pid ${String(pid)}; expected a positive safe integer`);
112
+ }
113
+ }
114
+
115
+ function validatePhase(phase: WindowsKillPhase): void {
116
+ if (phase !== 'terminate' && phase !== 'force') {
117
+ throw new Error(`Invalid Windows taskkill phase ${String(phase)}`);
118
+ }
119
+ }
120
+
121
+ function positiveFiniteInteger(value: number | undefined, fallback: number, label: string): number {
122
+ const candidate = value ?? fallback;
123
+ if (!Number.isFinite(candidate) || candidate <= 0) {
124
+ throw new Error(`${label} must be a positive finite number`);
125
+ }
126
+ return Math.max(1, Math.floor(candidate));
127
+ }
128
+
129
+ function outcome(
130
+ exitCode: number | null,
131
+ signal: NodeJS.Signals | null,
132
+ stdout: BoundedCapture,
133
+ stderr: BoundedCapture,
134
+ ): TaskkillOutcome {
135
+ return {
136
+ exitCode,
137
+ signal,
138
+ stdout: stdout.text(),
139
+ stderr: stderr.text(),
140
+ stdoutTruncated: stdout.isTruncated(),
141
+ stderrTruncated: stderr.isTruncated(),
142
+ };
143
+ }
144
+
145
+ function defaultSpawn(command: string, args: string[], options: SpawnOptions): WindowsTaskkillChildProcess {
146
+ return nodeSpawn(command, args, options);
147
+ }
148
+
149
+ export function runWindowsTaskkill(
150
+ pid: number,
151
+ phase: WindowsKillPhase,
152
+ options: WindowsTaskkillOptions = {},
153
+ ): Promise<TaskkillOutcome> {
154
+ validatePid(pid);
155
+ validatePhase(phase);
156
+ const env = options.env ?? process.env;
157
+ const taskkill = resolveTaskkillPath(env);
158
+ const timeoutMs = positiveFiniteInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
159
+ const maxCaptureBytes = positiveFiniteInteger(
160
+ options.maxCaptureBytes,
161
+ DEFAULT_MAX_CAPTURE_BYTES,
162
+ 'maxCaptureBytes',
163
+ );
164
+ const spawn = options.spawn ?? defaultSpawn;
165
+ const abortSignal = options.signal;
166
+
167
+ const args = ['/PID', String(pid), '/T'];
168
+ if (phase === 'force') args.push('/F');
169
+
170
+ if (abortSignal?.aborted) {
171
+ const stdout = new BoundedCapture(maxCaptureBytes);
172
+ const stderr = new BoundedCapture(maxCaptureBytes);
173
+ stderr.append('Windows taskkill was aborted before launch');
174
+ return Promise.resolve(outcome(null, null, stdout, stderr));
175
+ }
176
+
177
+ return new Promise<TaskkillOutcome>((resolve) => {
178
+ const stdout = new BoundedCapture(maxCaptureBytes);
179
+ const stderr = new BoundedCapture(maxCaptureBytes);
180
+ let settled = false;
181
+ let timeout: NodeJS.Timeout | undefined;
182
+ let child: WindowsTaskkillChildProcess | undefined;
183
+ let abortListener: (() => void) | undefined;
184
+
185
+ const settle = (result: TaskkillOutcome): void => {
186
+ if (settled) return;
187
+ settled = true;
188
+ if (timeout !== undefined) clearTimeout(timeout);
189
+ if (abortSignal !== undefined && abortListener !== undefined) {
190
+ abortSignal.removeEventListener('abort', abortListener);
191
+ }
192
+ resolve(result);
193
+ };
194
+
195
+ const stopHelper = (reason: string): void => {
196
+ stderr.append(reason);
197
+ if (child !== undefined) {
198
+ try {
199
+ child.kill('SIGKILL');
200
+ } catch (error) {
201
+ stderr.append(`; helper kill failed: ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ }
204
+ settle(outcome(null, null, stdout, stderr));
205
+ };
206
+
207
+ const spawnOptions: SpawnOptions = {
208
+ env,
209
+ shell: false,
210
+ windowsVerbatimArguments: false,
211
+ windowsHide: true,
212
+ stdio: ['ignore', 'pipe', 'pipe'],
213
+ };
214
+
215
+ try {
216
+ child = spawn(taskkill, args, spawnOptions);
217
+ } catch (error) {
218
+ stderr.append(`Windows taskkill spawn failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ settle(outcome(null, null, stdout, stderr));
220
+ return;
221
+ }
222
+
223
+ child.stdout?.on('data', (data) => {
224
+ stdout.append(data);
225
+ });
226
+ child.stderr?.on('data', (data) => {
227
+ stderr.append(data);
228
+ });
229
+ child.on('error', (error) => {
230
+ stderr.append(`Windows taskkill spawn error: ${error.message}`);
231
+ settle(outcome(null, null, stdout, stderr));
232
+ });
233
+ child.on('close', (code, signal) => {
234
+ settle(outcome(code, signal, stdout, stderr));
235
+ });
236
+
237
+ abortListener = () => {
238
+ stopHelper('Windows taskkill was aborted');
239
+ };
240
+ abortSignal?.addEventListener('abort', abortListener, { once: true });
241
+
242
+ // This timeout is the settlement guarantee for a taskkill helper that never
243
+ // exits. It must keep the event loop alive: an unref'd timer lets the loop
244
+ // drain first and leaves this promise pending forever. `settle()` always
245
+ // clears it, so keeping it referenced cannot leak.
246
+ timeout = setTimeout(() => {
247
+ stopHelper(`Windows taskkill timed out after ${String(timeoutMs)}ms`);
248
+ }, timeoutMs);
249
+ });
250
+ }
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import type { Usage } from '@earendil-works/pi-ai';
2
3
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
4
 
4
5
  export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
5
- 'pi-background-tasks.fusion-child-result.v1' as const;
6
+ 'pi-background-tasks.fusion-child-result.v2' as const;
6
7
  export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
7
8
 
8
9
  export interface FusionChildTextBlockMetadata {
@@ -10,14 +11,7 @@ export interface FusionChildTextBlockMetadata {
10
11
  sha256: string;
11
12
  }
12
13
 
13
- export interface FusionChildResultUsageMetadata {
14
- input: number;
15
- output: number;
16
- cacheRead: number;
17
- cacheWrite: number;
18
- totalTokens: number;
19
- costTotal?: number;
20
- }
14
+ export type FusionChildResultUsageMetadata = Usage;
21
15
 
22
16
  export interface FusionChildResultMetadata {
23
17
  schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
@@ -38,14 +32,7 @@ export function buildFusionChildResultMetadata(message: {
38
32
  model: string;
39
33
  stopReason: string;
40
34
  content: ReadonlyArray<{ type: string; text?: string }>;
41
- usage: {
42
- input: number;
43
- output: number;
44
- cacheRead: number;
45
- cacheWrite: number;
46
- totalTokens: number;
47
- cost: { total: number };
48
- };
35
+ usage: Usage;
49
36
  }): FusionChildResultMetadata {
50
37
  const textBlocks = message.content.flatMap((part) =>
51
38
  part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
@@ -56,10 +43,14 @@ export function buildFusionChildResultMetadata(message: {
56
43
  cacheRead: message.usage.cacheRead,
57
44
  cacheWrite: message.usage.cacheWrite,
58
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
+ },
59
53
  };
60
- if (Number.isFinite(message.usage.cost.total) && message.usage.cost.total >= 0) {
61
- usage.costTotal = message.usage.cost.total;
62
- }
63
54
  return {
64
55
  schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
65
56
  provider: message.provider,
@@ -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 =
@@ -127,7 +129,9 @@ function errorArtifactSuffix(error: unknown): string {
127
129
  function toolFailureMessage(error: unknown): string {
128
130
  const coordinates: string[] = [];
129
131
  if (error instanceof FusionError) {
130
- if (error.stage !== undefined) coordinates.push(`stage=${error.stage}`);
132
+ const budget = error.budget;
133
+ if (budget !== undefined) coordinates.push(`stage=${budget.budget_stage}`);
134
+ else if (error.stage !== undefined) coordinates.push(`stage=${error.stage}`);
131
135
  if (error.slot !== undefined) coordinates.push(`slot=${String(error.slot)}`);
132
136
  if (error.attempt !== undefined) coordinates.push(`attempt=${String(error.attempt)}`);
133
137
  }
@@ -160,8 +164,7 @@ function makeProgressDetails(event: FusionProgressEvent): FusionProgressDetails
160
164
 
161
165
  function usageSummary(details: FusionResultDetails): string {
162
166
  const tokens = details.usage.totalTokens;
163
- const cost =
164
- details.usage.costTotal === undefined ? '' : ` · $${details.usage.costTotal.toFixed(4)}`;
167
+ const cost = ` · $${details.usage.cost.total.toFixed(4)}`;
165
168
  return `${String(tokens)} tokens${cost}`;
166
169
  }
167
170
 
@@ -357,6 +360,7 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
357
360
  sessionId,
358
361
  canonicalInput: built.input,
359
362
  canonicalInputSerialized: built.serialized,
363
+ contextLedger: built.ledger,
360
364
  config: loaded.config,
361
365
  models,
362
366
  signal: controller.signal,
@@ -578,7 +582,7 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
578
582
  const toolResult: FusionToolResultWithUsage = {
579
583
  content: textContent(result.mergedText),
580
584
  details: result.details,
581
- usage: result.details.usage,
585
+ usage: cloneFusionUsage(result.details.usage),
582
586
  };
583
587
  return toolResult;
584
588
  },