pi-background-tasks 0.4.0 → 0.7.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.
@@ -1,144 +1,203 @@
1
- import { spawn as nodeSpawn, type SpawnOptions } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import { createWriteStream, existsSync } from "node:fs";
4
- import { mkdir, writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { formatSize } from "@earendil-works/pi-coding-agent";
1
+ import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { createWriteStream, existsSync } from 'node:fs';
4
+ import { mkdir, realpath, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import type { Api, Model } from '@earendil-works/pi-ai';
7
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
8
+ import { formatSize } from '@earendil-works/pi-coding-agent';
8
9
  import {
9
- boundedRead,
10
- deriveTaskNameFromCommand,
11
- escapeXml,
12
- formatDuration,
13
- normalizeTaskName,
14
- sanitizePathSegment,
15
- shellInvocation,
16
- shellQuote,
17
- snapshot,
18
- stripMatchingQuotes,
19
- taskDisplayName,
20
- type BgLogsDetails,
21
- type BgTask,
22
- type BgTaskSnapshot,
23
- type KillKind,
24
- type StartTaskOptions,
25
- type TaskContextUsage,
26
- type TaskStatus,
27
- type TaskTokenUsage,
28
- type TaskToolUsage,
29
- } from "./common.js";
30
-
31
- export const MAX_OUTPUT_BYTES = Number(process.env["PI_BG_MAX_OUTPUT_BYTES"] ?? 20 * 1024 * 1024);
10
+ boundedRead,
11
+ deriveTaskNameFromCommand,
12
+ escapeXml,
13
+ formatAgentActivityLine,
14
+ formatDuration,
15
+ isJsonObject,
16
+ normalizeTaskName,
17
+ parseAgentActivity,
18
+ parseJsonText,
19
+ sanitizePathSegment,
20
+ shellInvocation,
21
+ shellQuote,
22
+ snapshot,
23
+ taskDisplayName,
24
+ type BgLogsDetails,
25
+ type BgTask,
26
+ type BgTaskSnapshot,
27
+ type JsonObject,
28
+ type KillKind,
29
+ type StartAttestedPiTaskOptions,
30
+ type StartTaskOptions,
31
+ type TaskContextUsage,
32
+ type TaskStatus,
33
+ type TaskTokenUsage,
34
+ type TaskToolUsage,
35
+ } from './common.js';
36
+ import {
37
+ ATTESTED_TASK_ID_PATTERN,
38
+ attestedPiChildEnv,
39
+ buildAttestedPiArgv,
40
+ buildPiTaskAttestation,
41
+ closeAndFsyncOutputStream,
42
+ gitAuthoritySnapshot,
43
+ gitRepoRoot,
44
+ makeAttestedTaskId,
45
+ makeAttestedTaskPaths,
46
+ observePiOAuth,
47
+ parsePiJsonEvents,
48
+ resolveReportPath,
49
+ spawnAndCapturePi,
50
+ writeFileFsynced,
51
+ writeJsonAtomic,
52
+ } from './attested-pi-run.js';
53
+
54
+ export const MAX_OUTPUT_BYTES = Number(process.env['PI_BG_MAX_OUTPUT_BYTES'] ?? 20 * 1024 * 1024);
32
55
  export const KILL_GRACE_MS = 3000;
33
56
  export const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
34
57
  export const MAX_RECENT_TASKS = 100;
35
58
  const TELEMETRY_BUFFER_CHARS = 512 * 1024;
36
59
 
37
- export type BackgroundTaskContext = {
38
- cwd: string;
39
- sessionId?: string;
40
- modelRegistry: Pick<ExtensionContext["modelRegistry"], "getAll">;
41
- model?: ExtensionContext["model"] | undefined;
42
- };
43
-
44
- type OutputEventSource = { on(event: "data", listener: (data: Buffer | string) => void): unknown };
45
-
46
- export type BackgroundTaskChildProcess = {
47
- pid?: number | undefined;
48
- stdout?: OutputEventSource | null | undefined;
49
- stderr?: OutputEventSource | null | undefined;
50
- kill(signal?: NodeJS.Signals | string | number): boolean;
51
- on(event: "error", listener: (error: Error) => void): unknown;
52
- on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
53
- };
54
- export type BackgroundTaskSpawn = (command: string, args: string[], options: SpawnOptions) => BackgroundTaskChildProcess;
60
+ export interface BackgroundTaskModelRegistry
61
+ extends Pick<ExtensionContext['modelRegistry'], 'getAll'> {
62
+ find?: (provider: string, modelId: string) => Model<Api> | undefined;
63
+ isUsingOAuth?: (model: Model<Api>) => boolean;
64
+ }
65
+
66
+ export interface BackgroundTaskContext {
67
+ cwd: string;
68
+ sessionId?: string;
69
+ modelRegistry: BackgroundTaskModelRegistry;
70
+ model?: ExtensionContext['model'] | undefined;
71
+ }
72
+
73
+ interface OutputEventSource {
74
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown;
75
+ }
76
+
77
+ export interface BackgroundTaskChildProcess {
78
+ pid?: number | undefined;
79
+ stdout?: OutputEventSource | null | undefined;
80
+ stderr?: OutputEventSource | null | undefined;
81
+ kill(signal?: NodeJS.Signals): boolean;
82
+ on(event: 'error', listener: (error: Error) => void): unknown;
83
+ on(
84
+ event: 'close',
85
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
86
+ ): unknown;
87
+ }
88
+
89
+ export type BackgroundTaskSpawn = (
90
+ command: string,
91
+ args: string[],
92
+ options: SpawnOptions,
93
+ ) => BackgroundTaskChildProcess;
94
+
55
95
  type KillProcessFn = (pid: number, signal?: NodeJS.Signals | number) => boolean;
56
96
 
57
- export type CompletionNotificationMessage = {
58
- customType: "background-task-notification";
59
- content: string;
60
- display: true;
61
- details: BgTaskSnapshot;
62
- };
97
+ export interface CompletionNotificationMessage {
98
+ customType: 'background-task-notification';
99
+ content: string;
100
+ display: true;
101
+ details: BgTaskSnapshot;
102
+ }
63
103
 
64
- export type CompletionNotificationOptions = {
65
- deliverAs: "followUp";
66
- triggerTurn: boolean;
67
- };
104
+ export interface CompletionNotificationOptions {
105
+ deliverAs: 'followUp';
106
+ triggerTurn: boolean;
107
+ }
68
108
 
69
109
  export type CompletionNotificationSender = (
70
- message: CompletionNotificationMessage,
71
- options: CompletionNotificationOptions,
110
+ message: CompletionNotificationMessage,
111
+ options: CompletionNotificationOptions,
72
112
  ) => void;
73
113
 
74
- export type BackgroundTaskRegistryOptions = {
75
- onChange?: () => void;
76
- sendCompletionNotification: CompletionNotificationSender;
77
- spawn?: BackgroundTaskSpawn;
78
- killProcess?: KillProcessFn;
79
- platform?: NodeJS.Platform;
80
- env?: NodeJS.ProcessEnv;
81
- makeTaskId?: () => string;
82
- now?: () => number;
83
- maxOutputBytes?: number;
84
- maxRecentTasks?: number;
85
- killGraceMs?: number;
86
- stopWaitMs?: number;
87
- logger?: Pick<Console, "error">;
88
- };
89
-
90
- type RuntimeDir = { abs: string; display: string };
91
-
92
- type ModelWindowIndex = {
93
- byQualifiedId: Record<string, number>;
94
- byId: Record<string, number>;
95
- defaultModel?: string | undefined;
96
- defaultProvider?: string | undefined;
97
- defaultContextWindow?: number | undefined;
98
- };
114
+ export interface BackgroundTaskRegistryOptions {
115
+ onChange?: () => void;
116
+ sendCompletionNotification: CompletionNotificationSender;
117
+ publishTerminal?: (task: BgTaskSnapshot) => void;
118
+ spawn?: BackgroundTaskSpawn;
119
+ killProcess?: KillProcessFn;
120
+ platform?: NodeJS.Platform;
121
+ env?: NodeJS.ProcessEnv;
122
+ makeTaskId?: () => string;
123
+ now?: () => number;
124
+ maxOutputBytes?: number;
125
+ maxRecentTasks?: number;
126
+ killGraceMs?: number;
127
+ stopWaitMs?: number;
128
+ logger?: Pick<Console, 'error'>;
129
+ }
130
+
131
+ interface RuntimeDir {
132
+ abs: string;
133
+ display: string;
134
+ }
135
+
136
+ interface ModelWindowIndex {
137
+ byQualifiedId: Record<string, number>;
138
+ byId: Record<string, number>;
139
+ defaultModel?: string | undefined;
140
+ defaultProvider?: string | undefined;
141
+ defaultContextWindow?: number | undefined;
142
+ }
99
143
 
100
144
  function defaultTaskId(): string {
101
- return `b${randomBytes(4).toString("hex")}`;
102
- }
103
-
104
- export function commandMayLaunchPiAgent(command: string, env: NodeJS.ProcessEnv = process.env): boolean {
105
- if (env["PI_BG_DISABLE_PI_TELEMETRY"] === "1") return false;
106
- return /(^|[\s;&|()])pi(?=\s)(?=[^\n;&|]*(?:\s-p(?:\s|$)|\s--print(?:\s|$)|\s--mode(?:=|\s+)json\b))/m.test(command);
107
- }
108
-
109
- export function buildModelWindowIndex(ctx: Pick<BackgroundTaskContext, "modelRegistry" | "model">): ModelWindowIndex {
110
- const byQualifiedId: Record<string, number> = {};
111
- const candidatesById = new Map<string, Set<number>>();
112
- for (const model of ctx.modelRegistry.getAll()) {
113
- const contextWindow = typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0
114
- ? Math.floor(model.contextWindow)
115
- : undefined;
116
- if (!contextWindow) continue;
117
- byQualifiedId[`${model.provider}/${model.id}`] = contextWindow;
118
- let candidates = candidatesById.get(model.id);
119
- if (!candidates) {
120
- candidates = new Set<number>();
121
- candidatesById.set(model.id, candidates);
122
- }
123
- candidates.add(contextWindow);
124
- }
125
- const byId: Record<string, number> = {};
126
- for (const [id, windows] of candidatesById) {
127
- const onlyWindow = windows.values().next();
128
- if (windows.size === 1 && !onlyWindow.done) byId[id] = onlyWindow.value;
129
- }
130
- const current = ctx.model;
131
- return {
132
- byQualifiedId,
133
- byId,
134
- defaultModel: current?.id,
135
- defaultProvider: current?.provider,
136
- defaultContextWindow: current?.contextWindow,
137
- };
145
+ return `b${randomBytes(4).toString('hex')}`;
146
+ }
147
+
148
+ function dirNameFromDisplay(path: string): string {
149
+ const parts = path.split(/[\\/]/);
150
+ return parts.length >= 2 ? (parts.at(-2) ?? '') : '';
151
+ }
152
+
153
+ export function commandMayLaunchPiAgent(
154
+ command: string,
155
+ env: NodeJS.ProcessEnv = process.env,
156
+ ): boolean {
157
+ if (env['PI_BG_DISABLE_PI_TELEMETRY'] === '1') return false;
158
+ return /(^|[\s;&|()])pi(?=\s)(?=[^\n;&|]*(?:\s-p(?:\s|$)|\s--print(?:\s|$)|\s--mode(?:=|\s+)json\b))/m.test(
159
+ command,
160
+ );
161
+ }
162
+
163
+ export function buildModelWindowIndex(
164
+ ctx: Pick<BackgroundTaskContext, 'modelRegistry' | 'model'>,
165
+ ): ModelWindowIndex {
166
+ const byQualifiedId: Record<string, number> = {};
167
+ const candidatesById = new Map<string, Set<number>>();
168
+ for (const model of ctx.modelRegistry.getAll()) {
169
+ const contextWindow =
170
+ typeof model.contextWindow === 'number' &&
171
+ Number.isFinite(model.contextWindow) &&
172
+ model.contextWindow > 0
173
+ ? Math.floor(model.contextWindow)
174
+ : undefined;
175
+ if (!contextWindow) continue;
176
+ byQualifiedId[`${model.provider}/${model.id}`] = contextWindow;
177
+ let candidates = candidatesById.get(model.id);
178
+ if (!candidates) {
179
+ candidates = new Set<number>();
180
+ candidatesById.set(model.id, candidates);
181
+ }
182
+ candidates.add(contextWindow);
183
+ }
184
+ const byId: Record<string, number> = {};
185
+ for (const [id, windows] of candidatesById) {
186
+ const onlyWindow = windows.values().next();
187
+ if (windows.size === 1 && !onlyWindow.done) byId[id] = onlyWindow.value;
188
+ }
189
+ const current = ctx.model;
190
+ return {
191
+ byQualifiedId,
192
+ byId,
193
+ defaultModel: current?.id,
194
+ defaultProvider: current?.provider,
195
+ defaultContextWindow: current?.contextWindow,
196
+ };
138
197
  }
139
198
 
140
199
  export function createPiTelemetryWrapperSource(index: ModelWindowIndex): string {
141
- return `#!/usr/bin/env node
200
+ return `#!/usr/bin/env node
142
201
  const { spawn } = require("node:child_process");
143
202
  const index = ${JSON.stringify(index)};
144
203
 
@@ -221,6 +280,36 @@ function emitUnifiedTelemetry(payload) {
221
280
  process.stdout.write(JSON.stringify(out) + "\\n");
222
281
  }
223
282
 
283
+ function emitActivity(activity) {
284
+ process.stdout.write(JSON.stringify({ type: "background-task-activity", ...activity }) + "\\n");
285
+ }
286
+
287
+ function summarizeArgs(args) {
288
+ if (!args || typeof args !== "object") return "";
289
+ const pick = (value) => {
290
+ if (typeof value === "string" && value.trim()) return value.trim().slice(0, 200);
291
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
292
+ return undefined;
293
+ };
294
+ const preferred = ["path", "file_path", "file", "filename", "command", "cmd", "pattern", "query", "url", "name", "value", "text", "message"];
295
+ for (const key of preferred) { const summary = pick(args[key]); if (summary) return summary; }
296
+ for (const key of Object.keys(args)) { const summary = pick(args[key]); if (summary) return summary; }
297
+ return "";
298
+ }
299
+
300
+ function emitAssistantActivity(message) {
301
+ const content = message && Array.isArray(message.content) ? message.content : [];
302
+ for (const part of content) {
303
+ if (!part || typeof part !== "object") continue;
304
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
305
+ emitActivity({ kind: "assistant_text", text: part.text });
306
+ } else if (part.type === "thinking" || part.type === "reasoning") {
307
+ const text = typeof part.text === "string" ? part.text : (typeof part.thinking === "string" ? part.thinking : "");
308
+ if (text.trim()) emitActivity({ kind: "reasoning", text: text });
309
+ }
310
+ }
311
+ }
312
+
224
313
  function resolveModelName(fromMessage, fromArgs, providerFromArgs) {
225
314
  const message = fromMessage ? String(fromMessage) : "";
226
315
  const args = fromArgs ? String(fromArgs) : "";
@@ -320,7 +409,6 @@ function emitToolTelemetry() {
320
409
  const parsed = parseInvocation(process.argv.slice(2));
321
410
  const child = spawn("pi", parsed.args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
322
411
  let buffer = "";
323
- let finalText = "";
324
412
 
325
413
  if (!parsed.parseJson) {
326
414
  child.stdout.pipe(process.stdout);
@@ -338,9 +426,14 @@ child.on("error", (error) => {
338
426
  });
339
427
  child.on("close", (code, signal) => {
340
428
  if (parsed.parseJson && buffer.trim()) processLine(buffer);
341
- if (finalText) process.stdout.write(finalText.endsWith("\\n") ? finalText : finalText + "\\n");
342
- if (signal) process.kill(process.pid, signal);
343
- process.exit(code ?? 0);
429
+ // Never call process.exit() here: the final message telemetry may still be
430
+ // buffered on wrapper stdout, and forced exit can publish a stale context
431
+ // snapshot from the preceding assistant turn. exitCode lets Node drain the
432
+ // pipe; signal termination is deferred through the same stdout barrier.
433
+ process.stdout.write("", () => {
434
+ if (signal) process.kill(process.pid, signal);
435
+ else process.exitCode = code ?? 0;
436
+ });
344
437
  });
345
438
 
346
439
  function processLine(line) {
@@ -353,606 +446,1211 @@ function processLine(line) {
353
446
  return;
354
447
  }
355
448
  if (event.type === "tool_execution_start") {
356
- markToolStarted(event.toolCallId || event.tool_call_id, event.toolName || event.tool_name);
449
+ const toolName = event.toolName || event.tool_name || "tool";
450
+ markToolStarted(event.toolCallId || event.tool_call_id, toolName);
451
+ emitActivity({ kind: "tool_start", tool: String(toolName), argsSummary: summarizeArgs(event.args || event.arguments || event.input || event.parameters) });
357
452
  emitToolTelemetry();
358
453
  return;
359
454
  }
360
455
  if (event.type === "tool_execution_end") {
456
+ const toolName = event.toolName || event.tool_name || "tool";
361
457
  if (event.isError) markToolFailed(event.toolCallId || event.tool_call_id);
458
+ emitActivity({ kind: "tool_end", tool: String(toolName), isError: !!event.isError, error: typeof event.error === "string" ? event.error : undefined });
362
459
  emitToolTelemetry();
363
460
  return;
364
461
  }
365
462
  if (event.type === "message_end" && event.message && event.message.role === "assistant") {
463
+ emitAssistantActivity(event.message);
366
464
  countToolCallsFromMessage(event.message);
367
465
  emitMessageTelemetry(event.message, parsed.model, parsed.provider);
368
- finalText = (event.message.content || [])
369
- .filter((part) => part && part.type === "text" && part.text)
370
- .map((part) => part.text)
371
- .join("\\n");
372
466
  }
373
467
  }
374
468
  `;
375
469
  }
376
470
 
471
+ interface ContextUsagePayload extends JsonObject {
472
+ readonly contextWindow?: unknown;
473
+ readonly tokens?: unknown;
474
+ readonly percent?: unknown;
475
+ }
476
+
477
+ interface TokenUsagePayload extends JsonObject {
478
+ readonly input?: unknown;
479
+ readonly output?: unknown;
480
+ readonly cacheRead?: unknown;
481
+ readonly cacheWrite?: unknown;
482
+ readonly totalTokens?: unknown;
483
+ readonly costTotal?: unknown;
484
+ }
485
+
486
+ interface ToolUsagePayload extends JsonObject {
487
+ readonly byName?: unknown;
488
+ readonly failed?: unknown;
489
+ readonly total?: unknown;
490
+ }
491
+
377
492
  function normalizeContextUsage(value: unknown): TaskContextUsage | undefined {
378
- if (!value || typeof value !== "object") return undefined;
379
- const input = value as Record<string, unknown>;
380
- const rawContextWindow = input["contextWindow"];
381
- const contextWindow = typeof rawContextWindow === "number" && Number.isFinite(rawContextWindow) && rawContextWindow > 0
382
- ? Math.floor(rawContextWindow)
383
- : undefined;
384
- if (!contextWindow) return undefined;
385
- const rawTokens = input["tokens"];
386
- const tokens = rawTokens === null
387
- ? null
388
- : typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens >= 0
389
- ? Math.floor(rawTokens)
390
- : null;
391
- const rawPercent = input["percent"];
392
- const percent = rawPercent === null
393
- ? null
394
- : typeof rawPercent === "number" && Number.isFinite(rawPercent) && rawPercent >= 0
395
- ? rawPercent
396
- : tokens === null
397
- ? null
398
- : (tokens / contextWindow) * 100;
399
- return { tokens, contextWindow, percent };
493
+ if (!isJsonObject(value)) return undefined;
494
+ const input: ContextUsagePayload = value;
495
+ const rawContextWindow = input.contextWindow;
496
+ const contextWindow =
497
+ typeof rawContextWindow === 'number' &&
498
+ Number.isFinite(rawContextWindow) &&
499
+ rawContextWindow > 0
500
+ ? Math.floor(rawContextWindow)
501
+ : undefined;
502
+ if (!contextWindow) return undefined;
503
+ const rawTokens = input.tokens;
504
+ const tokens =
505
+ rawTokens === null
506
+ ? null
507
+ : typeof rawTokens === 'number' && Number.isFinite(rawTokens) && rawTokens >= 0
508
+ ? Math.floor(rawTokens)
509
+ : null;
510
+ const rawPercent = input.percent;
511
+ const percent =
512
+ rawPercent === null
513
+ ? null
514
+ : typeof rawPercent === 'number' && Number.isFinite(rawPercent) && rawPercent >= 0
515
+ ? rawPercent
516
+ : tokens === null
517
+ ? null
518
+ : (tokens / contextWindow) * 100;
519
+ return { tokens, contextWindow, percent };
400
520
  }
401
521
 
402
522
  function parseContextUsageXml(xml: string): TaskContextUsage | undefined {
403
- const readNumber = (tag: string): number | null | undefined => {
404
- const match = xml.match(new RegExp(`<${tag}>(.*?)</${tag}>`, "i"));
405
- if (!match) return undefined;
406
- const raw = match[1]?.trim();
407
- if (raw === "null" || raw === "?") return null;
408
- const parsed = Number(raw);
409
- return Number.isFinite(parsed) ? parsed : undefined;
410
- };
411
- const tokens = readNumber("tokens");
412
- const contextWindow = readNumber("context-window") ?? readNumber("contextWindow");
413
- const percent = readNumber("percent");
414
- return normalizeContextUsage({ tokens, contextWindow, percent });
523
+ const readNumber = (tag: string): number | null | undefined => {
524
+ const match = new RegExp(`<${tag}>(.*?)</${tag}>`, 'i').exec(xml);
525
+ if (!match) return undefined;
526
+ const raw = match[1]?.trim();
527
+ if (raw === 'null' || raw === '?') return null;
528
+ const parsed = Number(raw);
529
+ return Number.isFinite(parsed) ? parsed : undefined;
530
+ };
531
+ const tokens = readNumber('tokens');
532
+ const contextWindow = readNumber('context-window') ?? readNumber('contextWindow');
533
+ const percent = readNumber('percent');
534
+ return normalizeContextUsage({ tokens, contextWindow, percent });
415
535
  }
416
536
 
417
537
  function nonNegativeInteger(value: unknown): number {
418
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
538
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
419
539
  }
420
540
 
421
541
  function normalizeModel(value: unknown): string | undefined {
422
- if (typeof value !== "string") return undefined;
423
- const trimmed = value.trim();
424
- if (!trimmed) return undefined;
425
- return trimmed.length > 120 ? trimmed.slice(0, 120) : trimmed;
542
+ if (typeof value !== 'string') return undefined;
543
+ const trimmed = value.trim();
544
+ if (!trimmed) return undefined;
545
+ return trimmed.length > 120 ? trimmed.slice(0, 120) : trimmed;
426
546
  }
427
547
 
428
548
  function normalizeTokenUsage(value: unknown): TaskTokenUsage | undefined {
429
- if (!value || typeof value !== "object") return undefined;
430
- const input = value as Record<string, unknown>;
431
- const usage: TaskTokenUsage = {
432
- input: nonNegativeInteger(input["input"]),
433
- output: nonNegativeInteger(input["output"]),
434
- cacheRead: nonNegativeInteger(input["cacheRead"]),
435
- cacheWrite: nonNegativeInteger(input["cacheWrite"]),
436
- totalTokens: nonNegativeInteger(input["totalTokens"]),
437
- };
438
- if (!usage.totalTokens) usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
439
- const rawCostTotal = input["costTotal"];
440
- if (typeof rawCostTotal === "number" && Number.isFinite(rawCostTotal) && rawCostTotal >= 0) usage.costTotal = rawCostTotal;
441
- return usage.totalTokens > 0 ? usage : undefined;
549
+ if (!isJsonObject(value)) return undefined;
550
+ const input: TokenUsagePayload = value;
551
+ const usage: TaskTokenUsage = {
552
+ input: nonNegativeInteger(input.input),
553
+ output: nonNegativeInteger(input.output),
554
+ cacheRead: nonNegativeInteger(input.cacheRead),
555
+ cacheWrite: nonNegativeInteger(input.cacheWrite),
556
+ totalTokens: nonNegativeInteger(input.totalTokens),
557
+ };
558
+ if (usage.totalTokens <= 0)
559
+ usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
560
+ const rawCostTotal = input.costTotal;
561
+ if (typeof rawCostTotal === 'number' && Number.isFinite(rawCostTotal) && rawCostTotal >= 0)
562
+ usage.costTotal = rawCostTotal;
563
+ return usage.totalTokens > 0 ? usage : undefined;
442
564
  }
443
565
 
444
566
  function normalizeToolUsage(value: unknown): TaskToolUsage | undefined {
445
- if (!value || typeof value !== "object") return undefined;
446
- const input = value as Record<string, unknown>;
447
- const byName: Record<string, number> = {};
448
- const rawByName = input["byName"];
449
- if (rawByName && typeof rawByName === "object") {
450
- for (const [name, count] of Object.entries(rawByName)) {
451
- const normalized = nonNegativeInteger(count);
452
- if (normalized > 0) byName[name] = normalized;
453
- }
454
- }
455
- const byNameTotal = Object.values(byName).reduce((sum, count) => sum + count, 0);
456
- const failed = nonNegativeInteger(input["failed"]);
457
- const total = Math.max(nonNegativeInteger(input["total"]), byNameTotal, failed);
458
- return total > 0 || failed > 0 ? { total, failed, byName } : undefined;
567
+ if (!isJsonObject(value)) return undefined;
568
+ const input: ToolUsagePayload = value;
569
+ const byName: Record<string, number> = {};
570
+ const rawByName = input.byName;
571
+ if (isJsonObject(rawByName)) {
572
+ for (const [name, count] of Object.entries(rawByName)) {
573
+ const normalized = nonNegativeInteger(count);
574
+ if (normalized > 0) byName[name] = normalized;
575
+ }
576
+ }
577
+ const byNameTotal = Object.values(byName).reduce((sum, count) => sum + count, 0);
578
+ const failed = nonNegativeInteger(input.failed);
579
+ const total = Math.max(nonNegativeInteger(input.total), byNameTotal, failed);
580
+ return total > 0 || failed > 0 ? { total, failed, byName } : undefined;
581
+ }
582
+
583
+ interface TelemetryControlPayload extends JsonObject {
584
+ readonly type?: unknown;
585
+ readonly contextUsage?: unknown;
586
+ readonly tokenUsage?: unknown;
587
+ readonly toolUsage?: unknown;
588
+ readonly model?: unknown;
589
+ }
590
+
591
+ interface TelemetryDelta {
592
+ context?: TaskContextUsage | undefined;
593
+ tokens?: TaskTokenUsage | undefined;
594
+ tools?: TaskToolUsage | undefined;
595
+ model?: string | undefined;
596
+ }
597
+
598
+ function noopOnChange(): void {
599
+ return undefined;
459
600
  }
460
601
 
461
602
  export class BackgroundTaskRegistry {
462
- private readonly tasks = new Map<string, BgTask>();
463
- private runtimeDir: RuntimeDir | undefined;
464
- private shuttingDown = false;
465
- private readonly spawn: BackgroundTaskSpawn;
466
- private readonly killProcess: KillProcessFn;
467
- private readonly platform: NodeJS.Platform;
468
- private readonly env: NodeJS.ProcessEnv;
469
- private readonly makeTaskIdFn: () => string;
470
- private readonly now: () => number;
471
- private readonly maxOutputBytes: number;
472
- private readonly maxRecentTasks: number;
473
- private readonly killGraceMs: number;
474
- private readonly stopWaitMs: number;
475
- private readonly logger: Pick<Console, "error">;
476
- private readonly onChange: () => void;
477
- private readonly sendCompletionNotification: CompletionNotificationSender;
478
-
479
- constructor(options: BackgroundTaskRegistryOptions) {
480
- this.spawn = options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions));
481
- this.killProcess = options.killProcess ?? process.kill.bind(process);
482
- this.platform = options.platform ?? process.platform;
483
- this.env = options.env ?? process.env;
484
- this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
485
- this.now = options.now ?? Date.now;
486
- this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
487
- this.maxRecentTasks = options.maxRecentTasks ?? MAX_RECENT_TASKS;
488
- this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
489
- this.stopWaitMs = options.stopWaitMs ?? STOP_WAIT_MS;
490
- this.logger = options.logger ?? console;
491
- this.onChange = options.onChange ?? (() => {});
492
- this.sendCompletionNotification = options.sendCompletionNotification;
493
- }
494
-
495
- isShuttingDown(): boolean {
496
- return this.shuttingDown;
497
- }
498
-
499
- setShuttingDown(value: boolean): void {
500
- this.shuttingDown = value;
501
- }
502
-
503
- allTasks(): BgTask[] {
504
- return [...this.tasks.values()];
505
- }
506
-
507
- snapshot(task: BgTask): BgTaskSnapshot {
508
- return snapshot(task);
509
- }
510
-
511
- async ensureRuntimeDir(ctx: BackgroundTaskContext): Promise<RuntimeDir> {
512
- if (this.runtimeDir) return this.runtimeDir;
513
- const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${process.pid}`);
514
- const runId = `${sessionId}-${process.pid}`;
515
- const runtimeDirAbs = join(ctx.cwd, ".pi", "tasks", runId);
516
- const runtimeDirDisplay = join(".pi", "tasks", runId);
517
- await mkdir(runtimeDirAbs, { recursive: true });
518
- this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
519
- return this.runtimeDir;
520
- }
521
-
522
- async startTask(ctx: BackgroundTaskContext, command: string, options: StartTaskOptions = {}): Promise<BgTask> {
523
- const normalizedCommand = stripMatchingQuotes(command);
524
- if (!normalizedCommand) throw new Error("Background command is empty");
525
- if (this.shuttingDown) throw new Error("Cannot start a background task while Pi is shutting down");
526
-
527
- const dir = await this.ensureRuntimeDir(ctx);
528
- const id = this.makeTaskIdFn();
529
- const outputAbsPath = join(dir.abs, `${id}.output`);
530
- const metadataAbsPath = join(dir.abs, `${id}.json`);
531
- const outputPath = join(dir.display, `${id}.output`);
532
- const timeoutSeconds =
533
- typeof options.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds) && options.timeoutSeconds > 0
534
- ? Math.floor(options.timeoutSeconds)
535
- : undefined;
536
- const taskName = normalizeTaskName(options.name) ?? normalizeTaskName(options.description) ?? deriveTaskNameFromCommand(normalizedCommand);
537
- const isAgent = options.isAgent ?? false;
538
-
539
- const task: BgTask = {
540
- id,
541
- name: taskName,
542
- command: normalizedCommand,
543
- description: options.description?.trim() || undefined,
544
- status: "running",
545
- outputPath,
546
- outputAbsPath,
547
- metadataAbsPath,
548
- cwd: ctx.cwd,
549
- startTime: this.now(),
550
- exitCode: undefined,
551
- pid: undefined,
552
- bytesWritten: 0,
553
- isAgent,
554
- notified: false,
555
- notifyOnCompletion: options.notifyOnCompletion ?? true,
556
- triggerOnCompletion: options.triggerOnCompletion ?? false,
557
- timeoutSeconds,
558
- waiters: [],
559
- };
560
- this.tasks.set(id, task);
561
-
562
- const stream = createWriteStream(outputAbsPath, { flags: "a", encoding: "utf8" });
563
- task.stream = stream;
564
- stream.on("error", (error) => {
565
- task.error = `Output file write failed: ${error.message}`;
566
- if (task.status === "running") {
567
- task.killKind = "output_cap";
568
- try {
569
- this.requestKill(task, "SIGTERM");
570
- } catch (killError) {
571
- void this.finalizeTask(
572
- task,
573
- "failed",
574
- null,
575
- undefined,
576
- `${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
577
- );
578
- }
579
- }
580
- });
581
-
582
- try {
583
- let commandToSpawn = normalizedCommand;
584
- if (isAgent && commandMayLaunchPiAgent(normalizedCommand, this.env)) {
585
- const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
586
- await writeFile(wrapperAbsPath, createPiTelemetryWrapperSource(buildModelWindowIndex(ctx)), "utf8");
587
- commandToSpawn = `pi() { node ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
588
- }
589
- const invocation = shellInvocation(commandToSpawn, this.platform, this.env);
590
- const child = this.spawn(invocation.shell, invocation.args, {
591
- cwd: ctx.cwd,
592
- detached: this.platform !== "win32",
593
- stdio: ["ignore", "pipe", "pipe"],
594
- env: this.env,
595
- windowsHide: true,
596
- });
597
-
598
- task.child = child;
599
- task.pid = child.pid;
600
-
601
- child.stdout?.on("data", (data) => this.appendToOutput(task, data));
602
- child.stderr?.on("data", (data) => this.appendToOutput(task, data));
603
-
604
- child.on("error", (error) => {
605
- this.appendToOutput(task, `\n[background task spawn error: ${error.message}]\n`);
606
- void this.finalizeTask(task, "failed", null, undefined, error.message);
607
- });
608
-
609
- child.on("close", (code, signalName) => {
610
- let status: TaskStatus;
611
- let error: string | undefined;
612
- if (task.killKind === "user" || task.killKind === "shutdown") {
613
- status = "killed";
614
- } else if (task.killKind === "timeout") {
615
- status = "failed";
616
- error = task.error || `Timed out after ${task.timeoutSeconds}s`;
617
- } else if (task.killKind === "output_cap") {
618
- status = "failed";
619
- error = task.error || `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
620
- } else if ((code ?? 0) === 0) {
621
- status = "completed";
622
- } else {
623
- status = "failed";
624
- error = `Exited with code ${code ?? "null"}${signalName ? ` (${signalName})` : ""}`;
625
- }
626
- void this.finalizeTask(task, status, code, signalName, error);
627
- });
628
-
629
- if (timeoutSeconds) {
630
- task.timeoutHandle = setTimeout(() => {
631
- if (task.status !== "running") return;
632
- task.killKind = "timeout";
633
- task.error = `Timed out after ${timeoutSeconds}s`;
634
- this.appendToOutput(task, `\n[background task timeout: ${task.error}]\n`);
635
- try {
636
- this.requestKill(task, "SIGTERM");
637
- } catch (error) {
638
- void this.finalizeTask(task, "failed", null, undefined, `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`);
639
- }
640
- }, timeoutSeconds * 1000);
641
- }
642
-
643
- await this.writeMetadata(task);
644
- this.onChange();
645
- return task;
646
- } catch (error) {
647
- const message = error instanceof Error ? error.message : String(error);
648
- this.appendToOutput(task, `\n[background task spawn exception: ${message}]\n`);
649
- await this.finalizeTask(task, "failed", null, undefined, message);
650
- throw new Error(`Failed to start background task: ${message}`);
651
- }
652
- }
653
-
654
- resolveTask(idOrPrefix: string): BgTask {
655
- const id = idOrPrefix.trim();
656
- if (!id) throw new Error("Task ID is required");
657
- const exact = this.tasks.get(id);
658
- if (exact) return exact;
659
- const matches = [...this.tasks.values()].filter((task) => task.id.startsWith(id));
660
- const onlyMatch = matches[0];
661
- if (matches.length === 1 && onlyMatch) return onlyMatch;
662
- if (matches.length > 1) throw new Error(`Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(", ")}`);
663
- throw new Error(`Unknown background task ID: ${id}`);
664
- }
665
-
666
- async stopTask(task: BgTask, kind: KillKind, reason?: string): Promise<BgTask> {
667
- if (task.status !== "running") {
668
- throw new Error(`Task ${task.id} is ${task.status}, not running`);
669
- }
670
- task.killKind = kind;
671
- if (reason) task.error = reason;
672
- this.requestKill(task, "SIGTERM");
673
- const stopped = await this.waitForEnd(task, this.stopWaitMs);
674
- if (!stopped) {
675
- throw new Error(`Task ${task.id} did not exit within ${formatDuration(this.stopWaitMs)} after SIGTERM/SIGKILL`);
676
- }
677
- return task;
678
- }
679
-
680
- async stopAllRunning(kind: KillKind, reason?: string): Promise<{ stopped: number; failures: string[] }> {
681
- const running = this.allTasks().filter((task) => task.status === "running");
682
- const failures: string[] = [];
683
- let stopped = 0;
684
- await Promise.all(
685
- running.map(async (task) => {
686
- try {
687
- await this.stopTask(task, kind, reason);
688
- stopped++;
689
- } catch (error) {
690
- failures.push(`${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`);
691
- }
692
- }),
693
- );
694
- return { stopped, failures };
695
- }
696
-
697
- async getTaskLogs(task: BgTask, maxBytes: number, tail: boolean): Promise<{ text: string; details: BgLogsDetails }> {
698
- if (!existsSync(task.outputAbsPath)) {
699
- throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
700
- }
701
- const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
702
- const direction = tail ? "tail" : "head";
703
- let text = read.content || "(no output yet)";
704
- if (read.truncated) {
705
- const omitted = read.totalBytes - read.bytesRead;
706
- const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
707
- text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
708
- } else {
709
- text += `\n\n[Full output: ${task.outputPath}]`;
710
- }
711
- return {
712
- text,
713
- details: {
714
- task: snapshot(task),
715
- path: task.outputPath,
716
- bytesRead: read.bytesRead,
717
- truncated: read.truncated,
718
- tail,
719
- },
720
- };
721
- }
722
-
723
- private async writeMetadata(task: BgTask): Promise<void> {
724
- await writeFile(task.metadataAbsPath, `${JSON.stringify(snapshot(task), null, 2)}\n`, "utf8");
725
- }
726
-
727
- private ingestTelemetry(task: BgTask, text: string): void {
728
- if (!text) return;
729
- const telemetryText = `${task.contextUsageBuffer ?? ""}${text}`;
730
- let latestContext = task.contextUsage;
731
- let latestTokens = task.tokenUsage;
732
- let latestTools = task.toolUsage;
733
- let latestModel = task.model;
734
- for (const line of telemetryText.split(/\r?\n/)) {
735
- if (!line.includes("background-task-")) continue;
736
- const trimmed = line.trim();
737
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
738
- try {
739
- const parsed = JSON.parse(trimmed);
740
- if (parsed?.type === "background-task-context-usage") {
741
- latestContext = normalizeContextUsage(parsed) ?? latestContext;
742
- } else if (parsed?.type === "background-task-telemetry") {
743
- latestContext = normalizeContextUsage(parsed.contextUsage) ?? latestContext;
744
- latestTokens = normalizeTokenUsage(parsed.tokenUsage) ?? latestTokens;
745
- latestTools = normalizeToolUsage(parsed.toolUsage) ?? latestTools;
746
- latestModel = normalizeModel(parsed.model) ?? latestModel;
747
- }
748
- } catch {
749
- // Ignore malformed optional telemetry; task output remains authoritative for debugging.
750
- }
751
- }
752
- }
753
- const xmlMatches = telemetryText.matchAll(/<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi);
754
- for (const match of xmlMatches) latestContext = parseContextUsageXml(match[0]) ?? latestContext;
755
-
756
- const lastNewline = Math.max(telemetryText.lastIndexOf("\n"), telemetryText.lastIndexOf("\r"));
757
- let retained = lastNewline >= 0 ? telemetryText.slice(lastNewline + 1) : telemetryText;
758
- const lastXmlOpen = telemetryText.toLowerCase().lastIndexOf("<background-task-context-usage");
759
- const lastXmlClose = telemetryText.toLowerCase().lastIndexOf("</background-task-context-usage>");
760
- if (lastXmlOpen > lastXmlClose) retained = telemetryText.slice(lastXmlOpen);
761
- task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
762
-
763
- const before = JSON.stringify({ contextUsage: task.contextUsage, tokenUsage: task.tokenUsage, toolUsage: task.toolUsage, model: task.model });
764
- task.contextUsage = latestContext;
765
- task.tokenUsage = latestTokens;
766
- task.toolUsage = latestTools;
767
- task.model = latestModel;
768
- const after = JSON.stringify({ contextUsage: task.contextUsage, tokenUsage: task.tokenUsage, toolUsage: task.toolUsage, model: task.model });
769
- if (before !== after) {
770
- this.onChange();
771
- void this.writeMetadata(task).catch((error) => {
772
- this.logger.error(`[background-tasks] failed to write telemetry metadata for ${task.id}:`, error);
773
- });
774
- }
775
- }
776
-
777
- private appendToOutput(task: BgTask, data: Buffer | string): void {
778
- if (!task.stream || task.stream.destroyed) return;
779
- const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
780
- if (buffer.length === 0) return;
781
- this.ingestTelemetry(task, buffer.toString("utf8"));
782
-
783
- const nextBytes = task.bytesWritten + buffer.length;
784
- if (nextBytes <= this.maxOutputBytes) {
785
- task.stream.write(buffer);
786
- task.bytesWritten = nextBytes;
787
- return;
788
- }
789
-
790
- const remaining = Math.max(0, this.maxOutputBytes - task.bytesWritten);
791
- if (remaining > 0) {
792
- task.stream.write(buffer.subarray(0, remaining));
793
- task.bytesWritten += remaining;
794
- }
795
-
796
- if (!task.capExceeded) {
797
- task.capExceeded = true;
798
- task.error = `Output exceeded cap of ${formatSize(this.maxOutputBytes)}; terminating task`;
799
- const notice = `\n\n[background task error: ${task.error}]\n`;
800
- task.stream.write(notice);
801
- task.bytesWritten += Buffer.byteLength(notice, "utf8");
802
- task.killKind = "output_cap";
803
- try {
804
- this.requestKill(task, "SIGTERM");
805
- } catch (error) {
806
- task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
807
- void this.finalizeTask(task, "failed", null, undefined, task.error);
808
- }
809
- }
810
- }
811
-
812
- private requestKill(task: BgTask, signal: NodeJS.Signals = "SIGTERM"): void {
813
- if (task.status !== "running") {
814
- throw new Error(`Task ${task.id} is ${task.status}, not running`);
815
- }
816
- if (!task.child) {
817
- throw new Error(`Task ${task.id} has no child process handle`);
818
- }
819
- if (!task.pid) {
820
- throw new Error(`Task ${task.id} has no process id`);
821
- }
822
- if (task.killSignalSent && signal === "SIGTERM") return;
823
-
824
- const errors: string[] = [];
825
- let killed = false;
826
-
827
- if (this.platform !== "win32") {
828
- try {
829
- this.killProcess(-task.pid, signal);
830
- killed = true;
831
- } catch (error) {
832
- errors.push(`process group kill failed: ${error instanceof Error ? error.message : String(error)}`);
833
- }
834
- }
835
-
836
- if (!killed) {
837
- try {
838
- task.child.kill(signal);
839
- killed = true;
840
- } catch (error) {
841
- errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
842
- }
843
- }
844
-
845
- if (!killed) {
846
- throw new Error(`Could not kill task ${task.id}: ${errors.join("; ")}`);
847
- }
848
-
849
- task.killSignalSent = true;
850
- setTimeout(() => {
851
- if (task.status !== "running") return;
852
- try {
853
- this.requestKill(task, "SIGKILL");
854
- } catch (error) {
855
- task.error = `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`;
856
- void this.writeMetadata(task).catch((metadataError) => {
857
- this.logger.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
858
- });
859
- }
860
- }, this.killGraceMs).unref?.();
861
- }
862
-
863
- private waitForEnd(task: BgTask, timeoutMs: number): Promise<boolean> {
864
- if (task.status !== "running") return Promise.resolve(true);
865
- return new Promise((resolve) => {
866
- const timeout = setTimeout(() => {
867
- const idx = task.waiters.indexOf(done);
868
- if (idx >= 0) task.waiters.splice(idx, 1);
869
- resolve(false);
870
- }, timeoutMs);
871
- const done = () => {
872
- clearTimeout(timeout);
873
- resolve(true);
874
- };
875
- task.waiters.push(done);
876
- });
877
- }
878
-
879
- private async notifyCompletion(task: BgTask): Promise<void> {
880
- if (!task.notifyOnCompletion || task.notified || this.shuttingDown) return;
881
- task.notified = true;
882
- const exit = task.exitCode === undefined ? "" : `\n <exit-code>${task.exitCode}</exit-code>`;
883
- const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : "";
884
- const taskName = taskDisplayName(task);
885
- const content = [
886
- "<background-task-notification>",
887
- ` <task-id>${task.id}</task-id>`,
888
- ` <task-name>${escapeXml(taskName)}</task-name>`,
889
- ` <status>${task.status}</status>`,
890
- exit,
891
- error,
892
- ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
893
- ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
894
- "</background-task-notification>",
895
- ]
896
- .filter(Boolean)
897
- .join("\n");
898
-
899
- try {
900
- this.sendCompletionNotification(
901
- {
902
- customType: "background-task-notification",
903
- content,
904
- display: true,
905
- details: snapshot(task),
906
- },
907
- { deliverAs: "followUp", triggerTurn: task.triggerOnCompletion },
908
- );
909
- } catch (error) {
910
- task.notified = false;
911
- throw new Error(`Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
912
- }
913
- }
914
-
915
- private async finalizeTask(task: BgTask, status: TaskStatus, exitCode: number | null, signal?: string | null, error?: string): Promise<void> {
916
- if (task.finalized) return;
917
- task.finalized = true;
918
- if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
919
- task.status = status;
920
- task.exitCode = exitCode;
921
- task.signal = signal ?? null;
922
- task.endTime = this.now();
923
- if (error) task.error = error;
924
- if (task.stream && !task.stream.destroyed) task.stream.end();
925
-
926
- for (const waiter of task.waiters.splice(0)) waiter();
927
-
928
- try {
929
- await this.writeMetadata(task);
930
- } catch (metadataError) {
931
- this.logger.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
932
- }
933
-
934
- this.onChange();
935
- try {
936
- await this.notifyCompletion(task);
937
- } catch (notificationError) {
938
- this.logger.error(`[background-tasks] notification failed for ${task.id}:`, notificationError);
939
- }
940
- try {
941
- await this.writeMetadata(task);
942
- } catch (metadataError) {
943
- this.logger.error(`[background-tasks] failed to update notification metadata for ${task.id}:`, metadataError);
944
- }
945
- this.pruneOldTasks();
946
- }
947
-
948
- private pruneOldTasks(): void {
949
- if (this.tasks.size <= this.maxRecentTasks) return;
950
- const removable = [...this.tasks.values()]
951
- .filter((task) => task.status !== "running")
952
- .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
953
- while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
954
- const task = removable.shift();
955
- if (task) this.tasks.delete(task.id);
956
- }
957
- }
603
+ private readonly tasks = new Map<string, BgTask>();
604
+ private runtimeDir: RuntimeDir | undefined;
605
+ private shuttingDown = false;
606
+ private readonly spawn: BackgroundTaskSpawn;
607
+ private readonly killProcess: KillProcessFn;
608
+ private readonly platform: NodeJS.Platform;
609
+ private readonly env: NodeJS.ProcessEnv;
610
+ private readonly makeTaskIdFn: () => string;
611
+ private readonly now: () => number;
612
+ private readonly maxOutputBytes: number;
613
+ private readonly maxRecentTasks: number;
614
+ private readonly killGraceMs: number;
615
+ private readonly stopWaitMs: number;
616
+ private readonly logger: Pick<Console, 'error'>;
617
+ private readonly onChange: () => void;
618
+ private readonly sendCompletionNotification: CompletionNotificationSender;
619
+ private readonly publishTerminalSnapshot: (task: BgTaskSnapshot) => void;
620
+
621
+ constructor(options: BackgroundTaskRegistryOptions) {
622
+ this.spawn =
623
+ options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions));
624
+ this.killProcess = options.killProcess ?? process.kill.bind(process);
625
+ this.platform = options.platform ?? process.platform;
626
+ this.env = options.env ?? process.env;
627
+ this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
628
+ this.now = options.now ?? Date.now;
629
+ this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
630
+ this.maxRecentTasks = options.maxRecentTasks ?? MAX_RECENT_TASKS;
631
+ this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
632
+ this.stopWaitMs = options.stopWaitMs ?? STOP_WAIT_MS;
633
+ this.logger = options.logger ?? console;
634
+ this.onChange = options.onChange ?? noopOnChange;
635
+ this.sendCompletionNotification = options.sendCompletionNotification;
636
+ this.publishTerminalSnapshot = options.publishTerminal ?? noopOnChange;
637
+ }
638
+
639
+ isShuttingDown(): boolean {
640
+ return this.shuttingDown;
641
+ }
642
+
643
+ setShuttingDown(value: boolean): void {
644
+ this.shuttingDown = value;
645
+ }
646
+
647
+ allTasks(): BgTask[] {
648
+ return [...this.tasks.values()];
649
+ }
650
+
651
+ snapshot(task: BgTask): BgTaskSnapshot {
652
+ return snapshot(task);
653
+ }
654
+
655
+ async ensureRuntimeDir(ctx: BackgroundTaskContext): Promise<RuntimeDir> {
656
+ if (this.runtimeDir) return this.runtimeDir;
657
+ const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
658
+ const runId = `${sessionId}-${String(process.pid)}`;
659
+ const runtimeDirAbs = join(ctx.cwd, '.pi', 'tasks', runId);
660
+ const runtimeDirDisplay = join('.pi', 'tasks', runId);
661
+ await mkdir(runtimeDirAbs, { recursive: true });
662
+ this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
663
+ return this.runtimeDir;
664
+ }
665
+
666
+ async startTask(
667
+ ctx: BackgroundTaskContext,
668
+ command: string,
669
+ options: StartTaskOptions = {},
670
+ ): Promise<BgTask> {
671
+ const normalizedCommand = command.trim();
672
+ if (!normalizedCommand) throw new Error('Background command is empty');
673
+ if (this.shuttingDown)
674
+ throw new Error('Cannot start a background task while Pi is shutting down');
675
+
676
+ const dir = await this.ensureRuntimeDir(ctx);
677
+ const id = this.makeTaskIdFn();
678
+ const outputAbsPath = join(dir.abs, `${id}.output`);
679
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
680
+ const outputPath = join(dir.display, `${id}.output`);
681
+ const timeoutSeconds =
682
+ typeof options.timeoutSeconds === 'number' &&
683
+ Number.isFinite(options.timeoutSeconds) &&
684
+ options.timeoutSeconds > 0
685
+ ? Math.floor(options.timeoutSeconds)
686
+ : undefined;
687
+ const taskName =
688
+ normalizeTaskName(options.name) ??
689
+ normalizeTaskName(options.description) ??
690
+ deriveTaskNameFromCommand(normalizedCommand);
691
+ const isAgent = options.isAgent ?? false;
692
+ const trimmedDescription = options.description?.trim();
693
+ const description =
694
+ trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : undefined;
695
+
696
+ const task: BgTask = {
697
+ id,
698
+ name: taskName,
699
+ command: normalizedCommand,
700
+ description,
701
+ status: 'running',
702
+ outputPath,
703
+ outputAbsPath,
704
+ metadataAbsPath,
705
+ cwd: ctx.cwd,
706
+ startTime: this.now(),
707
+ exitCode: undefined,
708
+ pid: undefined,
709
+ bytesWritten: 0,
710
+ isAgent,
711
+ notified: false,
712
+ notifyOnCompletion: options.notifyOnCompletion ?? true,
713
+ triggerOnCompletion: options.triggerOnCompletion ?? false,
714
+ timeoutSeconds,
715
+ terminalPublicationGate: options.terminalPublicationGate,
716
+ waiters: [],
717
+ };
718
+ this.tasks.set(id, task);
719
+
720
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
721
+ task.stream = stream;
722
+ stream.on('error', (error) => {
723
+ task.error = `Output file write failed: ${error.message}`;
724
+ if (task.status === 'running') {
725
+ task.killKind = 'output_cap';
726
+ try {
727
+ this.requestKill(task, 'SIGTERM');
728
+ } catch (killError) {
729
+ void this.finalizeTask(
730
+ task,
731
+ 'failed',
732
+ null,
733
+ undefined,
734
+ `${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
735
+ );
736
+ }
737
+ }
738
+ });
739
+
740
+ try {
741
+ let commandToSpawn = normalizedCommand;
742
+ if (isAgent && commandMayLaunchPiAgent(normalizedCommand, this.env)) {
743
+ const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
744
+ await writeFile(
745
+ wrapperAbsPath,
746
+ createPiTelemetryWrapperSource(buildModelWindowIndex(ctx)),
747
+ 'utf8',
748
+ );
749
+ commandToSpawn = `pi() { node ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
750
+ task.telemetryWrapped = true;
751
+ }
752
+ const invocation = shellInvocation(commandToSpawn, this.platform, this.env);
753
+ const child = this.spawn(invocation.shell, invocation.args, {
754
+ cwd: ctx.cwd,
755
+ detached: this.platform !== 'win32',
756
+ stdio: ['ignore', 'pipe', 'pipe'],
757
+ env: this.env,
758
+ windowsHide: true,
759
+ });
760
+
761
+ task.child = child;
762
+ task.pid = child.pid;
763
+
764
+ child.stdout?.on('data', (data) => {
765
+ this.appendChildOutput(task, data, 'stdout');
766
+ });
767
+ child.stderr?.on('data', (data) => {
768
+ this.appendChildOutput(task, data, 'stderr');
769
+ });
770
+
771
+ child.on('error', (error) => {
772
+ this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
773
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
774
+ });
775
+
776
+ child.on('close', (code, signalName) => {
777
+ let status: TaskStatus;
778
+ let error: string | undefined;
779
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
780
+ status = 'killed';
781
+ } else if (task.killKind === 'timeout') {
782
+ status = 'failed';
783
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
784
+ } else if (task.killKind === 'output_cap') {
785
+ status = 'failed';
786
+ error = task.error ?? `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
787
+ } else if ((code ?? 0) === 0) {
788
+ status = 'completed';
789
+ } else {
790
+ status = 'failed';
791
+ const exitCode = code === null ? 'null' : String(code);
792
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
793
+ }
794
+ void this.finalizeTask(task, status, code, signalName, error);
795
+ });
796
+
797
+ if (timeoutSeconds !== undefined) {
798
+ task.timeoutHandle = setTimeout(() => {
799
+ if (task.status !== 'running') return;
800
+ task.killKind = 'timeout';
801
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
802
+ this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
803
+ try {
804
+ this.requestKill(task, 'SIGTERM');
805
+ } catch (error) {
806
+ void this.finalizeTask(
807
+ task,
808
+ 'failed',
809
+ null,
810
+ undefined,
811
+ `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
812
+ );
813
+ }
814
+ }, timeoutSeconds * 1000);
815
+ }
816
+
817
+ await this.writeMetadata(task);
818
+ this.onChange();
819
+ return task;
820
+ } catch (error) {
821
+ const message = error instanceof Error ? error.message : String(error);
822
+ this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
823
+ await this.finalizeTask(task, 'failed', null, undefined, message);
824
+ throw new Error(`Failed to start background task: ${message}`);
825
+ }
826
+ }
827
+
828
+ async startAttestedPiTask(
829
+ ctx: BackgroundTaskContext,
830
+ request: StartAttestedPiTaskOptions,
831
+ ): Promise<BgTask> {
832
+ if (this.shuttingDown)
833
+ throw new Error('Cannot start an attested Pi task while Pi is shutting down');
834
+
835
+ const dir = await this.ensureRuntimeDir(ctx);
836
+ const id = makeAttestedTaskId();
837
+ if (!ATTESTED_TASK_ID_PATTERN.test(id))
838
+ throw new Error('Generated attested task id is invalid');
839
+ const paths = makeAttestedTaskPaths(dir.abs, dir.display, id);
840
+ const argv = buildAttestedPiArgv(request);
841
+ const promptBytes = Buffer.from(request.prompt, 'utf8');
842
+ const reportAbsPath = await resolveReportPath(ctx.cwd, request.reportPath);
843
+ const auth = observePiOAuth(ctx, request.provider, request.model);
844
+ const repoRootRealpath = await gitRepoRoot(ctx.cwd);
845
+ const cwdRealpath = await realpath(ctx.cwd);
846
+ const startAuthority = await gitAuthoritySnapshot(ctx.cwd);
847
+ if (!startAuthority.clean)
848
+ throw new Error('Attested Pi task requires a clean worktree at start');
849
+ const timeoutSeconds =
850
+ typeof request.timeoutSeconds === 'number' &&
851
+ Number.isFinite(request.timeoutSeconds) &&
852
+ request.timeoutSeconds > 0
853
+ ? Math.floor(request.timeoutSeconds)
854
+ : undefined;
855
+
856
+ const task: BgTask = {
857
+ id,
858
+ name: normalizeTaskName(request.name) ?? 'Attested Pi task',
859
+ command: argv.map(shellQuote).join(' '),
860
+ status: 'running',
861
+ outputPath: paths.outputPath,
862
+ outputAbsPath: paths.outputAbsPath,
863
+ metadataAbsPath: paths.metadataAbsPath,
864
+ eventsAbsPath: paths.eventsAbsPath,
865
+ stderrAbsPath: paths.stderrAbsPath,
866
+ wrapperAbsPath: paths.wrapperAbsPath,
867
+ attestationAbsPath: paths.attestationAbsPath,
868
+ cwd: ctx.cwd,
869
+ startTime: this.now(),
870
+ exitCode: undefined,
871
+ pid: undefined,
872
+ bytesWritten: 0,
873
+ isAgent: true,
874
+ notified: false,
875
+ notifyOnCompletion: false,
876
+ triggerOnCompletion: false,
877
+ timeoutSeconds,
878
+ attestationPath: paths.attestationPath,
879
+ attestedPi: {
880
+ eventsPath: paths.eventsPath,
881
+ stderrPath: paths.stderrPath,
882
+ wrapperPath: paths.wrapperPath,
883
+ attestationPath: paths.attestationPath,
884
+ },
885
+ waiters: [],
886
+ };
887
+ this.tasks.set(id, task);
888
+
889
+ await writeFileFsynced(paths.outputAbsPath, '');
890
+ await writeFileFsynced(paths.eventsAbsPath, '');
891
+ await writeFileFsynced(paths.stderrAbsPath, '');
892
+ await writeFileFsynced(
893
+ paths.wrapperAbsPath,
894
+ 'direct-spawn attested Pi task; no shell telemetry wrapper is used\n',
895
+ );
896
+ await this.writeMetadata(task);
897
+
898
+ const captured = spawnAndCapturePi(this.spawn, argv, {
899
+ cwd: ctx.cwd,
900
+ detached: this.platform !== 'win32',
901
+ stdio: ['ignore', 'pipe', 'pipe'],
902
+ env: attestedPiChildEnv(this.env),
903
+ windowsHide: true,
904
+ });
905
+ task.child = captured.child;
906
+ task.pid = captured.child.pid;
907
+ await this.writeMetadata(task);
908
+ this.onChange();
909
+
910
+ captured.child.on('error', (error) => {
911
+ void this.finalizeAttestedPiTask(
912
+ task,
913
+ paths,
914
+ argv,
915
+ cwdRealpath,
916
+ repoRootRealpath,
917
+ startAuthority,
918
+ auth,
919
+ promptBytes,
920
+ reportAbsPath,
921
+ captured.stdoutChunks,
922
+ captured.stderrChunks,
923
+ 'failed',
924
+ null,
925
+ null,
926
+ error.message,
927
+ );
928
+ });
929
+
930
+ captured.child.on('close', (code, signalName) => {
931
+ let status: TaskStatus = (code ?? 0) === 0 && signalName === null ? 'completed' : 'failed';
932
+ let error: string | undefined;
933
+ if (task.killKind === 'timeout') {
934
+ status = 'failed';
935
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
936
+ } else if (task.killKind === 'user' || task.killKind === 'shutdown') {
937
+ status = 'killed';
938
+ error = task.error;
939
+ } else if (status === 'failed') {
940
+ const exitCode = code === null ? 'null' : String(code);
941
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
942
+ }
943
+ void this.finalizeAttestedPiTask(
944
+ task,
945
+ paths,
946
+ argv,
947
+ cwdRealpath,
948
+ repoRootRealpath,
949
+ startAuthority,
950
+ auth,
951
+ promptBytes,
952
+ reportAbsPath,
953
+ captured.stdoutChunks,
954
+ captured.stderrChunks,
955
+ status,
956
+ code,
957
+ signalName,
958
+ error,
959
+ );
960
+ });
961
+
962
+ if (timeoutSeconds !== undefined) {
963
+ task.timeoutHandle = setTimeout(() => {
964
+ if (task.status !== 'running') return;
965
+ task.killKind = 'timeout';
966
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
967
+ try {
968
+ this.requestKill(task, 'SIGTERM');
969
+ } catch (error) {
970
+ void this.finalizeAttestedPiTask(
971
+ task,
972
+ paths,
973
+ argv,
974
+ cwdRealpath,
975
+ repoRootRealpath,
976
+ startAuthority,
977
+ auth,
978
+ promptBytes,
979
+ reportAbsPath,
980
+ captured.stdoutChunks,
981
+ captured.stderrChunks,
982
+ 'failed',
983
+ null,
984
+ null,
985
+ error instanceof Error ? error.message : String(error),
986
+ );
987
+ }
988
+ }, timeoutSeconds * 1000);
989
+ }
990
+
991
+ return task;
992
+ }
993
+
994
+ private async finalizeAttestedPiTask(
995
+ task: BgTask,
996
+ paths: ReturnType<typeof makeAttestedTaskPaths>,
997
+ argv: string[],
998
+ cwdRealpath: string,
999
+ repoRootRealpath: string,
1000
+ startAuthority: Awaited<ReturnType<typeof gitAuthoritySnapshot>>,
1001
+ auth: ReturnType<typeof observePiOAuth>,
1002
+ promptBytes: Buffer,
1003
+ reportAbsPath: string,
1004
+ stdoutChunks: Buffer[],
1005
+ stderrChunks: Buffer[],
1006
+ status: TaskStatus,
1007
+ exitCode: number | null,
1008
+ signal: NodeJS.Signals | null,
1009
+ error?: string,
1010
+ ): Promise<void> {
1011
+ if (task.finalized) return;
1012
+ task.finalized = true;
1013
+ if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
1014
+ let finalStatus = status;
1015
+ task.exitCode = exitCode;
1016
+ task.signal = signal;
1017
+ task.endTime = this.now();
1018
+ if (error) task.error = error;
1019
+
1020
+ const rawEvents = Buffer.concat(stdoutChunks);
1021
+ const rawStderr = Buffer.concat(stderrChunks);
1022
+ await writeFileFsynced(paths.eventsAbsPath, rawEvents);
1023
+ await writeFileFsynced(paths.stderrAbsPath, rawStderr);
1024
+
1025
+ let parsed: ReturnType<typeof parsePiJsonEvents> | undefined;
1026
+ if (finalStatus === 'completed') {
1027
+ try {
1028
+ parsed = parsePiJsonEvents(rawEvents);
1029
+ task.model = parsed.providerScopedModelId;
1030
+ task.tokenUsage = {
1031
+ input: parsed.tokenUsage.input,
1032
+ output: parsed.tokenUsage.output,
1033
+ cacheRead: parsed.tokenUsage.cacheRead,
1034
+ cacheWrite: parsed.tokenUsage.cacheWrite,
1035
+ totalTokens: parsed.tokenUsage.totalTokens,
1036
+ };
1037
+ if (parsed.tokenUsage.costTotal !== undefined)
1038
+ task.tokenUsage.costTotal = parsed.tokenUsage.costTotal;
1039
+ task.toolUsage = parsed.toolUsage;
1040
+ const outputBytes = Buffer.from(parsed.humanTranscript, 'utf8');
1041
+ task.bytesWritten = outputBytes.length;
1042
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1043
+ } catch (parseError) {
1044
+ finalStatus = 'failed';
1045
+ task.error = parseError instanceof Error ? parseError.message : String(parseError);
1046
+ const outputBytes = Buffer.from(`[attested Pi task error: ${task.error}]\n`, 'utf8');
1047
+ task.bytesWritten = outputBytes.length;
1048
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1049
+ }
1050
+ } else {
1051
+ const outputBytes = Buffer.from(rawStderr.toString('utf8'), 'utf8');
1052
+ task.bytesWritten = outputBytes.length;
1053
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1054
+ }
1055
+
1056
+ try {
1057
+ if (finalStatus === 'completed' && parsed) {
1058
+ const finishAuthority = await gitAuthoritySnapshot(task.cwd);
1059
+ const completedSnapshot: BgTaskSnapshot = { ...snapshot(task), status: 'completed' };
1060
+ await this.writeMetadataSnapshot(task, completedSnapshot);
1061
+ const attestation = await buildPiTaskAttestation({
1062
+ task: completedSnapshot,
1063
+ paths,
1064
+ sessionDir: dirNameFromDisplay(paths.outputPath),
1065
+ argv,
1066
+ cwdRealpath,
1067
+ repoRootRealpath,
1068
+ startAuthority,
1069
+ finishAuthority,
1070
+ parsedEvents: parsed,
1071
+ auth,
1072
+ prompt: promptBytes,
1073
+ reportAbsPath,
1074
+ });
1075
+ await writeJsonAtomic(paths.attestationAbsPath, attestation);
1076
+ } else {
1077
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
1078
+ }
1079
+ } catch (attestationError) {
1080
+ finalStatus = 'failed';
1081
+ task.error =
1082
+ attestationError instanceof Error ? attestationError.message : String(attestationError);
1083
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: 'failed' }).catch(
1084
+ (metadataError: unknown) => {
1085
+ this.logger.error(
1086
+ `[background-tasks] failed to write failed attested metadata for ${task.id}:`,
1087
+ metadataError,
1088
+ );
1089
+ },
1090
+ );
1091
+ }
1092
+
1093
+ task.status = finalStatus;
1094
+ for (const waiter of task.waiters.splice(0)) waiter();
1095
+ this.onChange();
1096
+ this.publishTerminal(task);
1097
+ this.pruneOldTasks();
1098
+ }
1099
+
1100
+ resolveTask(idOrPrefix: string): BgTask {
1101
+ const id = idOrPrefix.trim();
1102
+ if (!id) throw new Error('Task ID is required');
1103
+ const exact = this.tasks.get(id);
1104
+ if (exact) return exact;
1105
+ const matches = [...this.tasks.values()].filter((task) => task.id.startsWith(id));
1106
+ const onlyMatch = matches[0];
1107
+ if (matches.length === 1 && onlyMatch) return onlyMatch;
1108
+ if (matches.length > 1)
1109
+ throw new Error(
1110
+ `Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(', ')}`,
1111
+ );
1112
+ throw new Error(`Unknown background task ID: ${id}`);
1113
+ }
1114
+
1115
+ async stopTask(task: BgTask, kind: KillKind, reason?: string): Promise<BgTask> {
1116
+ if (task.status !== 'running') {
1117
+ throw new Error(`Task ${task.id} is ${task.status}, not running`);
1118
+ }
1119
+ task.killKind = kind;
1120
+ if (reason) task.error = reason;
1121
+ this.requestKill(task, 'SIGTERM');
1122
+ const stopped = await this.waitForEnd(task, this.stopWaitMs);
1123
+ if (!stopped) {
1124
+ throw new Error(
1125
+ `Task ${task.id} did not exit within ${formatDuration(this.stopWaitMs)} after SIGTERM/SIGKILL`,
1126
+ );
1127
+ }
1128
+ return task;
1129
+ }
1130
+
1131
+ async stopAllRunning(
1132
+ kind: KillKind,
1133
+ reason?: string,
1134
+ ): Promise<{ stopped: number; failures: string[] }> {
1135
+ const running = this.allTasks().filter((task) => task.status === 'running');
1136
+ const failures: string[] = [];
1137
+ let stopped = 0;
1138
+ await Promise.all(
1139
+ running.map(async (task) => {
1140
+ try {
1141
+ await this.stopTask(task, kind, reason);
1142
+ stopped++;
1143
+ } catch (error) {
1144
+ failures.push(
1145
+ `${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`,
1146
+ );
1147
+ }
1148
+ }),
1149
+ );
1150
+ return { stopped, failures };
1151
+ }
1152
+
1153
+ async getTaskLogs(
1154
+ task: BgTask,
1155
+ maxBytes: number,
1156
+ tail: boolean,
1157
+ ): Promise<{ text: string; details: BgLogsDetails }> {
1158
+ if (!existsSync(task.outputAbsPath)) {
1159
+ throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
1160
+ }
1161
+ const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
1162
+ const direction = tail ? 'tail' : 'head';
1163
+ let text = read.content.length > 0 ? read.content : '(no output yet)';
1164
+ if (read.truncated) {
1165
+ const omitted = read.totalBytes - read.bytesRead;
1166
+ const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
1167
+ text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
1168
+ } else {
1169
+ text += `\n\n[Full output: ${task.outputPath}]`;
1170
+ }
1171
+ return {
1172
+ text,
1173
+ details: {
1174
+ task: snapshot(task),
1175
+ path: task.outputPath,
1176
+ bytesRead: read.bytesRead,
1177
+ truncated: read.truncated,
1178
+ tail,
1179
+ },
1180
+ };
1181
+ }
1182
+
1183
+ private async writeMetadata(task: BgTask): Promise<void> {
1184
+ await this.writeMetadataSnapshot(task, snapshot(task));
1185
+ }
1186
+
1187
+ private async writeMetadataSnapshot(task: BgTask, value: BgTaskSnapshot): Promise<void> {
1188
+ const write = async () => {
1189
+ await writeJsonAtomic(task.metadataAbsPath, value);
1190
+ };
1191
+ const previous = task.metadataWriteChain ?? Promise.resolve();
1192
+ const next = previous.then(write, write);
1193
+ task.metadataWriteChain = next.catch(() => undefined);
1194
+ await next;
1195
+ }
1196
+
1197
+ private ingestTelemetry(task: BgTask, text: string): void {
1198
+ if (!text) return;
1199
+ const telemetryText = `${task.contextUsageBuffer ?? ''}${text}`;
1200
+ let latestContext = task.contextUsage;
1201
+ let latestTokens = task.tokenUsage;
1202
+ let latestTools = task.toolUsage;
1203
+ let latestModel = task.model;
1204
+ for (const line of telemetryText.split(/\r?\n/)) {
1205
+ if (!line.includes('background-task-')) continue;
1206
+ const trimmed = line.trim();
1207
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
1208
+ try {
1209
+ const parsed = parseJsonText(trimmed);
1210
+ if (!isJsonObject(parsed)) continue;
1211
+ const payload: TelemetryControlPayload = parsed;
1212
+ if (payload.type === 'background-task-context-usage') {
1213
+ latestContext = normalizeContextUsage(payload) ?? latestContext;
1214
+ } else if (payload.type === 'background-task-telemetry') {
1215
+ latestContext = normalizeContextUsage(payload.contextUsage) ?? latestContext;
1216
+ latestTokens = normalizeTokenUsage(payload.tokenUsage) ?? latestTokens;
1217
+ latestTools = normalizeToolUsage(payload.toolUsage) ?? latestTools;
1218
+ latestModel = normalizeModel(payload.model) ?? latestModel;
1219
+ }
1220
+ } catch {
1221
+ // Ignore malformed optional telemetry; task output remains authoritative for debugging.
1222
+ }
1223
+ }
1224
+ }
1225
+ const xmlMatches = telemetryText.matchAll(
1226
+ /<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi,
1227
+ );
1228
+ for (const match of xmlMatches) latestContext = parseContextUsageXml(match[0]) ?? latestContext;
1229
+
1230
+ const lastNewline = Math.max(telemetryText.lastIndexOf('\n'), telemetryText.lastIndexOf('\r'));
1231
+ let retained = lastNewline >= 0 ? telemetryText.slice(lastNewline + 1) : telemetryText;
1232
+ const lastXmlOpen = telemetryText.toLowerCase().lastIndexOf('<background-task-context-usage');
1233
+ const lastXmlClose = telemetryText
1234
+ .toLowerCase()
1235
+ .lastIndexOf('</background-task-context-usage>');
1236
+ if (lastXmlOpen > lastXmlClose) retained = telemetryText.slice(lastXmlOpen);
1237
+ task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
1238
+
1239
+ this.commitTelemetry(task, {
1240
+ context: latestContext,
1241
+ tokens: latestTokens,
1242
+ tools: latestTools,
1243
+ model: latestModel,
1244
+ });
1245
+ }
1246
+
1247
+ /** Apply the latest parsed telemetry to a task, persisting metadata and notifying the UI only on change. */
1248
+ private commitTelemetry(task: BgTask, next: TelemetryDelta): void {
1249
+ const before = JSON.stringify({
1250
+ contextUsage: task.contextUsage,
1251
+ tokenUsage: task.tokenUsage,
1252
+ toolUsage: task.toolUsage,
1253
+ model: task.model,
1254
+ });
1255
+ if (next.context !== undefined) task.contextUsage = next.context;
1256
+ if (next.tokens !== undefined) task.tokenUsage = next.tokens;
1257
+ if (next.tools !== undefined) task.toolUsage = next.tools;
1258
+ if (next.model !== undefined) task.model = next.model;
1259
+ const after = JSON.stringify({
1260
+ contextUsage: task.contextUsage,
1261
+ tokenUsage: task.tokenUsage,
1262
+ toolUsage: task.toolUsage,
1263
+ model: task.model,
1264
+ });
1265
+ if (before !== after) {
1266
+ this.onChange();
1267
+ void this.writeMetadata(task).catch((error: unknown) => {
1268
+ this.logger.error(
1269
+ `[background-tasks] failed to write telemetry metadata for ${task.id}:`,
1270
+ error,
1271
+ );
1272
+ });
1273
+ }
1274
+ }
1275
+
1276
+ /** Cap-enforcing sink for all persisted task output; terminates the task once the byte cap is exceeded. */
1277
+ private writeToStream(task: BgTask, buffer: Buffer): void {
1278
+ if (!task.stream || task.stream.destroyed) return;
1279
+ if (buffer.length === 0) return;
1280
+
1281
+ const nextBytes = task.bytesWritten + buffer.length;
1282
+ if (nextBytes <= this.maxOutputBytes) {
1283
+ task.stream.write(buffer);
1284
+ task.bytesWritten = nextBytes;
1285
+ return;
1286
+ }
1287
+
1288
+ const remaining = Math.max(0, this.maxOutputBytes - task.bytesWritten);
1289
+ if (remaining > 0) {
1290
+ task.stream.write(buffer.subarray(0, remaining));
1291
+ task.bytesWritten += remaining;
1292
+ }
1293
+
1294
+ if (!task.capExceeded) {
1295
+ task.capExceeded = true;
1296
+ task.error = `Output exceeded cap of ${formatSize(this.maxOutputBytes)}; terminating task`;
1297
+ const notice = `\n\n[background task error: ${task.error}]\n`;
1298
+ task.stream.write(notice);
1299
+ task.bytesWritten += Buffer.byteLength(notice, 'utf8');
1300
+ task.killKind = 'output_cap';
1301
+ try {
1302
+ this.requestKill(task, 'SIGTERM');
1303
+ } catch (error) {
1304
+ task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
1305
+ void this.finalizeTask(task, 'failed', null, undefined, task.error);
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ /** Persist an internally generated notice (spawn/timeout/cap diagnostics) verbatim. */
1311
+ private writeNotice(task: BgTask, text: string): void {
1312
+ if (!text) return;
1313
+ this.writeToStream(task, Buffer.from(text, 'utf8'));
1314
+ }
1315
+
1316
+ private appendChildOutput(
1317
+ task: BgTask,
1318
+ data: Buffer | string,
1319
+ source: 'stdout' | 'stderr',
1320
+ ): void {
1321
+ if (!task.stream || task.stream.destroyed) return;
1322
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
1323
+ if (buffer.length === 0) return;
1324
+ if (task.telemetryWrapped) {
1325
+ // Wrapped Pi agents stream control lines on stdout (telemetry + activity); child
1326
+ // stderr is raw diagnostics and is always passed through to the transcript verbatim.
1327
+ if (source === 'stdout') this.processAgentStdout(task, buffer.toString('utf8'));
1328
+ else this.writeToStream(task, buffer);
1329
+ return;
1330
+ }
1331
+ this.ingestTelemetry(task, buffer.toString('utf8'));
1332
+ this.writeToStream(task, buffer);
1333
+ }
1334
+
1335
+ /** Reconstruct wrapped-agent stdout into whole control lines, routing telemetry to metrics and activity to the transcript. */
1336
+ private processAgentStdout(task: BgTask, text: string): void {
1337
+ const buffered = `${task.agentStdoutBuffer ?? ''}${text}`;
1338
+ const lastNewline = buffered.lastIndexOf('\n');
1339
+ task.agentStdoutBuffer = lastNewline >= 0 ? buffered.slice(lastNewline + 1) : buffered;
1340
+ if (lastNewline < 0) return;
1341
+ const latest: TelemetryDelta = {};
1342
+ for (const line of buffered.slice(0, lastNewline).split('\n'))
1343
+ this.consumeAgentLine(task, line, latest);
1344
+ this.commitTelemetry(task, latest);
1345
+ }
1346
+
1347
+ /** Flush a trailing partial wrapped-agent line on finalize so the last transcript fragment is never lost. */
1348
+ private flushAgentStdout(task: BgTask): void {
1349
+ const remainder = task.agentStdoutBuffer;
1350
+ if (!remainder) return;
1351
+ task.agentStdoutBuffer = '';
1352
+ const latest: TelemetryDelta = {};
1353
+ this.consumeAgentLine(task, remainder, latest);
1354
+ this.commitTelemetry(task, latest);
1355
+ }
1356
+
1357
+ private consumeAgentLine(task: BgTask, rawLine: string, latest: TelemetryDelta): void {
1358
+ const line = rawLine.replace(/\r$/, '');
1359
+ const trimmed = line.trim();
1360
+ if (!trimmed) return;
1361
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
1362
+ this.writeNotice(task, `${line}\n`);
1363
+ return;
1364
+ }
1365
+ let parsed: unknown;
1366
+ try {
1367
+ parsed = parseJsonText(trimmed);
1368
+ } catch {
1369
+ this.writeNotice(task, `${line}\n`);
1370
+ return;
1371
+ }
1372
+ if (!isJsonObject(parsed)) {
1373
+ this.writeNotice(task, `${line}\n`);
1374
+ return;
1375
+ }
1376
+ const record: TelemetryControlPayload = parsed;
1377
+ const type = record.type;
1378
+ if (type === 'background-task-context-usage') {
1379
+ const context = normalizeContextUsage(record);
1380
+ if (context) latest.context = context;
1381
+ return;
1382
+ }
1383
+ if (type === 'background-task-telemetry') {
1384
+ const context = normalizeContextUsage(record.contextUsage);
1385
+ if (context) latest.context = context;
1386
+ const tokens = normalizeTokenUsage(record.tokenUsage);
1387
+ if (tokens) latest.tokens = tokens;
1388
+ const tools = normalizeToolUsage(record.toolUsage);
1389
+ if (tools) latest.tools = tools;
1390
+ const model = normalizeModel(record.model);
1391
+ if (model) latest.model = model;
1392
+ return;
1393
+ }
1394
+ const activity = parseAgentActivity(parsed);
1395
+ if (activity) {
1396
+ const formatted = formatAgentActivityLine(activity);
1397
+ if (formatted) this.writeNotice(task, `${formatted}\n`);
1398
+ return;
1399
+ }
1400
+ // Unknown JSON object: pass through to the transcript rather than silently dropping it.
1401
+ this.writeNotice(task, `${line}\n`);
1402
+ }
1403
+
1404
+ private requestKill(task: BgTask, signal: NodeJS.Signals = 'SIGTERM'): void {
1405
+ if (task.status !== 'running') {
1406
+ throw new Error(`Task ${task.id} is ${task.status}, not running`);
1407
+ }
1408
+ if (!task.child) {
1409
+ throw new Error(`Task ${task.id} has no child process handle`);
1410
+ }
1411
+ if (!task.pid) {
1412
+ throw new Error(`Task ${task.id} has no process id`);
1413
+ }
1414
+ if (task.killSignalSent && signal === 'SIGTERM') return;
1415
+
1416
+ const errors: string[] = [];
1417
+ let killed = false;
1418
+
1419
+ if (this.platform !== 'win32') {
1420
+ try {
1421
+ this.killProcess(-task.pid, signal);
1422
+ killed = true;
1423
+ } catch (error) {
1424
+ errors.push(
1425
+ `process group kill failed: ${error instanceof Error ? error.message : String(error)}`,
1426
+ );
1427
+ }
1428
+ }
1429
+
1430
+ if (!killed) {
1431
+ try {
1432
+ task.child.kill(signal);
1433
+ killed = true;
1434
+ } catch (error) {
1435
+ errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
1436
+ }
1437
+ }
1438
+
1439
+ if (!killed) {
1440
+ throw new Error(`Could not kill task ${task.id}: ${errors.join('; ')}`);
1441
+ }
1442
+
1443
+ task.killSignalSent = true;
1444
+ setTimeout(() => {
1445
+ if (task.status !== 'running') return;
1446
+ try {
1447
+ this.requestKill(task, 'SIGKILL');
1448
+ } catch (error) {
1449
+ task.error = `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`;
1450
+ void this.writeMetadata(task).catch((metadataError: unknown) => {
1451
+ this.logger.error(
1452
+ `[background-tasks] failed to write metadata for ${task.id}:`,
1453
+ metadataError,
1454
+ );
1455
+ });
1456
+ }
1457
+ }, this.killGraceMs).unref();
1458
+ }
1459
+
1460
+ private waitForEnd(task: BgTask, timeoutMs: number): Promise<boolean> {
1461
+ if (task.status !== 'running') return Promise.resolve(true);
1462
+ return new Promise((resolve) => {
1463
+ const timeout = setTimeout(() => {
1464
+ const idx = task.waiters.indexOf(done);
1465
+ if (idx >= 0) task.waiters.splice(idx, 1);
1466
+ resolve(false);
1467
+ }, timeoutMs);
1468
+ const done = () => {
1469
+ clearTimeout(timeout);
1470
+ resolve(true);
1471
+ };
1472
+ task.waiters.push(done);
1473
+ });
1474
+ }
1475
+
1476
+ private publishTerminal(task: BgTask): void {
1477
+ if (task.terminalPublished || task.terminalPublishInFlight) return;
1478
+ task.terminalPublishInFlight = true;
1479
+ if (task.terminalPublicationGate === undefined) {
1480
+ this.tryPublishTerminalNow(task);
1481
+ return;
1482
+ }
1483
+ void this.publishTerminalWhenReady(task);
1484
+ }
1485
+
1486
+ private async publishTerminalWhenReady(task: BgTask): Promise<void> {
1487
+ try {
1488
+ await task.terminalPublicationGate;
1489
+ } catch (error) {
1490
+ this.handleTerminalPublishFailure(task, error);
1491
+ return;
1492
+ }
1493
+ this.tryPublishTerminalNow(task);
1494
+ }
1495
+
1496
+ private tryPublishTerminalNow(task: BgTask): void {
1497
+ try {
1498
+ if (task.terminalPublished) return;
1499
+ this.publishTerminalSnapshot(snapshot(task));
1500
+ task.terminalPublished = true;
1501
+ if (task.terminalPublishRetryHandle) {
1502
+ clearTimeout(task.terminalPublishRetryHandle);
1503
+ task.terminalPublishRetryHandle = undefined;
1504
+ }
1505
+ } catch (error) {
1506
+ this.handleTerminalPublishFailure(task, error);
1507
+ return;
1508
+ } finally {
1509
+ task.terminalPublishInFlight = false;
1510
+ }
1511
+ }
1512
+
1513
+ private handleTerminalPublishFailure(task: BgTask, error: unknown): void {
1514
+ this.logger.error(`[background-tasks] terminal publication failed for ${task.id}:`, error);
1515
+ task.terminalPublishInFlight = false;
1516
+ if (!task.terminalPublished && task.terminalPublishRetryHandle === undefined) {
1517
+ task.terminalPublishRetryHandle = setTimeout(() => {
1518
+ task.terminalPublishRetryHandle = undefined;
1519
+ this.publishTerminal(task);
1520
+ }, 100);
1521
+ task.terminalPublishRetryHandle.unref();
1522
+ }
1523
+ }
1524
+
1525
+ private notifyCompletion(task: BgTask): void {
1526
+ if (!task.notifyOnCompletion || task.notified || this.shuttingDown) return;
1527
+ task.notified = true;
1528
+ const exit =
1529
+ task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
1530
+ const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
1531
+ const taskName = taskDisplayName(task);
1532
+ const content = [
1533
+ '<background-task-notification>',
1534
+ ` <task-id>${task.id}</task-id>`,
1535
+ ` <task-name>${escapeXml(taskName)}</task-name>`,
1536
+ ` <status>${task.status}</status>`,
1537
+ exit,
1538
+ error,
1539
+ ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
1540
+ ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
1541
+ '</background-task-notification>',
1542
+ ]
1543
+ .filter(Boolean)
1544
+ .join('\n');
1545
+
1546
+ try {
1547
+ this.sendCompletionNotification(
1548
+ {
1549
+ customType: 'background-task-notification',
1550
+ content,
1551
+ display: true,
1552
+ details: snapshot(task),
1553
+ },
1554
+ { deliverAs: 'followUp', triggerTurn: task.triggerOnCompletion },
1555
+ );
1556
+ } catch (error) {
1557
+ task.notified = false;
1558
+ throw new Error(
1559
+ `Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
1560
+ );
1561
+ }
1562
+ }
1563
+
1564
+ private async finalizeTask(
1565
+ task: BgTask,
1566
+ status: TaskStatus,
1567
+ exitCode: number | null,
1568
+ signal?: string | null,
1569
+ error?: string,
1570
+ ): Promise<void> {
1571
+ if (task.finalized) return;
1572
+ task.finalized = true;
1573
+ if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
1574
+ let finalStatus = status;
1575
+ let finalError = error;
1576
+ task.exitCode = exitCode;
1577
+ task.signal = signal ?? null;
1578
+
1579
+ // Keep status="running" until the final wrapped-agent fragment has been
1580
+ // consumed and the output plus terminal metadata are durable. Publishing a
1581
+ // terminal state earlier lets bg_status observe the previous assistant
1582
+ // turn's context snapshot and recreates the same false-completion race the
1583
+ // attested producer is required to prevent.
1584
+ try {
1585
+ if (task.telemetryWrapped) {
1586
+ // Child-process close can be observed before the wrapper stdout listener has
1587
+ // committed its last parsed telemetry batch. Wait for a short quiet window,
1588
+ // then flush the trailing partial line, so completed status never races
1589
+ // ahead of the final assistant-turn context/token/tool snapshot.
1590
+ await new Promise<void>((resolve) => setTimeout(resolve, 25));
1591
+ this.flushAgentStdout(task);
1592
+ }
1593
+ if (task.stream && !task.stream.destroyed) await closeAndFsyncOutputStream(task.stream);
1594
+ } catch (finalizeError) {
1595
+ finalStatus = 'failed';
1596
+ const message =
1597
+ finalizeError instanceof Error ? finalizeError.message : String(finalizeError);
1598
+ finalError = finalError
1599
+ ? `${finalError}; final output durability failed: ${message}`
1600
+ : `Final output durability failed: ${message}`;
1601
+ }
1602
+
1603
+ task.endTime = this.now();
1604
+ if (finalError) task.error = finalError;
1605
+ try {
1606
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
1607
+ task.status = finalStatus;
1608
+ } catch (metadataError) {
1609
+ finalStatus = 'failed';
1610
+ task.status = 'failed';
1611
+ task.error = `Terminal metadata write failed: ${metadataError instanceof Error ? metadataError.message : String(metadataError)}`;
1612
+ this.logger.error(
1613
+ `[background-tasks] failed to write metadata for ${task.id}:`,
1614
+ metadataError,
1615
+ );
1616
+ await this.writeMetadata(task).catch((retryError: unknown) => {
1617
+ this.logger.error(
1618
+ `[background-tasks] failed to write failed terminal metadata for ${task.id}:`,
1619
+ retryError,
1620
+ );
1621
+ });
1622
+ }
1623
+
1624
+ for (const waiter of task.waiters.splice(0)) waiter();
1625
+ this.onChange();
1626
+ this.publishTerminal(task);
1627
+ try {
1628
+ this.notifyCompletion(task);
1629
+ } catch (notificationError) {
1630
+ this.logger.error(
1631
+ `[background-tasks] notification failed for ${task.id}:`,
1632
+ notificationError,
1633
+ );
1634
+ }
1635
+ try {
1636
+ await this.writeMetadata(task);
1637
+ } catch (metadataError) {
1638
+ this.logger.error(
1639
+ `[background-tasks] failed to update notification metadata for ${task.id}:`,
1640
+ metadataError,
1641
+ );
1642
+ }
1643
+ this.pruneOldTasks();
1644
+ }
1645
+
1646
+ private pruneOldTasks(): void {
1647
+ if (this.tasks.size <= this.maxRecentTasks) return;
1648
+ const removable = [...this.tasks.values()]
1649
+ .filter((task) => task.status !== 'running')
1650
+ .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
1651
+ while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
1652
+ const task = removable.shift();
1653
+ if (task) this.tasks.delete(task.id);
1654
+ }
1655
+ }
958
1656
  }