pi-background-tasks 0.2.0 → 0.3.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.
@@ -0,0 +1,958 @@
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";
8
+ 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);
32
+ export const KILL_GRACE_MS = 3000;
33
+ export const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
34
+ export const MAX_RECENT_TASKS = 100;
35
+ const TELEMETRY_BUFFER_CHARS = 512 * 1024;
36
+
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;
55
+ type KillProcessFn = (pid: number, signal?: NodeJS.Signals | number) => boolean;
56
+
57
+ export type CompletionNotificationMessage = {
58
+ customType: "background-task-notification";
59
+ content: string;
60
+ display: true;
61
+ details: BgTaskSnapshot;
62
+ };
63
+
64
+ export type CompletionNotificationOptions = {
65
+ deliverAs: "followUp";
66
+ triggerTurn: boolean;
67
+ };
68
+
69
+ export type CompletionNotificationSender = (
70
+ message: CompletionNotificationMessage,
71
+ options: CompletionNotificationOptions,
72
+ ) => void;
73
+
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
+ };
99
+
100
+ 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
+ };
138
+ }
139
+
140
+ export function createPiTelemetryWrapperSource(index: ModelWindowIndex): string {
141
+ return `#!/usr/bin/env node
142
+ const { spawn } = require("node:child_process");
143
+ const index = ${JSON.stringify(index)};
144
+
145
+ const tokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
146
+ let costTotal = 0;
147
+ let hasCostTotal = false;
148
+ let agentModel;
149
+ const toolUsage = { total: 0, failed: 0, byName: {} };
150
+ const seenToolCallIds = new Set();
151
+ const failedToolCallIds = new Set();
152
+
153
+ function nonNegativeInteger(value) {
154
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
155
+ }
156
+
157
+ function normalizeUsage(usage) {
158
+ if (!usage) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
159
+ const input = nonNegativeInteger(usage.input);
160
+ const output = nonNegativeInteger(usage.output);
161
+ const cacheRead = nonNegativeInteger(usage.cacheRead);
162
+ const cacheWrite = nonNegativeInteger(usage.cacheWrite);
163
+ const explicitTotal = nonNegativeInteger(usage.totalTokens);
164
+ const totalTokens = explicitTotal || (input + output + cacheRead + cacheWrite);
165
+ const cost = usage.cost && typeof usage.cost.total === "number" && Number.isFinite(usage.cost.total) && usage.cost.total >= 0
166
+ ? usage.cost.total
167
+ : undefined;
168
+ return { input, output, cacheRead, cacheWrite, totalTokens, cost };
169
+ }
170
+
171
+ function addTokenUsage(usage) {
172
+ const normalized = normalizeUsage(usage);
173
+ if (!normalized.totalTokens) return normalized;
174
+ tokenUsage.input += normalized.input;
175
+ tokenUsage.output += normalized.output;
176
+ tokenUsage.cacheRead += normalized.cacheRead;
177
+ tokenUsage.cacheWrite += normalized.cacheWrite;
178
+ tokenUsage.totalTokens += normalized.totalTokens;
179
+ if (normalized.cost !== undefined) {
180
+ costTotal += normalized.cost;
181
+ hasCostTotal = true;
182
+ }
183
+ return normalized;
184
+ }
185
+
186
+ function currentTokenUsage() {
187
+ if (!tokenUsage.totalTokens) return undefined;
188
+ const out = { ...tokenUsage };
189
+ if (hasCostTotal) out.costTotal = costTotal;
190
+ return out;
191
+ }
192
+
193
+ function markToolStarted(id, name) {
194
+ const key = id ? String(id) : undefined;
195
+ if (key && seenToolCallIds.has(key)) return;
196
+ if (key) seenToolCallIds.add(key);
197
+ const toolName = name ? String(name) : "unknown";
198
+ toolUsage.total += 1;
199
+ toolUsage.byName[toolName] = (toolUsage.byName[toolName] || 0) + 1;
200
+ }
201
+
202
+ function markToolFailed(id) {
203
+ const key = id ? String(id) : undefined;
204
+ if (key && failedToolCallIds.has(key)) return;
205
+ if (key) failedToolCallIds.add(key);
206
+ toolUsage.failed += 1;
207
+ }
208
+
209
+ function currentToolUsage() {
210
+ if (!toolUsage.total && !toolUsage.failed) return undefined;
211
+ return { total: toolUsage.total, failed: toolUsage.failed, byName: { ...toolUsage.byName } };
212
+ }
213
+
214
+ function emitUnifiedTelemetry(payload) {
215
+ const out = { type: "background-task-telemetry", ...payload };
216
+ const tokens = currentTokenUsage();
217
+ const tools = currentToolUsage();
218
+ if (tokens && !out.tokenUsage) out.tokenUsage = tokens;
219
+ if (tools && !out.toolUsage) out.toolUsage = tools;
220
+ if (agentModel && !out.model) out.model = agentModel;
221
+ process.stdout.write(JSON.stringify(out) + "\\n");
222
+ }
223
+
224
+ function resolveModelName(fromMessage, fromArgs, providerFromArgs) {
225
+ const message = fromMessage ? String(fromMessage) : "";
226
+ const args = fromArgs ? String(fromArgs) : "";
227
+ const bareOf = (value) => value.includes("/") ? value.split("/").pop() : value;
228
+ if (message && message.includes("/")) return message;
229
+ if (args && args.includes("/") && (!message || bareOf(args) === message)) return args;
230
+ const primary = message || args;
231
+ if (!primary) return undefined;
232
+ if (primary.includes("/")) return primary;
233
+ if (providerFromArgs) return providerFromArgs + "/" + primary;
234
+ if (index.defaultProvider) return index.defaultProvider + "/" + primary;
235
+ return primary;
236
+ }
237
+
238
+ function parseInvocation(argv) {
239
+ const out = [];
240
+ let model;
241
+ let provider;
242
+ let hasMode = false;
243
+ let modeValue;
244
+ for (let i = 0; i < argv.length; i++) {
245
+ const arg = argv[i];
246
+ if (arg === "-p" || arg === "--print") continue;
247
+ if (arg === "--mode") {
248
+ hasMode = true;
249
+ modeValue = argv[i + 1];
250
+ out.push(arg);
251
+ if (i + 1 < argv.length) out.push(argv[++i]);
252
+ continue;
253
+ }
254
+ if (arg.startsWith("--mode=")) {
255
+ hasMode = true;
256
+ modeValue = arg.slice("--mode=".length);
257
+ out.push(arg);
258
+ continue;
259
+ }
260
+ if (arg === "--model" && i + 1 < argv.length) {
261
+ model = argv[i + 1];
262
+ out.push(arg, argv[++i]);
263
+ continue;
264
+ }
265
+ if (arg.startsWith("--model=")) model = arg.slice("--model=".length);
266
+ if (arg === "--provider" && i + 1 < argv.length) {
267
+ provider = argv[i + 1];
268
+ out.push(arg, argv[++i]);
269
+ continue;
270
+ }
271
+ if (arg.startsWith("--provider=")) provider = arg.slice("--provider=".length);
272
+ out.push(arg);
273
+ }
274
+ if (hasMode && modeValue !== "json") return { args: argv, parseJson: false, model, provider };
275
+ if (!hasMode) out.unshift("--mode", "json");
276
+ return { args: out, parseJson: true, model, provider };
277
+ }
278
+
279
+ function resolveWindow(modelFromArgs, providerFromArgs, modelFromMessage) {
280
+ const candidates = [];
281
+ if (modelFromMessage) candidates.push(modelFromMessage);
282
+ if (modelFromArgs) candidates.push(modelFromArgs);
283
+ if (modelFromArgs && providerFromArgs && !modelFromArgs.includes("/")) candidates.push(providerFromArgs + "/" + modelFromArgs);
284
+ if (modelFromArgs && index.defaultProvider && !modelFromArgs.includes("/")) candidates.push(index.defaultProvider + "/" + modelFromArgs);
285
+ if (index.defaultModel && index.defaultProvider) candidates.push(index.defaultProvider + "/" + index.defaultModel);
286
+ for (const candidate of candidates) {
287
+ if (!candidate) continue;
288
+ if (index.byQualifiedId[candidate]) return index.byQualifiedId[candidate];
289
+ const bare = String(candidate).includes("/") ? String(candidate).split("/").pop() : String(candidate);
290
+ if (bare && index.byId[bare]) return index.byId[bare];
291
+ }
292
+ return index.defaultContextWindow || 0;
293
+ }
294
+
295
+ function countToolCallsFromMessage(message) {
296
+ const content = message && Array.isArray(message.content) ? message.content : [];
297
+ for (const part of content) {
298
+ if (part && part.type === "toolCall") markToolStarted(part.id, part.name);
299
+ }
300
+ }
301
+
302
+ function emitMessageTelemetry(message, modelFromArgs, providerFromArgs) {
303
+ const usage = addTokenUsage(message && message.usage);
304
+ const resolvedModel = resolveModelName(message && message.model, modelFromArgs, providerFromArgs);
305
+ if (resolvedModel) agentModel = resolvedModel;
306
+ const contextWindow = resolveWindow(modelFromArgs, providerFromArgs, message && message.model);
307
+ const contextUsage = usage.totalTokens && contextWindow
308
+ ? { tokens: usage.totalTokens, contextWindow, percent: (usage.totalTokens / contextWindow) * 100 }
309
+ : undefined;
310
+ if (contextUsage) process.stdout.write(JSON.stringify({ type: "background-task-context-usage", ...contextUsage }) + "\\n");
311
+ const payload = {};
312
+ if (contextUsage) payload.contextUsage = contextUsage;
313
+ emitUnifiedTelemetry(payload);
314
+ }
315
+
316
+ function emitToolTelemetry() {
317
+ emitUnifiedTelemetry({});
318
+ }
319
+
320
+ const parsed = parseInvocation(process.argv.slice(2));
321
+ const child = spawn("pi", parsed.args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
322
+ let buffer = "";
323
+ let finalText = "";
324
+
325
+ if (!parsed.parseJson) {
326
+ child.stdout.pipe(process.stdout);
327
+ } else {
328
+ child.stdout.on("data", (chunk) => {
329
+ buffer += chunk.toString();
330
+ const lines = buffer.split("\\n");
331
+ buffer = lines.pop() || "";
332
+ for (const line of lines) processLine(line);
333
+ });
334
+ }
335
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
336
+ child.on("error", (error) => {
337
+ process.stderr.write("[pi-bg telemetry wrapper error: " + error.message + "]\\n");
338
+ });
339
+ child.on("close", (code, signal) => {
340
+ 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);
344
+ });
345
+
346
+ function processLine(line) {
347
+ if (!line.trim()) return;
348
+ let event;
349
+ try {
350
+ event = JSON.parse(line);
351
+ } catch {
352
+ process.stdout.write(line + "\\n");
353
+ return;
354
+ }
355
+ if (event.type === "tool_execution_start") {
356
+ markToolStarted(event.toolCallId || event.tool_call_id, event.toolName || event.tool_name);
357
+ emitToolTelemetry();
358
+ return;
359
+ }
360
+ if (event.type === "tool_execution_end") {
361
+ if (event.isError) markToolFailed(event.toolCallId || event.tool_call_id);
362
+ emitToolTelemetry();
363
+ return;
364
+ }
365
+ if (event.type === "message_end" && event.message && event.message.role === "assistant") {
366
+ countToolCallsFromMessage(event.message);
367
+ 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
+ }
373
+ }
374
+ `;
375
+ }
376
+
377
+ 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 };
400
+ }
401
+
402
+ 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 });
415
+ }
416
+
417
+ function nonNegativeInteger(value: unknown): number {
418
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
419
+ }
420
+
421
+ 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;
426
+ }
427
+
428
+ 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;
442
+ }
443
+
444
+ 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;
459
+ }
460
+
461
+ 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
+ }
958
+ }