pi-background-tasks 0.6.0 → 0.7.2

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,146 +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
- formatAgentActivityLine,
13
- formatDuration,
14
- normalizeTaskName,
15
- parseAgentActivity,
16
- sanitizePathSegment,
17
- shellInvocation,
18
- shellQuote,
19
- snapshot,
20
- stripMatchingQuotes,
21
- taskDisplayName,
22
- type BgLogsDetails,
23
- type BgTask,
24
- type BgTaskSnapshot,
25
- type KillKind,
26
- type StartTaskOptions,
27
- type TaskContextUsage,
28
- type TaskStatus,
29
- type TaskTokenUsage,
30
- type TaskToolUsage,
31
- } from "./common.js";
32
-
33
- 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);
34
55
  export const KILL_GRACE_MS = 3000;
35
56
  export const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
36
57
  export const MAX_RECENT_TASKS = 100;
37
58
  const TELEMETRY_BUFFER_CHARS = 512 * 1024;
38
59
 
39
- export type BackgroundTaskContext = {
40
- cwd: string;
41
- sessionId?: string;
42
- modelRegistry: Pick<ExtensionContext["modelRegistry"], "getAll">;
43
- model?: ExtensionContext["model"] | undefined;
44
- };
45
-
46
- type OutputEventSource = { on(event: "data", listener: (data: Buffer | string) => void): unknown };
47
-
48
- export type BackgroundTaskChildProcess = {
49
- pid?: number | undefined;
50
- stdout?: OutputEventSource | null | undefined;
51
- stderr?: OutputEventSource | null | undefined;
52
- kill(signal?: NodeJS.Signals | string | number): boolean;
53
- on(event: "error", listener: (error: Error) => void): unknown;
54
- on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
55
- };
56
- 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
+
57
95
  type KillProcessFn = (pid: number, signal?: NodeJS.Signals | number) => boolean;
58
96
 
59
- export type CompletionNotificationMessage = {
60
- customType: "background-task-notification";
61
- content: string;
62
- display: true;
63
- details: BgTaskSnapshot;
64
- };
97
+ export interface CompletionNotificationMessage {
98
+ customType: 'background-task-notification';
99
+ content: string;
100
+ display: true;
101
+ details: BgTaskSnapshot;
102
+ }
65
103
 
66
- export type CompletionNotificationOptions = {
67
- deliverAs: "followUp";
68
- triggerTurn: boolean;
69
- };
104
+ export interface CompletionNotificationOptions {
105
+ deliverAs: 'followUp';
106
+ triggerTurn: boolean;
107
+ }
70
108
 
71
109
  export type CompletionNotificationSender = (
72
- message: CompletionNotificationMessage,
73
- options: CompletionNotificationOptions,
110
+ message: CompletionNotificationMessage,
111
+ options: CompletionNotificationOptions,
74
112
  ) => void;
75
113
 
76
- export type BackgroundTaskRegistryOptions = {
77
- onChange?: () => void;
78
- sendCompletionNotification: CompletionNotificationSender;
79
- spawn?: BackgroundTaskSpawn;
80
- killProcess?: KillProcessFn;
81
- platform?: NodeJS.Platform;
82
- env?: NodeJS.ProcessEnv;
83
- makeTaskId?: () => string;
84
- now?: () => number;
85
- maxOutputBytes?: number;
86
- maxRecentTasks?: number;
87
- killGraceMs?: number;
88
- stopWaitMs?: number;
89
- logger?: Pick<Console, "error">;
90
- };
91
-
92
- type RuntimeDir = { abs: string; display: string };
93
-
94
- type ModelWindowIndex = {
95
- byQualifiedId: Record<string, number>;
96
- byId: Record<string, number>;
97
- defaultModel?: string | undefined;
98
- defaultProvider?: string | undefined;
99
- defaultContextWindow?: number | undefined;
100
- };
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
+ }
101
143
 
102
144
  function defaultTaskId(): string {
103
- return `b${randomBytes(4).toString("hex")}`;
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) ?? '') : '';
104
151
  }
105
152
 
106
- export function commandMayLaunchPiAgent(command: string, env: NodeJS.ProcessEnv = process.env): boolean {
107
- if (env["PI_BG_DISABLE_PI_TELEMETRY"] === "1") return false;
108
- return /(^|[\s;&|()])pi(?=\s)(?=[^\n;&|]*(?:\s-p(?:\s|$)|\s--print(?:\s|$)|\s--mode(?:=|\s+)json\b))/m.test(command);
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
+ );
109
161
  }
110
162
 
111
- export function buildModelWindowIndex(ctx: Pick<BackgroundTaskContext, "modelRegistry" | "model">): ModelWindowIndex {
112
- const byQualifiedId: Record<string, number> = {};
113
- const candidatesById = new Map<string, Set<number>>();
114
- for (const model of ctx.modelRegistry.getAll()) {
115
- const contextWindow = typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0
116
- ? Math.floor(model.contextWindow)
117
- : undefined;
118
- if (!contextWindow) continue;
119
- byQualifiedId[`${model.provider}/${model.id}`] = contextWindow;
120
- let candidates = candidatesById.get(model.id);
121
- if (!candidates) {
122
- candidates = new Set<number>();
123
- candidatesById.set(model.id, candidates);
124
- }
125
- candidates.add(contextWindow);
126
- }
127
- const byId: Record<string, number> = {};
128
- for (const [id, windows] of candidatesById) {
129
- const onlyWindow = windows.values().next();
130
- if (windows.size === 1 && !onlyWindow.done) byId[id] = onlyWindow.value;
131
- }
132
- const current = ctx.model;
133
- return {
134
- byQualifiedId,
135
- byId,
136
- defaultModel: current?.id,
137
- defaultProvider: current?.provider,
138
- defaultContextWindow: current?.contextWindow,
139
- };
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
+ };
140
197
  }
141
198
 
142
199
  export function createPiTelemetryWrapperSource(index: ModelWindowIndex): string {
143
- return `#!/usr/bin/env node
200
+ return `#!/usr/bin/env node
144
201
  const { spawn } = require("node:child_process");
145
202
  const index = ${JSON.stringify(index)};
146
203
 
@@ -369,8 +426,14 @@ child.on("error", (error) => {
369
426
  });
370
427
  child.on("close", (code, signal) => {
371
428
  if (parsed.parseJson && buffer.trim()) processLine(buffer);
372
- if (signal) process.kill(process.pid, signal);
373
- 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
+ });
374
437
  });
375
438
 
376
439
  function processLine(line) {
@@ -405,687 +468,1190 @@ function processLine(line) {
405
468
  `;
406
469
  }
407
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
+
408
492
  function normalizeContextUsage(value: unknown): TaskContextUsage | undefined {
409
- if (!value || typeof value !== "object") return undefined;
410
- const input = value as Record<string, unknown>;
411
- const rawContextWindow = input["contextWindow"];
412
- const contextWindow = typeof rawContextWindow === "number" && Number.isFinite(rawContextWindow) && rawContextWindow > 0
413
- ? Math.floor(rawContextWindow)
414
- : undefined;
415
- if (!contextWindow) return undefined;
416
- const rawTokens = input["tokens"];
417
- const tokens = rawTokens === null
418
- ? null
419
- : typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens >= 0
420
- ? Math.floor(rawTokens)
421
- : null;
422
- const rawPercent = input["percent"];
423
- const percent = rawPercent === null
424
- ? null
425
- : typeof rawPercent === "number" && Number.isFinite(rawPercent) && rawPercent >= 0
426
- ? rawPercent
427
- : tokens === null
428
- ? null
429
- : (tokens / contextWindow) * 100;
430
- 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 };
431
520
  }
432
521
 
433
522
  function parseContextUsageXml(xml: string): TaskContextUsage | undefined {
434
- const readNumber = (tag: string): number | null | undefined => {
435
- const match = xml.match(new RegExp(`<${tag}>(.*?)</${tag}>`, "i"));
436
- if (!match) return undefined;
437
- const raw = match[1]?.trim();
438
- if (raw === "null" || raw === "?") return null;
439
- const parsed = Number(raw);
440
- return Number.isFinite(parsed) ? parsed : undefined;
441
- };
442
- const tokens = readNumber("tokens");
443
- const contextWindow = readNumber("context-window") ?? readNumber("contextWindow");
444
- const percent = readNumber("percent");
445
- 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 });
446
535
  }
447
536
 
448
537
  function nonNegativeInteger(value: unknown): number {
449
- 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;
450
539
  }
451
540
 
452
541
  function normalizeModel(value: unknown): string | undefined {
453
- if (typeof value !== "string") return undefined;
454
- const trimmed = value.trim();
455
- if (!trimmed) return undefined;
456
- 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;
457
546
  }
458
547
 
459
548
  function normalizeTokenUsage(value: unknown): TaskTokenUsage | undefined {
460
- if (!value || typeof value !== "object") return undefined;
461
- const input = value as Record<string, unknown>;
462
- const usage: TaskTokenUsage = {
463
- input: nonNegativeInteger(input["input"]),
464
- output: nonNegativeInteger(input["output"]),
465
- cacheRead: nonNegativeInteger(input["cacheRead"]),
466
- cacheWrite: nonNegativeInteger(input["cacheWrite"]),
467
- totalTokens: nonNegativeInteger(input["totalTokens"]),
468
- };
469
- if (!usage.totalTokens) usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
470
- const rawCostTotal = input["costTotal"];
471
- if (typeof rawCostTotal === "number" && Number.isFinite(rawCostTotal) && rawCostTotal >= 0) usage.costTotal = rawCostTotal;
472
- 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;
473
564
  }
474
565
 
475
566
  function normalizeToolUsage(value: unknown): TaskToolUsage | undefined {
476
- if (!value || typeof value !== "object") return undefined;
477
- const input = value as Record<string, unknown>;
478
- const byName: Record<string, number> = {};
479
- const rawByName = input["byName"];
480
- if (rawByName && typeof rawByName === "object") {
481
- for (const [name, count] of Object.entries(rawByName)) {
482
- const normalized = nonNegativeInteger(count);
483
- if (normalized > 0) byName[name] = normalized;
484
- }
485
- }
486
- const byNameTotal = Object.values(byName).reduce((sum, count) => sum + count, 0);
487
- const failed = nonNegativeInteger(input["failed"]);
488
- const total = Math.max(nonNegativeInteger(input["total"]), byNameTotal, failed);
489
- 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;
490
581
  }
491
582
 
492
- type TelemetryDelta = {
493
- context?: TaskContextUsage | undefined;
494
- tokens?: TaskTokenUsage | undefined;
495
- tools?: TaskToolUsage | undefined;
496
- model?: string | undefined;
497
- };
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;
600
+ }
498
601
 
499
602
  export class BackgroundTaskRegistry {
500
- private readonly tasks = new Map<string, BgTask>();
501
- private runtimeDir: RuntimeDir | undefined;
502
- private shuttingDown = false;
503
- private readonly spawn: BackgroundTaskSpawn;
504
- private readonly killProcess: KillProcessFn;
505
- private readonly platform: NodeJS.Platform;
506
- private readonly env: NodeJS.ProcessEnv;
507
- private readonly makeTaskIdFn: () => string;
508
- private readonly now: () => number;
509
- private readonly maxOutputBytes: number;
510
- private readonly maxRecentTasks: number;
511
- private readonly killGraceMs: number;
512
- private readonly stopWaitMs: number;
513
- private readonly logger: Pick<Console, "error">;
514
- private readonly onChange: () => void;
515
- private readonly sendCompletionNotification: CompletionNotificationSender;
516
-
517
- constructor(options: BackgroundTaskRegistryOptions) {
518
- this.spawn = options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions));
519
- this.killProcess = options.killProcess ?? process.kill.bind(process);
520
- this.platform = options.platform ?? process.platform;
521
- this.env = options.env ?? process.env;
522
- this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
523
- this.now = options.now ?? Date.now;
524
- this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
525
- this.maxRecentTasks = options.maxRecentTasks ?? MAX_RECENT_TASKS;
526
- this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
527
- this.stopWaitMs = options.stopWaitMs ?? STOP_WAIT_MS;
528
- this.logger = options.logger ?? console;
529
- this.onChange = options.onChange ?? (() => {});
530
- this.sendCompletionNotification = options.sendCompletionNotification;
531
- }
532
-
533
- isShuttingDown(): boolean {
534
- return this.shuttingDown;
535
- }
536
-
537
- setShuttingDown(value: boolean): void {
538
- this.shuttingDown = value;
539
- }
540
-
541
- allTasks(): BgTask[] {
542
- return [...this.tasks.values()];
543
- }
544
-
545
- snapshot(task: BgTask): BgTaskSnapshot {
546
- return snapshot(task);
547
- }
548
-
549
- async ensureRuntimeDir(ctx: BackgroundTaskContext): Promise<RuntimeDir> {
550
- if (this.runtimeDir) return this.runtimeDir;
551
- const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${process.pid}`);
552
- const runId = `${sessionId}-${process.pid}`;
553
- const runtimeDirAbs = join(ctx.cwd, ".pi", "tasks", runId);
554
- const runtimeDirDisplay = join(".pi", "tasks", runId);
555
- await mkdir(runtimeDirAbs, { recursive: true });
556
- this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
557
- return this.runtimeDir;
558
- }
559
-
560
- async startTask(ctx: BackgroundTaskContext, command: string, options: StartTaskOptions = {}): Promise<BgTask> {
561
- const normalizedCommand = stripMatchingQuotes(command);
562
- if (!normalizedCommand) throw new Error("Background command is empty");
563
- if (this.shuttingDown) throw new Error("Cannot start a background task while Pi is shutting down");
564
-
565
- const dir = await this.ensureRuntimeDir(ctx);
566
- const id = this.makeTaskIdFn();
567
- const outputAbsPath = join(dir.abs, `${id}.output`);
568
- const metadataAbsPath = join(dir.abs, `${id}.json`);
569
- const outputPath = join(dir.display, `${id}.output`);
570
- const timeoutSeconds =
571
- typeof options.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds) && options.timeoutSeconds > 0
572
- ? Math.floor(options.timeoutSeconds)
573
- : undefined;
574
- const taskName = normalizeTaskName(options.name) ?? normalizeTaskName(options.description) ?? deriveTaskNameFromCommand(normalizedCommand);
575
- const isAgent = options.isAgent ?? false;
576
-
577
- const task: BgTask = {
578
- id,
579
- name: taskName,
580
- command: normalizedCommand,
581
- description: options.description?.trim() || undefined,
582
- status: "running",
583
- outputPath,
584
- outputAbsPath,
585
- metadataAbsPath,
586
- cwd: ctx.cwd,
587
- startTime: this.now(),
588
- exitCode: undefined,
589
- pid: undefined,
590
- bytesWritten: 0,
591
- isAgent,
592
- notified: false,
593
- notifyOnCompletion: options.notifyOnCompletion ?? true,
594
- triggerOnCompletion: options.triggerOnCompletion ?? false,
595
- timeoutSeconds,
596
- waiters: [],
597
- };
598
- this.tasks.set(id, task);
599
-
600
- const stream = createWriteStream(outputAbsPath, { flags: "a", encoding: "utf8" });
601
- task.stream = stream;
602
- stream.on("error", (error) => {
603
- task.error = `Output file write failed: ${error.message}`;
604
- if (task.status === "running") {
605
- task.killKind = "output_cap";
606
- try {
607
- this.requestKill(task, "SIGTERM");
608
- } catch (killError) {
609
- void this.finalizeTask(
610
- task,
611
- "failed",
612
- null,
613
- undefined,
614
- `${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
615
- );
616
- }
617
- }
618
- });
619
-
620
- try {
621
- let commandToSpawn = normalizedCommand;
622
- if (isAgent && commandMayLaunchPiAgent(normalizedCommand, this.env)) {
623
- const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
624
- await writeFile(wrapperAbsPath, createPiTelemetryWrapperSource(buildModelWindowIndex(ctx)), "utf8");
625
- commandToSpawn = `pi() { node ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
626
- task.telemetryWrapped = true;
627
- }
628
- const invocation = shellInvocation(commandToSpawn, this.platform, this.env);
629
- const child = this.spawn(invocation.shell, invocation.args, {
630
- cwd: ctx.cwd,
631
- detached: this.platform !== "win32",
632
- stdio: ["ignore", "pipe", "pipe"],
633
- env: this.env,
634
- windowsHide: true,
635
- });
636
-
637
- task.child = child;
638
- task.pid = child.pid;
639
-
640
- child.stdout?.on("data", (data) => this.appendChildOutput(task, data, "stdout"));
641
- child.stderr?.on("data", (data) => this.appendChildOutput(task, data, "stderr"));
642
-
643
- child.on("error", (error) => {
644
- this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
645
- void this.finalizeTask(task, "failed", null, undefined, error.message);
646
- });
647
-
648
- child.on("close", (code, signalName) => {
649
- let status: TaskStatus;
650
- let error: string | undefined;
651
- if (task.killKind === "user" || task.killKind === "shutdown") {
652
- status = "killed";
653
- } else if (task.killKind === "timeout") {
654
- status = "failed";
655
- error = task.error || `Timed out after ${task.timeoutSeconds}s`;
656
- } else if (task.killKind === "output_cap") {
657
- status = "failed";
658
- error = task.error || `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
659
- } else if ((code ?? 0) === 0) {
660
- status = "completed";
661
- } else {
662
- status = "failed";
663
- error = `Exited with code ${code ?? "null"}${signalName ? ` (${signalName})` : ""}`;
664
- }
665
- void this.finalizeTask(task, status, code, signalName, error);
666
- });
667
-
668
- if (timeoutSeconds) {
669
- task.timeoutHandle = setTimeout(() => {
670
- if (task.status !== "running") return;
671
- task.killKind = "timeout";
672
- task.error = `Timed out after ${timeoutSeconds}s`;
673
- this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
674
- try {
675
- this.requestKill(task, "SIGTERM");
676
- } catch (error) {
677
- void this.finalizeTask(task, "failed", null, undefined, `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`);
678
- }
679
- }, timeoutSeconds * 1000);
680
- }
681
-
682
- await this.writeMetadata(task);
683
- this.onChange();
684
- return task;
685
- } catch (error) {
686
- const message = error instanceof Error ? error.message : String(error);
687
- this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
688
- await this.finalizeTask(task, "failed", null, undefined, message);
689
- throw new Error(`Failed to start background task: ${message}`);
690
- }
691
- }
692
-
693
- resolveTask(idOrPrefix: string): BgTask {
694
- const id = idOrPrefix.trim();
695
- if (!id) throw new Error("Task ID is required");
696
- const exact = this.tasks.get(id);
697
- if (exact) return exact;
698
- const matches = [...this.tasks.values()].filter((task) => task.id.startsWith(id));
699
- const onlyMatch = matches[0];
700
- if (matches.length === 1 && onlyMatch) return onlyMatch;
701
- if (matches.length > 1) throw new Error(`Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(", ")}`);
702
- throw new Error(`Unknown background task ID: ${id}`);
703
- }
704
-
705
- async stopTask(task: BgTask, kind: KillKind, reason?: string): Promise<BgTask> {
706
- if (task.status !== "running") {
707
- throw new Error(`Task ${task.id} is ${task.status}, not running`);
708
- }
709
- task.killKind = kind;
710
- if (reason) task.error = reason;
711
- this.requestKill(task, "SIGTERM");
712
- const stopped = await this.waitForEnd(task, this.stopWaitMs);
713
- if (!stopped) {
714
- throw new Error(`Task ${task.id} did not exit within ${formatDuration(this.stopWaitMs)} after SIGTERM/SIGKILL`);
715
- }
716
- return task;
717
- }
718
-
719
- async stopAllRunning(kind: KillKind, reason?: string): Promise<{ stopped: number; failures: string[] }> {
720
- const running = this.allTasks().filter((task) => task.status === "running");
721
- const failures: string[] = [];
722
- let stopped = 0;
723
- await Promise.all(
724
- running.map(async (task) => {
725
- try {
726
- await this.stopTask(task, kind, reason);
727
- stopped++;
728
- } catch (error) {
729
- failures.push(`${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`);
730
- }
731
- }),
732
- );
733
- return { stopped, failures };
734
- }
735
-
736
- async getTaskLogs(task: BgTask, maxBytes: number, tail: boolean): Promise<{ text: string; details: BgLogsDetails }> {
737
- if (!existsSync(task.outputAbsPath)) {
738
- throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
739
- }
740
- const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
741
- const direction = tail ? "tail" : "head";
742
- let text = read.content || "(no output yet)";
743
- if (read.truncated) {
744
- const omitted = read.totalBytes - read.bytesRead;
745
- const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
746
- text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
747
- } else {
748
- text += `\n\n[Full output: ${task.outputPath}]`;
749
- }
750
- return {
751
- text,
752
- details: {
753
- task: snapshot(task),
754
- path: task.outputPath,
755
- bytesRead: read.bytesRead,
756
- truncated: read.truncated,
757
- tail,
758
- },
759
- };
760
- }
761
-
762
- private async writeMetadata(task: BgTask): Promise<void> {
763
- await writeFile(task.metadataAbsPath, `${JSON.stringify(snapshot(task), null, 2)}\n`, "utf8");
764
- }
765
-
766
- private ingestTelemetry(task: BgTask, text: string): void {
767
- if (!text) return;
768
- const telemetryText = `${task.contextUsageBuffer ?? ""}${text}`;
769
- let latestContext = task.contextUsage;
770
- let latestTokens = task.tokenUsage;
771
- let latestTools = task.toolUsage;
772
- let latestModel = task.model;
773
- for (const line of telemetryText.split(/\r?\n/)) {
774
- if (!line.includes("background-task-")) continue;
775
- const trimmed = line.trim();
776
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
777
- try {
778
- const parsed = JSON.parse(trimmed);
779
- if (parsed?.type === "background-task-context-usage") {
780
- latestContext = normalizeContextUsage(parsed) ?? latestContext;
781
- } else if (parsed?.type === "background-task-telemetry") {
782
- latestContext = normalizeContextUsage(parsed.contextUsage) ?? latestContext;
783
- latestTokens = normalizeTokenUsage(parsed.tokenUsage) ?? latestTokens;
784
- latestTools = normalizeToolUsage(parsed.toolUsage) ?? latestTools;
785
- latestModel = normalizeModel(parsed.model) ?? latestModel;
786
- }
787
- } catch {
788
- // Ignore malformed optional telemetry; task output remains authoritative for debugging.
789
- }
790
- }
791
- }
792
- const xmlMatches = telemetryText.matchAll(/<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi);
793
- for (const match of xmlMatches) latestContext = parseContextUsageXml(match[0]) ?? latestContext;
794
-
795
- const lastNewline = Math.max(telemetryText.lastIndexOf("\n"), telemetryText.lastIndexOf("\r"));
796
- let retained = lastNewline >= 0 ? telemetryText.slice(lastNewline + 1) : telemetryText;
797
- const lastXmlOpen = telemetryText.toLowerCase().lastIndexOf("<background-task-context-usage");
798
- const lastXmlClose = telemetryText.toLowerCase().lastIndexOf("</background-task-context-usage>");
799
- if (lastXmlOpen > lastXmlClose) retained = telemetryText.slice(lastXmlOpen);
800
- task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
801
-
802
- this.commitTelemetry(task, { context: latestContext, tokens: latestTokens, tools: latestTools, model: latestModel });
803
- }
804
-
805
- /** Apply the latest parsed telemetry to a task, persisting metadata and notifying the UI only on change. */
806
- private commitTelemetry(task: BgTask, next: TelemetryDelta): void {
807
- const before = JSON.stringify({ contextUsage: task.contextUsage, tokenUsage: task.tokenUsage, toolUsage: task.toolUsage, model: task.model });
808
- if (next.context !== undefined) task.contextUsage = next.context;
809
- if (next.tokens !== undefined) task.tokenUsage = next.tokens;
810
- if (next.tools !== undefined) task.toolUsage = next.tools;
811
- if (next.model !== undefined) task.model = next.model;
812
- const after = JSON.stringify({ contextUsage: task.contextUsage, tokenUsage: task.tokenUsage, toolUsage: task.toolUsage, model: task.model });
813
- if (before !== after) {
814
- this.onChange();
815
- void this.writeMetadata(task).catch((error) => {
816
- this.logger.error(`[background-tasks] failed to write telemetry metadata for ${task.id}:`, error);
817
- });
818
- }
819
- }
820
-
821
- /** Cap-enforcing sink for all persisted task output; terminates the task once the byte cap is exceeded. */
822
- private writeToStream(task: BgTask, buffer: Buffer): void {
823
- if (!task.stream || task.stream.destroyed) return;
824
- if (buffer.length === 0) return;
825
-
826
- const nextBytes = task.bytesWritten + buffer.length;
827
- if (nextBytes <= this.maxOutputBytes) {
828
- task.stream.write(buffer);
829
- task.bytesWritten = nextBytes;
830
- return;
831
- }
832
-
833
- const remaining = Math.max(0, this.maxOutputBytes - task.bytesWritten);
834
- if (remaining > 0) {
835
- task.stream.write(buffer.subarray(0, remaining));
836
- task.bytesWritten += remaining;
837
- }
838
-
839
- if (!task.capExceeded) {
840
- task.capExceeded = true;
841
- task.error = `Output exceeded cap of ${formatSize(this.maxOutputBytes)}; terminating task`;
842
- const notice = `\n\n[background task error: ${task.error}]\n`;
843
- task.stream.write(notice);
844
- task.bytesWritten += Buffer.byteLength(notice, "utf8");
845
- task.killKind = "output_cap";
846
- try {
847
- this.requestKill(task, "SIGTERM");
848
- } catch (error) {
849
- task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
850
- void this.finalizeTask(task, "failed", null, undefined, task.error);
851
- }
852
- }
853
- }
854
-
855
- /** Persist an internally generated notice (spawn/timeout/cap diagnostics) verbatim. */
856
- private writeNotice(task: BgTask, text: string): void {
857
- if (!text) return;
858
- this.writeToStream(task, Buffer.from(text, "utf8"));
859
- }
860
-
861
- private appendChildOutput(task: BgTask, data: Buffer | string, source: "stdout" | "stderr"): void {
862
- if (!task.stream || task.stream.destroyed) return;
863
- const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
864
- if (buffer.length === 0) return;
865
- if (task.telemetryWrapped) {
866
- // Wrapped Pi agents stream control lines on stdout (telemetry + activity); child
867
- // stderr is raw diagnostics and is always passed through to the transcript verbatim.
868
- if (source === "stdout") this.processAgentStdout(task, buffer.toString("utf8"));
869
- else this.writeToStream(task, buffer);
870
- return;
871
- }
872
- this.ingestTelemetry(task, buffer.toString("utf8"));
873
- this.writeToStream(task, buffer);
874
- }
875
-
876
- /** Reconstruct wrapped-agent stdout into whole control lines, routing telemetry to metrics and activity to the transcript. */
877
- private processAgentStdout(task: BgTask, text: string): void {
878
- const buffered = `${task.agentStdoutBuffer ?? ""}${text}`;
879
- const lastNewline = buffered.lastIndexOf("\n");
880
- task.agentStdoutBuffer = lastNewline >= 0 ? buffered.slice(lastNewline + 1) : buffered;
881
- if (lastNewline < 0) return;
882
- const latest: TelemetryDelta = {};
883
- for (const line of buffered.slice(0, lastNewline).split("\n")) this.consumeAgentLine(task, line, latest);
884
- this.commitTelemetry(task, latest);
885
- }
886
-
887
- /** Flush a trailing partial wrapped-agent line on finalize so the last transcript fragment is never lost. */
888
- private flushAgentStdout(task: BgTask): void {
889
- const remainder = task.agentStdoutBuffer;
890
- if (!remainder) return;
891
- task.agentStdoutBuffer = "";
892
- const latest: TelemetryDelta = {};
893
- this.consumeAgentLine(task, remainder, latest);
894
- this.commitTelemetry(task, latest);
895
- }
896
-
897
- private consumeAgentLine(task: BgTask, rawLine: string, latest: TelemetryDelta): void {
898
- const line = rawLine.replace(/\r$/, "");
899
- const trimmed = line.trim();
900
- if (!trimmed) return;
901
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
902
- this.writeNotice(task, `${line}\n`);
903
- return;
904
- }
905
- let parsed: unknown;
906
- try {
907
- parsed = JSON.parse(trimmed);
908
- } catch {
909
- this.writeNotice(task, `${line}\n`);
910
- return;
911
- }
912
- if (typeof parsed !== "object" || parsed === null) {
913
- this.writeNotice(task, `${line}\n`);
914
- return;
915
- }
916
- const record = parsed as Record<string, unknown>;
917
- const type = record["type"];
918
- if (type === "background-task-context-usage") {
919
- const context = normalizeContextUsage(parsed);
920
- if (context) latest.context = context;
921
- return;
922
- }
923
- if (type === "background-task-telemetry") {
924
- const context = normalizeContextUsage(record["contextUsage"]);
925
- if (context) latest.context = context;
926
- const tokens = normalizeTokenUsage(record["tokenUsage"]);
927
- if (tokens) latest.tokens = tokens;
928
- const tools = normalizeToolUsage(record["toolUsage"]);
929
- if (tools) latest.tools = tools;
930
- const model = normalizeModel(record["model"]);
931
- if (model) latest.model = model;
932
- return;
933
- }
934
- const activity = parseAgentActivity(parsed);
935
- if (activity) {
936
- const formatted = formatAgentActivityLine(activity);
937
- if (formatted) this.writeNotice(task, `${formatted}\n`);
938
- return;
939
- }
940
- // Unknown JSON object: pass through to the transcript rather than silently dropping it.
941
- this.writeNotice(task, `${line}\n`);
942
- }
943
-
944
- private requestKill(task: BgTask, signal: NodeJS.Signals = "SIGTERM"): void {
945
- if (task.status !== "running") {
946
- throw new Error(`Task ${task.id} is ${task.status}, not running`);
947
- }
948
- if (!task.child) {
949
- throw new Error(`Task ${task.id} has no child process handle`);
950
- }
951
- if (!task.pid) {
952
- throw new Error(`Task ${task.id} has no process id`);
953
- }
954
- if (task.killSignalSent && signal === "SIGTERM") return;
955
-
956
- const errors: string[] = [];
957
- let killed = false;
958
-
959
- if (this.platform !== "win32") {
960
- try {
961
- this.killProcess(-task.pid, signal);
962
- killed = true;
963
- } catch (error) {
964
- errors.push(`process group kill failed: ${error instanceof Error ? error.message : String(error)}`);
965
- }
966
- }
967
-
968
- if (!killed) {
969
- try {
970
- task.child.kill(signal);
971
- killed = true;
972
- } catch (error) {
973
- errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
974
- }
975
- }
976
-
977
- if (!killed) {
978
- throw new Error(`Could not kill task ${task.id}: ${errors.join("; ")}`);
979
- }
980
-
981
- task.killSignalSent = true;
982
- setTimeout(() => {
983
- if (task.status !== "running") return;
984
- try {
985
- this.requestKill(task, "SIGKILL");
986
- } catch (error) {
987
- task.error = `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`;
988
- void this.writeMetadata(task).catch((metadataError) => {
989
- this.logger.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
990
- });
991
- }
992
- }, this.killGraceMs).unref?.();
993
- }
994
-
995
- private waitForEnd(task: BgTask, timeoutMs: number): Promise<boolean> {
996
- if (task.status !== "running") return Promise.resolve(true);
997
- return new Promise((resolve) => {
998
- const timeout = setTimeout(() => {
999
- const idx = task.waiters.indexOf(done);
1000
- if (idx >= 0) task.waiters.splice(idx, 1);
1001
- resolve(false);
1002
- }, timeoutMs);
1003
- const done = () => {
1004
- clearTimeout(timeout);
1005
- resolve(true);
1006
- };
1007
- task.waiters.push(done);
1008
- });
1009
- }
1010
-
1011
- private async notifyCompletion(task: BgTask): Promise<void> {
1012
- if (!task.notifyOnCompletion || task.notified || this.shuttingDown) return;
1013
- task.notified = true;
1014
- const exit = task.exitCode === undefined ? "" : `\n <exit-code>${task.exitCode}</exit-code>`;
1015
- const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : "";
1016
- const taskName = taskDisplayName(task);
1017
- const content = [
1018
- "<background-task-notification>",
1019
- ` <task-id>${task.id}</task-id>`,
1020
- ` <task-name>${escapeXml(taskName)}</task-name>`,
1021
- ` <status>${task.status}</status>`,
1022
- exit,
1023
- error,
1024
- ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
1025
- ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
1026
- "</background-task-notification>",
1027
- ]
1028
- .filter(Boolean)
1029
- .join("\n");
1030
-
1031
- try {
1032
- this.sendCompletionNotification(
1033
- {
1034
- customType: "background-task-notification",
1035
- content,
1036
- display: true,
1037
- details: snapshot(task),
1038
- },
1039
- { deliverAs: "followUp", triggerTurn: task.triggerOnCompletion },
1040
- );
1041
- } catch (error) {
1042
- task.notified = false;
1043
- throw new Error(`Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
1044
- }
1045
- }
1046
-
1047
- private async finalizeTask(task: BgTask, status: TaskStatus, exitCode: number | null, signal?: string | null, error?: string): Promise<void> {
1048
- if (task.finalized) return;
1049
- task.finalized = true;
1050
- if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
1051
- task.status = status;
1052
- task.exitCode = exitCode;
1053
- task.signal = signal ?? null;
1054
- task.endTime = this.now();
1055
- if (error) task.error = error;
1056
- if (task.telemetryWrapped) this.flushAgentStdout(task);
1057
- if (task.stream && !task.stream.destroyed) task.stream.end();
1058
-
1059
- for (const waiter of task.waiters.splice(0)) waiter();
1060
-
1061
- try {
1062
- await this.writeMetadata(task);
1063
- } catch (metadataError) {
1064
- this.logger.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
1065
- }
1066
-
1067
- this.onChange();
1068
- try {
1069
- await this.notifyCompletion(task);
1070
- } catch (notificationError) {
1071
- this.logger.error(`[background-tasks] notification failed for ${task.id}:`, notificationError);
1072
- }
1073
- try {
1074
- await this.writeMetadata(task);
1075
- } catch (metadataError) {
1076
- this.logger.error(`[background-tasks] failed to update notification metadata for ${task.id}:`, metadataError);
1077
- }
1078
- this.pruneOldTasks();
1079
- }
1080
-
1081
- private pruneOldTasks(): void {
1082
- if (this.tasks.size <= this.maxRecentTasks) return;
1083
- const removable = [...this.tasks.values()]
1084
- .filter((task) => task.status !== "running")
1085
- .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
1086
- while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
1087
- const task = removable.shift();
1088
- if (task) this.tasks.delete(task.id);
1089
- }
1090
- }
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
+ ' <guidance>Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.</guidance>',
1542
+ '</background-task-notification>',
1543
+ ]
1544
+ .filter(Boolean)
1545
+ .join('\n');
1546
+
1547
+ try {
1548
+ this.sendCompletionNotification(
1549
+ {
1550
+ customType: 'background-task-notification',
1551
+ content,
1552
+ display: true,
1553
+ details: snapshot(task),
1554
+ },
1555
+ { deliverAs: 'followUp', triggerTurn: task.triggerOnCompletion },
1556
+ );
1557
+ } catch (error) {
1558
+ task.notified = false;
1559
+ throw new Error(
1560
+ `Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
1561
+ );
1562
+ }
1563
+ }
1564
+
1565
+ private async finalizeTask(
1566
+ task: BgTask,
1567
+ status: TaskStatus,
1568
+ exitCode: number | null,
1569
+ signal?: string | null,
1570
+ error?: string,
1571
+ ): Promise<void> {
1572
+ if (task.finalized) return;
1573
+ task.finalized = true;
1574
+ if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
1575
+ let finalStatus = status;
1576
+ let finalError = error;
1577
+ task.exitCode = exitCode;
1578
+ task.signal = signal ?? null;
1579
+
1580
+ // Keep status="running" until the final wrapped-agent fragment has been
1581
+ // consumed and the output plus terminal metadata are durable. Publishing a
1582
+ // terminal state earlier lets bg_status observe the previous assistant
1583
+ // turn's context snapshot and recreates the same false-completion race the
1584
+ // attested producer is required to prevent.
1585
+ try {
1586
+ if (task.telemetryWrapped) {
1587
+ // Child-process close can be observed before the wrapper stdout listener has
1588
+ // committed its last parsed telemetry batch. Wait for a short quiet window,
1589
+ // then flush the trailing partial line, so completed status never races
1590
+ // ahead of the final assistant-turn context/token/tool snapshot.
1591
+ await new Promise<void>((resolve) => setTimeout(resolve, 25));
1592
+ this.flushAgentStdout(task);
1593
+ }
1594
+ if (task.stream && !task.stream.destroyed) await closeAndFsyncOutputStream(task.stream);
1595
+ } catch (finalizeError) {
1596
+ finalStatus = 'failed';
1597
+ const message =
1598
+ finalizeError instanceof Error ? finalizeError.message : String(finalizeError);
1599
+ finalError = finalError
1600
+ ? `${finalError}; final output durability failed: ${message}`
1601
+ : `Final output durability failed: ${message}`;
1602
+ }
1603
+
1604
+ task.endTime = this.now();
1605
+ if (finalError) task.error = finalError;
1606
+ try {
1607
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
1608
+ task.status = finalStatus;
1609
+ } catch (metadataError) {
1610
+ finalStatus = 'failed';
1611
+ task.status = 'failed';
1612
+ task.error = `Terminal metadata write failed: ${metadataError instanceof Error ? metadataError.message : String(metadataError)}`;
1613
+ this.logger.error(
1614
+ `[background-tasks] failed to write metadata for ${task.id}:`,
1615
+ metadataError,
1616
+ );
1617
+ await this.writeMetadata(task).catch((retryError: unknown) => {
1618
+ this.logger.error(
1619
+ `[background-tasks] failed to write failed terminal metadata for ${task.id}:`,
1620
+ retryError,
1621
+ );
1622
+ });
1623
+ }
1624
+
1625
+ for (const waiter of task.waiters.splice(0)) waiter();
1626
+ this.onChange();
1627
+ this.publishTerminal(task);
1628
+ try {
1629
+ this.notifyCompletion(task);
1630
+ } catch (notificationError) {
1631
+ this.logger.error(
1632
+ `[background-tasks] notification failed for ${task.id}:`,
1633
+ notificationError,
1634
+ );
1635
+ }
1636
+ try {
1637
+ await this.writeMetadata(task);
1638
+ } catch (metadataError) {
1639
+ this.logger.error(
1640
+ `[background-tasks] failed to update notification metadata for ${task.id}:`,
1641
+ metadataError,
1642
+ );
1643
+ }
1644
+ this.pruneOldTasks();
1645
+ }
1646
+
1647
+ private pruneOldTasks(): void {
1648
+ if (this.tasks.size <= this.maxRecentTasks) return;
1649
+ const removable = [...this.tasks.values()]
1650
+ .filter((task) => task.status !== 'running')
1651
+ .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
1652
+ while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
1653
+ const task = removable.shift();
1654
+ if (task) this.tasks.delete(task.id);
1655
+ }
1656
+ }
1091
1657
  }