@pi-unipi/background-tasks 2.6.1

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.
Files changed (116) hide show
  1. package/README.md +87 -0
  2. package/extensions/anthropic-attribution.ts +1 -0
  3. package/extensions/delegate-child.ts +1 -0
  4. package/extensions/fusion-child.ts +1 -0
  5. package/package.json +40 -0
  6. package/src/__tests__/anthropic-attribution.test.ts +195 -0
  7. package/src/__tests__/config.test.ts +137 -0
  8. package/src/__tests__/core.test.ts +493 -0
  9. package/src/__tests__/delegate-artifacts.test.ts +528 -0
  10. package/src/__tests__/delegate-budget.test.ts +456 -0
  11. package/src/__tests__/delegate-launch.test.ts +676 -0
  12. package/src/__tests__/delegate-result-package.test.ts +350 -0
  13. package/src/__tests__/delegate-seed.test.ts +392 -0
  14. package/src/__tests__/durable-fs.test.ts +559 -0
  15. package/src/__tests__/extension-api.test.ts +579 -0
  16. package/src/__tests__/fusion-artifacts.test.ts +1039 -0
  17. package/src/__tests__/fusion-budget.test.ts +1356 -0
  18. package/src/__tests__/fusion-claude-cache.test.ts +320 -0
  19. package/src/__tests__/fusion-config.test.ts +335 -0
  20. package/src/__tests__/fusion-context-prompts.test.ts +670 -0
  21. package/src/__tests__/fusion-evaluation.test.ts +315 -0
  22. package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
  23. package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
  24. package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
  25. package/src/__tests__/fusion-model-selector.test.ts +205 -0
  26. package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
  27. package/src/__tests__/fusion-rpc.test.ts +369 -0
  28. package/src/__tests__/fusion-sdk.test.ts +1226 -0
  29. package/src/__tests__/fusion-v5-core.test.ts +219 -0
  30. package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
  31. package/src/__tests__/fusion-web-fetch.test.ts +485 -0
  32. package/src/__tests__/fusion-workflows.test.ts +59 -0
  33. package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
  34. package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
  35. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
  36. package/src/__tests__/helpers/fusion-canonical.ts +140 -0
  37. package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
  38. package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
  39. package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
  40. package/src/__tests__/helpers/normalize.ts +22 -0
  41. package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
  42. package/src/__tests__/pi-launch.test.ts +202 -0
  43. package/src/__tests__/registry.test.ts +1580 -0
  44. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
  45. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
  46. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
  47. package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
  48. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
  49. package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
  50. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
  51. package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
  52. package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
  53. package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
  54. package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
  55. package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
  56. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
  57. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
  58. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
  59. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
  60. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
  61. package/src/__tests__/task-manager.test.ts +479 -0
  62. package/src/__tests__/windows-taskkill.test.ts +161 -0
  63. package/src/anthropic-attribution-path.ts +21 -0
  64. package/src/anthropic-attribution.ts +1983 -0
  65. package/src/attested-pi-run.ts +612 -0
  66. package/src/child-process.ts +55 -0
  67. package/src/common.ts +8 -0
  68. package/src/config.ts +292 -0
  69. package/src/context-parent-snapshot.ts +142 -0
  70. package/src/context-token-budget.ts +903 -0
  71. package/src/context-visible-conversation-v2.ts +551 -0
  72. package/src/delegate/artifacts.ts +487 -0
  73. package/src/delegate/budget.ts +415 -0
  74. package/src/delegate/hook-contract-evidence.json +18 -0
  75. package/src/delegate/hook-contract.ts +154 -0
  76. package/src/delegate/launch.ts +497 -0
  77. package/src/delegate/result-package.ts +459 -0
  78. package/src/delegate/runner.ts +449 -0
  79. package/src/delegate/seed.ts +423 -0
  80. package/src/delegate/types.ts +323 -0
  81. package/src/delegate-child-extension.ts +978 -0
  82. package/src/delegate-extension.ts +806 -0
  83. package/src/durable-fs.ts +386 -0
  84. package/src/extension-api.ts +548 -0
  85. package/src/fixtures/delegate-context-incident.json +17 -0
  86. package/src/fixtures/fusion-golden-bytes.json +310 -0
  87. package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
  88. package/src/fusion/artifacts.ts +967 -0
  89. package/src/fusion/budget.ts +1162 -0
  90. package/src/fusion/child-protocol.ts +305 -0
  91. package/src/fusion/claude-cache.ts +207 -0
  92. package/src/fusion/clean-context.ts +91 -0
  93. package/src/fusion/config.ts +449 -0
  94. package/src/fusion/context.ts +265 -0
  95. package/src/fusion/evaluation.ts +800 -0
  96. package/src/fusion/orchestrator.ts +1288 -0
  97. package/src/fusion/output-contract.ts +34 -0
  98. package/src/fusion/pi-child.ts +2373 -0
  99. package/src/fusion/prompts.ts +345 -0
  100. package/src/fusion/result-package.ts +959 -0
  101. package/src/fusion/source-policy.ts +257 -0
  102. package/src/fusion/types.ts +1139 -0
  103. package/src/fusion/web-fetch.ts +1060 -0
  104. package/src/fusion/workflows.ts +184 -0
  105. package/src/fusion-child-extension.ts +1052 -0
  106. package/src/fusion-extension.ts +1293 -0
  107. package/src/index.ts +295 -0
  108. package/src/pi-launch.ts +225 -0
  109. package/src/registry.ts +2424 -0
  110. package/src/settings-overlay.ts +208 -0
  111. package/src/task-manager.ts +774 -0
  112. package/src/tools.ts +530 -0
  113. package/src/turndown.d.ts +15 -0
  114. package/src/types.ts +963 -0
  115. package/src/ui/fusion-model-selector.ts +322 -0
  116. package/src/windows-taskkill.ts +250 -0
@@ -0,0 +1,2424 @@
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';
9
+ import {
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 StartDelegateTaskOptions,
31
+ type StartManagedTaskOptions,
32
+ type StartTaskOptions,
33
+ type TaskContextUsage,
34
+ type TaskStatus,
35
+ type TaskTokenUsage,
36
+ type TaskToolUsage,
37
+ } from './types.js';
38
+ import type {
39
+ BackgroundTaskChildProcess,
40
+ BackgroundTaskContext,
41
+ BackgroundTaskSpawn,
42
+ } from './child-process.js';
43
+ export type { BackgroundTaskChildProcess, BackgroundTaskContext, BackgroundTaskSpawn };
44
+ import {
45
+ ATTESTED_TASK_ID_PATTERN,
46
+ attestedPiChildEnv,
47
+ buildAttestedPiArgv,
48
+ buildPiTaskAttestation,
49
+ closeAndFsyncOutputStream,
50
+ gitAuthoritySnapshot,
51
+ gitRepoRoot,
52
+ makeAttestedTaskId,
53
+ makeAttestedTaskPaths,
54
+ observePiOAuth,
55
+ parsePiJsonEvents,
56
+ resolveReportPath,
57
+ spawnAndCapturePi,
58
+ writeFileFsynced,
59
+ writeJsonAtomic,
60
+ } from './attested-pi-run.js';
61
+ import {
62
+ assertWindowsCommandLineWithinLimit,
63
+ piLaunchArgv,
64
+ resolvePiLaunch,
65
+ type PiLaunchSpec,
66
+ } from './pi-launch.js';
67
+ import { resolveAnthropicAttributionExtensionPath } from './anthropic-attribution-path.js';
68
+ import {
69
+ runWindowsTaskkill,
70
+ type TaskkillOutcome,
71
+ type WindowsKillPhase,
72
+ type WindowsTaskkillOptions,
73
+ } from './windows-taskkill.js';
74
+
75
+ export const MAX_OUTPUT_BYTES = Number(process.env['UNIPI_BG_MAX_OUTPUT_BYTES'] ?? 20 * 1024 * 1024);
76
+ export const KILL_GRACE_MS = 3000;
77
+ export const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
78
+ export const MAX_RECENT_TASKS = 100;
79
+ const TELEMETRY_BUFFER_CHARS = 512 * 1024;
80
+ export const WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON =
81
+ 'win32-cmd-cannot-safely-intercept-pi-argv';
82
+
83
+ type KillProcessFn = (pid: number, signal?: NodeJS.Signals | number) => boolean;
84
+ type KillTreeFn = (
85
+ pid: number,
86
+ phase: WindowsKillPhase,
87
+ signal?: AbortSignal,
88
+ ) => Promise<TaskkillOutcome>;
89
+
90
+ interface WindowsKillState {
91
+ softController?: AbortController | undefined;
92
+ softPromise?: Promise<void> | undefined;
93
+ forcePromise?: Promise<void> | undefined;
94
+ forceFailure?: Error | undefined;
95
+ forceFailureListeners?: Array<(error: Error) => void> | undefined;
96
+ }
97
+
98
+ export interface CompletionNotificationMessage {
99
+ customType: 'background-task-notification';
100
+ content: string;
101
+ display: true;
102
+ details: BgTaskSnapshot;
103
+ }
104
+
105
+ export interface CompletionNotificationOptions {
106
+ deliverAs: 'followUp';
107
+ triggerTurn: boolean;
108
+ }
109
+
110
+ export type CompletionNotificationSender = (
111
+ message: CompletionNotificationMessage,
112
+ options: CompletionNotificationOptions,
113
+ ) => void;
114
+
115
+ export interface BackgroundTaskRegistryOptions {
116
+ onChange?: () => void;
117
+ sendCompletionNotification: CompletionNotificationSender;
118
+ publishTerminal?: (task: BgTaskSnapshot) => void;
119
+ spawn?: BackgroundTaskSpawn;
120
+ killProcess?: KillProcessFn;
121
+ killTree?: KillTreeFn;
122
+ platform?: NodeJS.Platform;
123
+ env?: NodeJS.ProcessEnv;
124
+ makeTaskId?: () => string;
125
+ now?: () => number;
126
+ maxOutputBytes?: number;
127
+ maxRecentTasks?: number;
128
+ killGraceMs?: number;
129
+ stopWaitMs?: number;
130
+ logger?: Pick<Console, 'error'>;
131
+ }
132
+
133
+ interface RuntimeDir {
134
+ abs: string;
135
+ display: string;
136
+ }
137
+
138
+ interface ModelWindowIndex {
139
+ byQualifiedId: Record<string, number>;
140
+ byId: Record<string, number>;
141
+ defaultModel?: string | undefined;
142
+ defaultProvider?: string | undefined;
143
+ defaultContextWindow?: number | undefined;
144
+ }
145
+
146
+ function defaultTaskId(): string {
147
+ return `b${randomBytes(4).toString('hex')}`;
148
+ }
149
+
150
+ function dirNameFromDisplay(path: string): string {
151
+ const parts = path.split(/[\\/]/);
152
+ return parts.length >= 2 ? (parts.at(-2) ?? '') : '';
153
+ }
154
+
155
+ export function commandMayLaunchPiAgent(
156
+ command: string,
157
+ env: NodeJS.ProcessEnv = process.env,
158
+ ): boolean {
159
+ if (env['UNIPI_BG_DISABLE_PI_TELEMETRY'] === '1') return false;
160
+ return /(^|[\s;&|()])pi(?=\s)(?=[^\n;&|]*(?:\s-p(?:\s|$)|\s--print(?:\s|$)|\s--mode(?:=|\s+)json\b))/m.test(
161
+ command,
162
+ );
163
+ }
164
+
165
+ export function buildModelWindowIndex(
166
+ ctx: Pick<BackgroundTaskContext, 'modelRegistry' | 'model'>,
167
+ ): ModelWindowIndex {
168
+ const byQualifiedId: Record<string, number> = {};
169
+ const candidatesById = new Map<string, Set<number>>();
170
+ for (const model of ctx.modelRegistry.getAll()) {
171
+ const contextWindow =
172
+ typeof model.contextWindow === 'number' &&
173
+ Number.isFinite(model.contextWindow) &&
174
+ model.contextWindow > 0
175
+ ? Math.floor(model.contextWindow)
176
+ : undefined;
177
+ if (!contextWindow) continue;
178
+ const providerId = String(model.provider);
179
+ const modelId = String(model.id);
180
+ byQualifiedId[`${providerId}/${modelId}`] = contextWindow;
181
+ let candidates = candidatesById.get(modelId);
182
+ if (!candidates) {
183
+ candidates = new Set<number>();
184
+ candidatesById.set(modelId, candidates);
185
+ }
186
+ candidates.add(contextWindow);
187
+ }
188
+ const byId: Record<string, number> = {};
189
+ for (const [id, windows] of candidatesById) {
190
+ const onlyWindow = windows.values().next();
191
+ if (windows.size === 1 && !onlyWindow.done) byId[id] = onlyWindow.value;
192
+ }
193
+ const current = ctx.model;
194
+ return {
195
+ byQualifiedId,
196
+ byId,
197
+ defaultModel: current?.id,
198
+ defaultProvider: current?.provider,
199
+ defaultContextWindow: current?.contextWindow,
200
+ };
201
+ }
202
+
203
+ export function createPiTelemetryWrapperSource(
204
+ index: ModelWindowIndex,
205
+ launch: PiLaunchSpec = resolvePiLaunch(),
206
+ ): string {
207
+ return `#!/usr/bin/env node
208
+ const { spawn } = require("node:child_process");
209
+ const index = ${JSON.stringify(index)};
210
+ const launch = ${JSON.stringify(launch)};
211
+ const WINDOWS_COMMAND_LINE_LIMIT = 32767;
212
+
213
+ const tokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
214
+ let costTotal = 0;
215
+ let hasCostTotal = false;
216
+ let agentModel;
217
+ const toolUsage = { total: 0, failed: 0, byName: {} };
218
+ const seenToolCallIds = new Set();
219
+ const failedToolCallIds = new Set();
220
+
221
+ function nonNegativeInteger(value) {
222
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
223
+ }
224
+
225
+ function normalizeUsage(usage) {
226
+ if (!usage) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
227
+ const input = nonNegativeInteger(usage.input);
228
+ const output = nonNegativeInteger(usage.output);
229
+ const cacheRead = nonNegativeInteger(usage.cacheRead);
230
+ const cacheWrite = nonNegativeInteger(usage.cacheWrite);
231
+ const explicitTotal = nonNegativeInteger(usage.totalTokens);
232
+ const totalTokens = explicitTotal || (input + output + cacheRead + cacheWrite);
233
+ const cost = usage.cost && typeof usage.cost.total === "number" && Number.isFinite(usage.cost.total) && usage.cost.total >= 0
234
+ ? usage.cost.total
235
+ : undefined;
236
+ return { input, output, cacheRead, cacheWrite, totalTokens, cost };
237
+ }
238
+
239
+ function addTokenUsage(usage) {
240
+ const normalized = normalizeUsage(usage);
241
+ if (!normalized.totalTokens) return normalized;
242
+ tokenUsage.input += normalized.input;
243
+ tokenUsage.output += normalized.output;
244
+ tokenUsage.cacheRead += normalized.cacheRead;
245
+ tokenUsage.cacheWrite += normalized.cacheWrite;
246
+ tokenUsage.totalTokens += normalized.totalTokens;
247
+ if (normalized.cost !== undefined) {
248
+ costTotal += normalized.cost;
249
+ hasCostTotal = true;
250
+ }
251
+ return normalized;
252
+ }
253
+
254
+ function currentTokenUsage() {
255
+ if (!tokenUsage.totalTokens) return undefined;
256
+ const out = { ...tokenUsage };
257
+ if (hasCostTotal) out.costTotal = costTotal;
258
+ return out;
259
+ }
260
+
261
+ function markToolStarted(id, name) {
262
+ const key = id ? String(id) : undefined;
263
+ if (key && seenToolCallIds.has(key)) return;
264
+ if (key) seenToolCallIds.add(key);
265
+ const toolName = name ? String(name) : "unknown";
266
+ toolUsage.total += 1;
267
+ toolUsage.byName[toolName] = (toolUsage.byName[toolName] || 0) + 1;
268
+ }
269
+
270
+ function markToolFailed(id) {
271
+ const key = id ? String(id) : undefined;
272
+ if (key && failedToolCallIds.has(key)) return;
273
+ if (key) failedToolCallIds.add(key);
274
+ toolUsage.failed += 1;
275
+ }
276
+
277
+ function currentToolUsage() {
278
+ if (!toolUsage.total && !toolUsage.failed) return undefined;
279
+ return { total: toolUsage.total, failed: toolUsage.failed, byName: { ...toolUsage.byName } };
280
+ }
281
+
282
+ function renderWindowsArgument(value) {
283
+ if (value.length > 0 && !/[ \\t\"]/.test(value)) return value;
284
+ let rendered = "\\\"";
285
+ let backslashes = 0;
286
+ for (const char of value) {
287
+ if (char === "\\\\") {
288
+ backslashes += 1;
289
+ continue;
290
+ }
291
+ if (char === "\\\"") {
292
+ rendered += "\\\\".repeat(backslashes * 2 + 1);
293
+ rendered += "\\\"";
294
+ backslashes = 0;
295
+ continue;
296
+ }
297
+ if (backslashes > 0) {
298
+ rendered += "\\\\".repeat(backslashes);
299
+ backslashes = 0;
300
+ }
301
+ rendered += char;
302
+ }
303
+ if (backslashes > 0) rendered += "\\\\".repeat(backslashes * 2);
304
+ rendered += "\\\"";
305
+ return rendered;
306
+ }
307
+
308
+ function assertWindowsLimit(stage, args) {
309
+ if (process.platform !== "win32") return;
310
+ const measured = [launch.executable, ...launch.argvPrefix, ...args].map(renderWindowsArgument).join(" ").length + 1;
311
+ if (measured > WINDOWS_COMMAND_LINE_LIMIT) {
312
+ const error = new Error("pi_command_line_too_long: " + stage + " measured UTF-16 command line length " + String(measured) + " exceeds limit " + String(WINDOWS_COMMAND_LINE_LIMIT));
313
+ error.code = "pi_command_line_too_long";
314
+ throw error;
315
+ }
316
+ }
317
+
318
+ function emitUnifiedTelemetry(payload) {
319
+ const out = { type: "background-task-telemetry", ...payload };
320
+ const tokens = currentTokenUsage();
321
+ const tools = currentToolUsage();
322
+ if (tokens && !out.tokenUsage) out.tokenUsage = tokens;
323
+ if (tools && !out.toolUsage) out.toolUsage = tools;
324
+ if (agentModel && !out.model) out.model = agentModel;
325
+ process.stdout.write(JSON.stringify(out) + "\\n");
326
+ }
327
+
328
+ function emitActivity(activity) {
329
+ process.stdout.write(JSON.stringify({ type: "background-task-activity", ...activity }) + "\\n");
330
+ }
331
+
332
+ function summarizeArgs(args) {
333
+ if (!args || typeof args !== "object") return "";
334
+ const pick = (value) => {
335
+ if (typeof value === "string" && value.trim()) return value.trim().slice(0, 200);
336
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
337
+ return undefined;
338
+ };
339
+ const preferred = ["path", "file_path", "file", "filename", "command", "cmd", "pattern", "query", "url", "name", "value", "text", "message"];
340
+ for (const key of preferred) { const summary = pick(args[key]); if (summary) return summary; }
341
+ for (const key of Object.keys(args)) { const summary = pick(args[key]); if (summary) return summary; }
342
+ return "";
343
+ }
344
+
345
+ function emitAssistantActivity(message) {
346
+ const content = message && Array.isArray(message.content) ? message.content : [];
347
+ for (const part of content) {
348
+ if (!part || typeof part !== "object") continue;
349
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
350
+ emitActivity({ kind: "assistant_text", text: part.text });
351
+ } else if (part.type === "thinking" || part.type === "reasoning") {
352
+ const text = typeof part.text === "string" ? part.text : (typeof part.thinking === "string" ? part.thinking : "");
353
+ if (text.trim()) emitActivity({ kind: "reasoning", text: text });
354
+ }
355
+ }
356
+ }
357
+
358
+ function resolveModelName(fromMessage, fromArgs, providerFromArgs) {
359
+ const message = fromMessage ? String(fromMessage) : "";
360
+ const args = fromArgs ? String(fromArgs) : "";
361
+ const bareOf = (value) => value.includes("/") ? value.split("/").pop() : value;
362
+ if (message && message.includes("/")) return message;
363
+ if (args && args.includes("/") && (!message || bareOf(args) === message)) return args;
364
+ const primary = message || args;
365
+ if (!primary) return undefined;
366
+ if (primary.includes("/")) return primary;
367
+ if (providerFromArgs) return providerFromArgs + "/" + primary;
368
+ if (index.defaultProvider) return index.defaultProvider + "/" + primary;
369
+ return primary;
370
+ }
371
+
372
+ function parseInvocation(argv) {
373
+ const out = [];
374
+ let model;
375
+ let provider;
376
+ let hasMode = false;
377
+ let modeValue;
378
+ for (let i = 0; i < argv.length; i++) {
379
+ const arg = argv[i];
380
+ if (arg === "-p" || arg === "--print") continue;
381
+ if (arg === "--mode") {
382
+ hasMode = true;
383
+ modeValue = argv[i + 1];
384
+ out.push(arg);
385
+ if (i + 1 < argv.length) out.push(argv[++i]);
386
+ continue;
387
+ }
388
+ if (arg.startsWith("--mode=")) {
389
+ hasMode = true;
390
+ modeValue = arg.slice("--mode=".length);
391
+ out.push(arg);
392
+ continue;
393
+ }
394
+ if (arg === "--model" && i + 1 < argv.length) {
395
+ model = argv[i + 1];
396
+ out.push(arg, argv[++i]);
397
+ continue;
398
+ }
399
+ if (arg.startsWith("--model=")) model = arg.slice("--model=".length);
400
+ if (arg === "--provider" && i + 1 < argv.length) {
401
+ provider = argv[i + 1];
402
+ out.push(arg, argv[++i]);
403
+ continue;
404
+ }
405
+ if (arg.startsWith("--provider=")) provider = arg.slice("--provider=".length);
406
+ out.push(arg);
407
+ }
408
+ if (hasMode && modeValue !== "json") return { args: argv, parseJson: false, model, provider };
409
+ if (!hasMode) out.unshift("--mode", "json");
410
+ return { args: out, parseJson: true, model, provider };
411
+ }
412
+
413
+ function resolveWindow(modelFromArgs, providerFromArgs, modelFromMessage) {
414
+ const candidates = [];
415
+ if (modelFromMessage) candidates.push(modelFromMessage);
416
+ if (modelFromArgs) candidates.push(modelFromArgs);
417
+ if (modelFromArgs && providerFromArgs && !modelFromArgs.includes("/")) candidates.push(providerFromArgs + "/" + modelFromArgs);
418
+ if (modelFromArgs && index.defaultProvider && !modelFromArgs.includes("/")) candidates.push(index.defaultProvider + "/" + modelFromArgs);
419
+ if (index.defaultModel && index.defaultProvider) candidates.push(index.defaultProvider + "/" + index.defaultModel);
420
+ for (const candidate of candidates) {
421
+ if (!candidate) continue;
422
+ if (index.byQualifiedId[candidate]) return index.byQualifiedId[candidate];
423
+ const bare = String(candidate).includes("/") ? String(candidate).split("/").pop() : String(candidate);
424
+ if (bare && index.byId[bare]) return index.byId[bare];
425
+ }
426
+ return index.defaultContextWindow || 0;
427
+ }
428
+
429
+ function countToolCallsFromMessage(message) {
430
+ const content = message && Array.isArray(message.content) ? message.content : [];
431
+ for (const part of content) {
432
+ if (part && part.type === "toolCall") markToolStarted(part.id, part.name);
433
+ }
434
+ }
435
+
436
+ function emitMessageTelemetry(message, modelFromArgs, providerFromArgs) {
437
+ const usage = addTokenUsage(message && message.usage);
438
+ const resolvedModel = resolveModelName(message && message.model, modelFromArgs, providerFromArgs);
439
+ if (resolvedModel) agentModel = resolvedModel;
440
+ const contextWindow = resolveWindow(modelFromArgs, providerFromArgs, message && message.model);
441
+ const contextUsage = usage.totalTokens && contextWindow
442
+ ? { tokens: usage.totalTokens, contextWindow, percent: (usage.totalTokens / contextWindow) * 100 }
443
+ : undefined;
444
+ if (contextUsage) process.stdout.write(JSON.stringify({ type: "background-task-context-usage", ...contextUsage }) + "\\n");
445
+ const payload = {};
446
+ if (contextUsage) payload.contextUsage = contextUsage;
447
+ emitUnifiedTelemetry(payload);
448
+ }
449
+
450
+ function emitToolTelemetry() {
451
+ emitUnifiedTelemetry({});
452
+ }
453
+
454
+ const parsed = parseInvocation(process.argv.slice(2));
455
+ let child;
456
+ let buffer = "";
457
+ try {
458
+ const childArgs = [...launch.argvPrefix, ...parsed.args];
459
+ assertWindowsLimit("telemetry-wrapper-pi", parsed.args);
460
+ child = spawn(launch.executable, childArgs, { stdio: ["ignore", "pipe", "pipe"], env: process.env, shell: false, windowsHide: true });
461
+ } catch (error) {
462
+ const message = error && typeof error.message === "string" ? error.message : String(error);
463
+ process.stderr.write("[pi-bg telemetry wrapper error: " + message + "]\\n");
464
+ process.exitCode = 1;
465
+ }
466
+
467
+ if (child) {
468
+ if (!parsed.parseJson) {
469
+ child.stdout.pipe(process.stdout);
470
+ } else {
471
+ child.stdout.on("data", (chunk) => {
472
+ buffer += chunk.toString();
473
+ const lines = buffer.split("\\n");
474
+ buffer = lines.pop() || "";
475
+ for (const line of lines) processLine(line);
476
+ });
477
+ }
478
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
479
+ child.on("error", (error) => {
480
+ process.stderr.write("[pi-bg telemetry wrapper error: " + error.message + "]\\n");
481
+ });
482
+ child.on("close", (code, signal) => {
483
+ if (parsed.parseJson && buffer.trim()) processLine(buffer);
484
+ // Never call process.exit() here: the final message telemetry may still be
485
+ // buffered on wrapper stdout, and forced exit can publish a stale context
486
+ // snapshot from the preceding assistant turn. exitCode lets Node drain the
487
+ // pipe; signal termination is deferred through the same stdout barrier.
488
+ process.stdout.write("", () => {
489
+ if (signal) process.kill(process.pid, signal);
490
+ else process.exitCode = code ?? 0;
491
+ });
492
+ });
493
+ }
494
+
495
+ function processLine(line) {
496
+ if (!line.trim()) return;
497
+ let event;
498
+ try {
499
+ event = JSON.parse(line);
500
+ } catch {
501
+ process.stdout.write(line + "\\n");
502
+ return;
503
+ }
504
+ if (event.type === "tool_execution_start") {
505
+ const toolName = event.toolName || event.tool_name || "tool";
506
+ markToolStarted(event.toolCallId || event.tool_call_id, toolName);
507
+ emitActivity({ kind: "tool_start", tool: String(toolName), argsSummary: summarizeArgs(event.args || event.arguments || event.input || event.parameters) });
508
+ emitToolTelemetry();
509
+ return;
510
+ }
511
+ if (event.type === "tool_execution_end") {
512
+ const toolName = event.toolName || event.tool_name || "tool";
513
+ if (event.isError) markToolFailed(event.toolCallId || event.tool_call_id);
514
+ emitActivity({ kind: "tool_end", tool: String(toolName), isError: !!event.isError, error: typeof event.error === "string" ? event.error : undefined });
515
+ emitToolTelemetry();
516
+ return;
517
+ }
518
+ if (event.type === "message_end" && event.message && event.message.role === "assistant") {
519
+ emitAssistantActivity(event.message);
520
+ countToolCallsFromMessage(event.message);
521
+ emitMessageTelemetry(event.message, parsed.model, parsed.provider);
522
+ }
523
+ }
524
+ `;
525
+ }
526
+
527
+ interface ContextUsagePayload extends JsonObject {
528
+ readonly contextWindow?: unknown;
529
+ readonly tokens?: unknown;
530
+ readonly percent?: unknown;
531
+ }
532
+
533
+ interface TokenUsagePayload extends JsonObject {
534
+ readonly input?: unknown;
535
+ readonly output?: unknown;
536
+ readonly cacheRead?: unknown;
537
+ readonly cacheWrite?: unknown;
538
+ readonly totalTokens?: unknown;
539
+ readonly costTotal?: unknown;
540
+ }
541
+
542
+ interface ToolUsagePayload extends JsonObject {
543
+ readonly byName?: unknown;
544
+ readonly failed?: unknown;
545
+ readonly total?: unknown;
546
+ }
547
+
548
+ function normalizeContextUsage(value: unknown): TaskContextUsage | undefined {
549
+ if (!isJsonObject(value)) return undefined;
550
+ const input: ContextUsagePayload = value;
551
+ const rawContextWindow = input.contextWindow;
552
+ const contextWindow =
553
+ typeof rawContextWindow === 'number' &&
554
+ Number.isFinite(rawContextWindow) &&
555
+ rawContextWindow > 0
556
+ ? Math.floor(rawContextWindow)
557
+ : undefined;
558
+ if (!contextWindow) return undefined;
559
+ const rawTokens = input.tokens;
560
+ const tokens =
561
+ rawTokens === null
562
+ ? null
563
+ : typeof rawTokens === 'number' && Number.isFinite(rawTokens) && rawTokens >= 0
564
+ ? Math.floor(rawTokens)
565
+ : null;
566
+ const rawPercent = input.percent;
567
+ const percent =
568
+ rawPercent === null
569
+ ? null
570
+ : typeof rawPercent === 'number' && Number.isFinite(rawPercent) && rawPercent >= 0
571
+ ? rawPercent
572
+ : tokens === null
573
+ ? null
574
+ : (tokens / contextWindow) * 100;
575
+ return { tokens, contextWindow, percent };
576
+ }
577
+
578
+ function parseContextUsageXml(xml: string): TaskContextUsage | undefined {
579
+ const readNumber = (tag: string): number | null | undefined => {
580
+ const match = new RegExp(`<${tag}>(.*?)</${tag}>`, 'i').exec(xml);
581
+ if (!match) return undefined;
582
+ const raw = match[1]?.trim();
583
+ if (raw === 'null' || raw === '?') return null;
584
+ const parsed = Number(raw);
585
+ return Number.isFinite(parsed) ? parsed : undefined;
586
+ };
587
+ const tokens = readNumber('tokens');
588
+ const contextWindow = readNumber('context-window') ?? readNumber('contextWindow');
589
+ const percent = readNumber('percent');
590
+ return normalizeContextUsage({ tokens, contextWindow, percent });
591
+ }
592
+
593
+ function nonNegativeInteger(value: unknown): number {
594
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
595
+ }
596
+
597
+ function normalizeModel(value: unknown): string | undefined {
598
+ if (typeof value !== 'string') return undefined;
599
+ const trimmed = value.trim();
600
+ if (!trimmed) return undefined;
601
+ return trimmed.length > 120 ? trimmed.slice(0, 120) : trimmed;
602
+ }
603
+
604
+ function normalizeTokenUsage(value: unknown): TaskTokenUsage | undefined {
605
+ if (!isJsonObject(value)) return undefined;
606
+ const input: TokenUsagePayload = value;
607
+ const usage: TaskTokenUsage = {
608
+ input: nonNegativeInteger(input.input),
609
+ output: nonNegativeInteger(input.output),
610
+ cacheRead: nonNegativeInteger(input.cacheRead),
611
+ cacheWrite: nonNegativeInteger(input.cacheWrite),
612
+ totalTokens: nonNegativeInteger(input.totalTokens),
613
+ };
614
+ if (usage.totalTokens <= 0)
615
+ usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
616
+ const rawCostTotal = input.costTotal;
617
+ if (typeof rawCostTotal === 'number' && Number.isFinite(rawCostTotal) && rawCostTotal >= 0)
618
+ usage.costTotal = rawCostTotal;
619
+ return usage.totalTokens > 0 ? usage : undefined;
620
+ }
621
+
622
+ function normalizeToolUsage(value: unknown): TaskToolUsage | undefined {
623
+ if (!isJsonObject(value)) return undefined;
624
+ const input: ToolUsagePayload = value;
625
+ const byName: Record<string, number> = {};
626
+ const rawByName = input.byName;
627
+ if (isJsonObject(rawByName)) {
628
+ for (const [name, count] of Object.entries(rawByName)) {
629
+ const normalized = nonNegativeInteger(count);
630
+ if (normalized > 0) byName[name] = normalized;
631
+ }
632
+ }
633
+ const byNameTotal = Object.values(byName).reduce((sum, count) => sum + count, 0);
634
+ const failed = nonNegativeInteger(input.failed);
635
+ const total = Math.max(nonNegativeInteger(input.total), byNameTotal, failed);
636
+ return total > 0 || failed > 0 ? { total, failed, byName } : undefined;
637
+ }
638
+
639
+ interface TelemetryControlPayload extends JsonObject {
640
+ readonly type?: unknown;
641
+ readonly contextUsage?: unknown;
642
+ readonly tokenUsage?: unknown;
643
+ readonly toolUsage?: unknown;
644
+ readonly model?: unknown;
645
+ }
646
+
647
+ interface TelemetryDelta {
648
+ context?: TaskContextUsage | undefined;
649
+ tokens?: TaskTokenUsage | undefined;
650
+ tools?: TaskToolUsage | undefined;
651
+ model?: string | undefined;
652
+ }
653
+
654
+ function noopOnChange(): void {
655
+ return undefined;
656
+ }
657
+
658
+ /**
659
+ * Deliver the delegate prompt bytes over stdin.
660
+ *
661
+ * A failure to deliver the seed is loud: the caller terminates the task rather
662
+ * than letting a child run without the context it was supposed to receive.
663
+ */
664
+ function writeDelegateStdin(
665
+ child: BackgroundTaskChildProcess,
666
+ bytes: Buffer,
667
+ onError: (error: Error) => void,
668
+ ): void {
669
+ const stdin = child.stdin;
670
+ if (stdin === undefined || stdin === null) {
671
+ onError(new Error('delegate child stdin pipe is unavailable'));
672
+ return;
673
+ }
674
+ stdin.once('error', onError);
675
+ stdin.write(bytes, (error?: Error | null) => {
676
+ if (error !== undefined && error !== null) {
677
+ onError(error);
678
+ return;
679
+ }
680
+ stdin.end();
681
+ });
682
+ }
683
+
684
+ export class BackgroundTaskRegistry {
685
+ private readonly tasks = new Map<string, BgTask>();
686
+ private runtimeDir: RuntimeDir | undefined;
687
+ private runtimeNonce: string | undefined;
688
+ private shuttingDown = false;
689
+ private readonly spawn: BackgroundTaskSpawn;
690
+ private readonly killProcess: KillProcessFn;
691
+ private readonly killTree: KillTreeFn;
692
+ private readonly platform: NodeJS.Platform;
693
+ private readonly env: NodeJS.ProcessEnv;
694
+ private readonly makeTaskIdFn: () => string;
695
+ private readonly now: () => number;
696
+ private readonly maxOutputBytes: number;
697
+ private readonly maxRecentTasks: number;
698
+ private readonly killGraceMs: number;
699
+ private readonly stopWaitMs: number;
700
+ private readonly logger: Pick<Console, 'error'>;
701
+ private readonly onChange: () => void;
702
+ private readonly sendCompletionNotification: CompletionNotificationSender;
703
+ private readonly publishTerminalSnapshot: (task: BgTaskSnapshot) => void;
704
+ private readonly windowsKillStates = new WeakMap<BgTask, WindowsKillState>();
705
+
706
+ constructor(options: BackgroundTaskRegistryOptions) {
707
+ this.spawn =
708
+ options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions ?? {}) as unknown as BackgroundTaskChildProcess);
709
+ this.killProcess = options.killProcess ?? process.kill.bind(process);
710
+ this.platform = options.platform ?? process.platform;
711
+ this.env = options.env ?? process.env;
712
+ this.killTree =
713
+ options.killTree ??
714
+ ((pid, phase, signal) => {
715
+ const taskkillOptions: WindowsTaskkillOptions =
716
+ signal === undefined ? { env: this.env } : { env: this.env, signal };
717
+ return runWindowsTaskkill(pid, phase, taskkillOptions);
718
+ });
719
+ this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
720
+ this.now = options.now ?? Date.now;
721
+ this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
722
+ this.maxRecentTasks = options.maxRecentTasks ?? MAX_RECENT_TASKS;
723
+ this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
724
+ this.stopWaitMs = options.stopWaitMs ?? STOP_WAIT_MS;
725
+ this.logger = options.logger ?? console;
726
+ this.onChange = options.onChange ?? noopOnChange;
727
+ this.sendCompletionNotification = options.sendCompletionNotification;
728
+ this.publishTerminalSnapshot = options.publishTerminal ?? noopOnChange;
729
+ }
730
+
731
+ isShuttingDown(): boolean {
732
+ return this.shuttingDown;
733
+ }
734
+
735
+ setShuttingDown(value: boolean): void {
736
+ this.shuttingDown = value;
737
+ }
738
+
739
+ allTasks(): BgTask[] {
740
+ return [...this.tasks.values()];
741
+ }
742
+
743
+ snapshot(task: BgTask): BgTaskSnapshot {
744
+ return snapshot(task);
745
+ }
746
+
747
+ async ensureRuntimeDir(ctx: BackgroundTaskContext): Promise<RuntimeDir> {
748
+ if (this.runtimeDir) return this.runtimeDir;
749
+ // OUR convention: runtime artifacts under the OS temp root, never .pi/tasks/.
750
+ // A per-instance nonce keeps concurrent registries in one process isolated
751
+ // (the reference relied on per-cwd .pi/tasks for this).
752
+ if (this.runtimeNonce === undefined) this.runtimeNonce = randomBytes(4).toString('hex');
753
+ const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
754
+ const runId = `${sessionId}-${String(process.pid)}-${this.runtimeNonce}`;
755
+ const tmpBase = process.env['UNIPI_BG_TMP_DIR'] ?? (process.env['TMPDIR'] ?? '/tmp');
756
+ const runtimeDirAbs = join(tmpBase, 'unipi-bg-tasks', runId);
757
+ const runtimeDirDisplay = join(tmpBase, 'unipi-bg-tasks', runId);
758
+ await mkdir(runtimeDirAbs, { recursive: true });
759
+ this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
760
+ return this.runtimeDir;
761
+ }
762
+
763
+ async startTask(
764
+ ctx: BackgroundTaskContext,
765
+ command: string,
766
+ options: StartTaskOptions = {},
767
+ ): Promise<BgTask> {
768
+ const normalizedCommand = command.trim();
769
+ if (!normalizedCommand) throw new Error('Background command is empty');
770
+ if (this.shuttingDown)
771
+ throw new Error('Cannot start a background task while Pi is shutting down');
772
+
773
+ const isAgent = options.isAgent ?? false;
774
+ const baseInvocation = shellInvocation(normalizedCommand, this.platform, this.env);
775
+ const piTelemetryRequested = isAgent && commandMayLaunchPiAgent(normalizedCommand, this.env);
776
+ const piTelemetryLaunch =
777
+ piTelemetryRequested && baseInvocation.dialect === 'posix'
778
+ ? resolvePiLaunch({ platform: this.platform })
779
+ : undefined;
780
+
781
+ const dir = await this.ensureRuntimeDir(ctx);
782
+ const id = this.makeTaskIdFn();
783
+ const outputAbsPath = join(dir.abs, `${id}.output`);
784
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
785
+ const outputPath = join(dir.display, `${id}.output`);
786
+ const timeoutSeconds =
787
+ typeof options.timeoutSeconds === 'number' &&
788
+ Number.isFinite(options.timeoutSeconds) &&
789
+ options.timeoutSeconds > 0
790
+ ? Math.floor(options.timeoutSeconds)
791
+ : undefined;
792
+ const taskName =
793
+ normalizeTaskName(options.name) ??
794
+ normalizeTaskName(options.description) ??
795
+ deriveTaskNameFromCommand(normalizedCommand);
796
+ const trimmedDescription = options.description?.trim();
797
+ const description =
798
+ trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : undefined;
799
+
800
+ const task: BgTask = {
801
+ id,
802
+ name: taskName,
803
+ command: normalizedCommand,
804
+ description,
805
+ status: 'running',
806
+ outputPath,
807
+ outputAbsPath,
808
+ metadataAbsPath,
809
+ cwd: ctx.cwd,
810
+ startTime: this.now(),
811
+ exitCode: undefined,
812
+ pid: undefined,
813
+ bytesWritten: 0,
814
+ isAgent,
815
+ notified: false,
816
+ notifyOnCompletion: options.notifyOnCompletion ?? true,
817
+ triggerOnCompletion: options.triggerOnCompletion ?? false,
818
+ timeoutSeconds,
819
+ terminalPublicationGate: options.terminalPublicationGate,
820
+ waiters: [],
821
+ };
822
+ this.tasks.set(id, task);
823
+
824
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
825
+ task.stream = stream;
826
+ stream.on('error', (error) => {
827
+ task.error = `Output file write failed: ${error.message}`;
828
+ if (task.status === 'running') {
829
+ task.killKind = 'output_cap';
830
+ try {
831
+ this.requestKill(task, 'SIGTERM');
832
+ } catch (killError) {
833
+ void this.finalizeTask(
834
+ task,
835
+ 'failed',
836
+ null,
837
+ undefined,
838
+ `${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
839
+ );
840
+ }
841
+ }
842
+ });
843
+
844
+ try {
845
+ let commandToSpawn = normalizedCommand;
846
+ if (piTelemetryRequested) {
847
+ if (baseInvocation.dialect === 'posix') {
848
+ if (piTelemetryLaunch === undefined)
849
+ throw new Error('Pi telemetry launch spec was not resolved');
850
+ const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
851
+ await writeFile(
852
+ wrapperAbsPath,
853
+ createPiTelemetryWrapperSource(buildModelWindowIndex(ctx), piTelemetryLaunch),
854
+ 'utf8',
855
+ );
856
+ commandToSpawn = `pi() { ${shellQuote(process.execPath)} ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
857
+ task.telemetryWrapped = true;
858
+ } else {
859
+ task.telemetryUnavailableReason = WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON;
860
+ }
861
+ }
862
+ const invocation =
863
+ commandToSpawn === normalizedCommand
864
+ ? baseInvocation
865
+ : shellInvocation(commandToSpawn, this.platform, this.env);
866
+ const child = this.spawn(invocation.shell, invocation.args, {
867
+ cwd: ctx.cwd,
868
+ detached: this.platform !== 'win32',
869
+ stdio: ['ignore', 'pipe', 'pipe'],
870
+ env: this.env,
871
+ windowsHide: true,
872
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
873
+ });
874
+
875
+ task.child = child;
876
+ task.pid = child.pid;
877
+
878
+ child.stdout?.on('data', (data) => {
879
+ this.appendChildOutput(task, data, 'stdout');
880
+ });
881
+ child.stderr?.on('data', (data) => {
882
+ this.appendChildOutput(task, data, 'stderr');
883
+ });
884
+
885
+ child.on('error', (error) => {
886
+ this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
887
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
888
+ });
889
+
890
+ child.on('close', (code, signalName) => {
891
+ let status: TaskStatus;
892
+ let error: string | undefined;
893
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
894
+ status = 'killed';
895
+ } else if (task.killKind === 'timeout') {
896
+ status = 'failed';
897
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
898
+ } else if (task.killKind === 'output_cap') {
899
+ status = 'failed';
900
+ error = task.error ?? `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
901
+ } else if ((code ?? 0) === 0) {
902
+ status = 'completed';
903
+ } else {
904
+ status = 'failed';
905
+ const exitCode = code === null ? 'null' : String(code);
906
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
907
+ }
908
+ void this.finalizeTask(task, status, code, signalName, error);
909
+ });
910
+
911
+ if (timeoutSeconds !== undefined) {
912
+ task.timeoutHandle = setTimeout(() => {
913
+ if (task.status !== 'running') return;
914
+ task.killKind = 'timeout';
915
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
916
+ this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
917
+ try {
918
+ this.requestKill(task, 'SIGTERM');
919
+ } catch (error) {
920
+ void this.finalizeTask(
921
+ task,
922
+ 'failed',
923
+ null,
924
+ undefined,
925
+ `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
926
+ );
927
+ }
928
+ }, timeoutSeconds * 1000);
929
+ }
930
+
931
+ await this.writeMetadata(task);
932
+ this.onChange();
933
+ return task;
934
+ } catch (error) {
935
+ const message = error instanceof Error ? error.message : String(error);
936
+ this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
937
+ await this.finalizeTask(task, 'failed', null, undefined, message);
938
+ throw new Error(`Failed to start background task: ${message}`);
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Track an in-process asynchronous workflow through the same durable task,
944
+ * notification, status, log, and cancellation surfaces as child processes.
945
+ * The supplied completion promise must own all workflow cleanup before it
946
+ * settles; terminal publication happens only after that settlement.
947
+ */
948
+ async startManagedTask(
949
+ ctx: BackgroundTaskContext,
950
+ request: StartManagedTaskOptions,
951
+ ): Promise<BgTask> {
952
+ if (this.shuttingDown)
953
+ throw new Error('Cannot start a managed background task while Pi is shutting down');
954
+ if (!/^[a-zA-Z0-9_.-]+$/u.test(request.id))
955
+ throw new Error(`Managed background task id is invalid: ${request.id}`);
956
+ if (this.tasks.has(request.id))
957
+ throw new Error(`Background task id already exists: ${request.id}`);
958
+
959
+ const dir = await this.ensureRuntimeDir(ctx);
960
+ const outputAbsPath = join(dir.abs, `${request.id}.output`);
961
+ const metadataAbsPath = join(dir.abs, `${request.id}.json`);
962
+ const outputPath = join(dir.display, `${request.id}.output`);
963
+ const task: BgTask = {
964
+ id: request.id,
965
+ name: normalizeTaskName(request.name) ?? 'Managed background task',
966
+ command: request.command,
967
+ description: request.description,
968
+ status: 'running',
969
+ outputPath,
970
+ outputAbsPath,
971
+ metadataAbsPath,
972
+ cwd: ctx.cwd,
973
+ startTime: this.now(),
974
+ exitCode: undefined,
975
+ pid: undefined,
976
+ bytesWritten: 0,
977
+ isAgent: request.isAgent,
978
+ notified: false,
979
+ notifyOnCompletion: request.notifyOnCompletion,
980
+ triggerOnCompletion: request.triggerOnCompletion,
981
+ fusion: request.fusion,
982
+ managedCancel: request.cancel,
983
+ managedStopWaitMs: request.stopWaitMs,
984
+ terminalPublicationGate: request.terminalPublicationGate,
985
+ waiters: [],
986
+ };
987
+ this.tasks.set(task.id, task);
988
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
989
+ task.stream = stream;
990
+ stream.on('error', (error) => {
991
+ task.error = `Output file write failed: ${error.message}`;
992
+ if (task.status === 'running' && !task.managedCancelRequested) {
993
+ task.managedCancelRequested = true;
994
+ try {
995
+ request.cancel();
996
+ } catch (cancelError) {
997
+ task.error = `${task.error}; cancellation failed: ${BackgroundTaskRegistry.errorMessage(cancelError)}`;
998
+ }
999
+ }
1000
+ });
1001
+
1002
+ try {
1003
+ await this.writeMetadata(task);
1004
+ this.onChange();
1005
+ } catch (error) {
1006
+ this.tasks.delete(task.id);
1007
+ if (!stream.destroyed) stream.destroy();
1008
+ try {
1009
+ request.cancel();
1010
+ } catch (cancelError) {
1011
+ this.logger.error(
1012
+ `[background-tasks] managed task cancellation after metadata failure also failed for ${task.id}:`,
1013
+ cancelError,
1014
+ );
1015
+ }
1016
+ throw new Error(
1017
+ `Failed to register managed background task: ${BackgroundTaskRegistry.errorMessage(error)}`,
1018
+ );
1019
+ }
1020
+
1021
+ void request.completion
1022
+ .then(
1023
+ () => this.finalizeTask(task, 'completed', 0),
1024
+ (error: unknown) => {
1025
+ const message = BackgroundTaskRegistry.errorMessage(error);
1026
+ const killed = task.killKind === 'user' || task.killKind === 'shutdown';
1027
+ return this.finalizeTask(task, killed ? 'killed' : 'failed', null, undefined, message);
1028
+ },
1029
+ )
1030
+ .catch((error: unknown) => {
1031
+ this.logger.error(
1032
+ `[background-tasks] managed task finalization failed for ${task.id}:`,
1033
+ error,
1034
+ );
1035
+ });
1036
+ return task;
1037
+ }
1038
+
1039
+ async updateManagedTask(task: BgTask, state: string, line?: string): Promise<void> {
1040
+ if (task.status !== 'running' || task.fusion === undefined) return;
1041
+ task.fusion.state = state;
1042
+ if (line !== undefined && line.length > 0) this.writeNotice(task, `${line}\n`);
1043
+ await this.writeMetadata(task);
1044
+ this.onChange();
1045
+ }
1046
+
1047
+ /** Claim deferred Fusion usage exactly once before returning it from bg_result. */
1048
+ async claimFusionUsage(task: BgTask): Promise<boolean> {
1049
+ if (task.fusion === undefined) throw new Error(`Task ${task.id} is not a Fusion task`);
1050
+ let claimed = false;
1051
+ const write = async () => {
1052
+ if (!task.fusion || task.fusion.usageDelivered) return;
1053
+ task.fusion.usageDelivered = true;
1054
+ await writeJsonAtomic(task.metadataAbsPath, snapshot(task));
1055
+ claimed = true;
1056
+ };
1057
+ const previous = task.metadataWriteChain ?? Promise.resolve();
1058
+ const next = previous.then(write, write);
1059
+ task.metadataWriteChain = next.catch(() => undefined);
1060
+ await next;
1061
+ return claimed;
1062
+ }
1063
+
1064
+ /**
1065
+ * Start a prepared delegate child.
1066
+ *
1067
+ * The caller has already completed preflight, so by the time this runs the
1068
+ * seed, budget plan, and artifact directory exist and the argv is fixed. The
1069
+ * child is launched directly, never through a shell, and its terminal state
1070
+ * flows through the same durable notification path as `bg_run`.
1071
+ */
1072
+ async startDelegateTask(
1073
+ ctx: BackgroundTaskContext,
1074
+ request: StartDelegateTaskOptions,
1075
+ ): Promise<BgTask> {
1076
+ if (this.shuttingDown)
1077
+ throw new Error('Cannot start a delegate task while Pi is shutting down');
1078
+
1079
+ const launch = resolvePiLaunch({ platform: this.platform });
1080
+ assertWindowsCommandLineWithinLimit(launch, request.argv, this.platform, 'bg-delegate');
1081
+
1082
+ const dir = await this.ensureRuntimeDir(ctx);
1083
+ const id = request.facts.taskId;
1084
+ const outputAbsPath = join(dir.abs, `${id}.output`);
1085
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
1086
+ const outputPath = join(dir.display, `${id}.output`);
1087
+
1088
+ const task: BgTask = {
1089
+ id,
1090
+ name: normalizeTaskName(request.name) ?? 'Delegate task',
1091
+ command: ['pi', ...request.argv].map(shellQuote).join(' '),
1092
+ status: 'running',
1093
+ outputPath,
1094
+ outputAbsPath,
1095
+ metadataAbsPath,
1096
+ cwd: ctx.cwd,
1097
+ startTime: this.now(),
1098
+ exitCode: undefined,
1099
+ pid: undefined,
1100
+ bytesWritten: 0,
1101
+ isAgent: true,
1102
+ notified: false,
1103
+ notifyOnCompletion: request.notifyOnCompletion,
1104
+ triggerOnCompletion: request.triggerOnCompletion,
1105
+ timeoutSeconds: request.timeoutSeconds,
1106
+ model: request.facts.route.qualifiedId,
1107
+ delegate: request.facts,
1108
+ waiters: [],
1109
+ };
1110
+ this.tasks.set(id, task);
1111
+
1112
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1113
+ task.stream = stream;
1114
+ stream.on('error', (error) => {
1115
+ task.error = `Output file write failed: ${error.message}`;
1116
+ });
1117
+
1118
+ try {
1119
+ const child = this.spawn(launch.executable, piLaunchArgv(launch, [...request.argv]), {
1120
+ cwd: ctx.cwd,
1121
+ detached: this.platform !== 'win32',
1122
+ shell: false,
1123
+ // The seed travels over stdin, never as a shell or positional argument,
1124
+ // so the bytes the child reads are exactly the bytes that were persisted
1125
+ // and hashed, with no quoting or command-line length limit in the path.
1126
+ stdio: ['pipe', 'pipe', 'pipe'],
1127
+ env: request.env,
1128
+ windowsHide: true,
1129
+ });
1130
+ task.child = child;
1131
+ task.pid = child.pid;
1132
+ writeDelegateStdin(child, request.stdinBytes, (error) => {
1133
+ this.writeNotice(task, `\n[delegate stdin write failed: ${error.message}]\n`);
1134
+ if (task.status === 'running') {
1135
+ task.killKind = 'user';
1136
+ task.error = `Delegate seed could not be delivered: ${error.message}`;
1137
+ try {
1138
+ this.requestKill(task, 'SIGTERM');
1139
+ } catch {
1140
+ void this.finalizeTask(task, 'failed', null, undefined, task.error);
1141
+ }
1142
+ }
1143
+ });
1144
+
1145
+ child.stdout?.on('data', (data) => {
1146
+ this.appendChildOutput(task, data, 'stdout');
1147
+ });
1148
+ child.stderr?.on('data', (data) => {
1149
+ this.appendChildOutput(task, data, 'stderr');
1150
+ });
1151
+ child.on('error', (error) => {
1152
+ this.writeNotice(task, `\n[delegate spawn error: ${error.message}]\n`);
1153
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
1154
+ });
1155
+ child.on('close', (code, signalName) => {
1156
+ let status: TaskStatus;
1157
+ let error: string | undefined;
1158
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
1159
+ status = 'killed';
1160
+ } else if (task.killKind === 'timeout') {
1161
+ status = 'failed';
1162
+ error = task.error ?? `Timed out after ${String(request.timeoutSeconds ?? 0)}s`;
1163
+ } else if ((code ?? 0) === 0) {
1164
+ status = 'completed';
1165
+ } else {
1166
+ status = 'failed';
1167
+ error = `Exited with code ${code === null ? 'null' : String(code)}${signalName ? ` (${signalName})` : ''}`;
1168
+ }
1169
+ void this.finalizeTask(task, status, code, signalName, error);
1170
+ });
1171
+
1172
+ if (request.timeoutSeconds !== undefined) {
1173
+ task.timeoutHandle = setTimeout(() => {
1174
+ if (task.status !== 'running') return;
1175
+ task.killKind = 'timeout';
1176
+ task.error = `Timed out after ${String(request.timeoutSeconds)}s`;
1177
+ this.writeNotice(task, `\n[delegate timeout: ${task.error}]\n`);
1178
+ try {
1179
+ this.requestKill(task, 'SIGTERM');
1180
+ } catch (error) {
1181
+ void this.finalizeTask(
1182
+ task,
1183
+ 'failed',
1184
+ null,
1185
+ undefined,
1186
+ `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
1187
+ );
1188
+ }
1189
+ }, request.timeoutSeconds * 1000);
1190
+ }
1191
+
1192
+ await this.writeMetadata(task);
1193
+ this.onChange();
1194
+ return task;
1195
+ } catch (error) {
1196
+ const message = error instanceof Error ? error.message : String(error);
1197
+ this.writeNotice(task, `\n[delegate spawn exception: ${message}]\n`);
1198
+ await this.finalizeTask(task, 'failed', null, undefined, message);
1199
+ throw new Error(`Failed to start delegate task: ${message}`);
1200
+ }
1201
+ }
1202
+
1203
+ async startAttestedPiTask(
1204
+ ctx: BackgroundTaskContext,
1205
+ request: StartAttestedPiTaskOptions,
1206
+ ): Promise<BgTask> {
1207
+ if (this.shuttingDown)
1208
+ throw new Error('Cannot start an attested Pi task while Pi is shutting down');
1209
+
1210
+ const attributionExtensionPath =
1211
+ request.provider === 'anthropic' ? resolveAnthropicAttributionExtensionPath() : undefined;
1212
+ const argv = buildAttestedPiArgv(request, attributionExtensionPath);
1213
+ const attestedPiLaunch = resolvePiLaunch({ platform: this.platform });
1214
+ assertWindowsCommandLineWithinLimit(
1215
+ attestedPiLaunch,
1216
+ argv.slice(1),
1217
+ this.platform,
1218
+ 'attested-pi-run',
1219
+ );
1220
+
1221
+ const dir = await this.ensureRuntimeDir(ctx);
1222
+ const id = makeAttestedTaskId();
1223
+ if (!ATTESTED_TASK_ID_PATTERN.test(id))
1224
+ throw new Error('Generated attested task id is invalid');
1225
+ const paths = makeAttestedTaskPaths(dir.abs, dir.display, id);
1226
+ const promptBytes = Buffer.from(request.prompt, 'utf8');
1227
+ const reportAbsPath = await resolveReportPath(ctx.cwd, request.reportPath);
1228
+ const auth = observePiOAuth(ctx, request.provider, request.model);
1229
+ const repoRootRealpath = await gitRepoRoot(ctx.cwd);
1230
+ const cwdRealpath = await realpath(ctx.cwd);
1231
+ const startAuthority = await gitAuthoritySnapshot(ctx.cwd);
1232
+ if (!startAuthority.clean)
1233
+ throw new Error('Attested Pi task requires a clean worktree at start');
1234
+ const timeoutSeconds =
1235
+ typeof request.timeoutSeconds === 'number' &&
1236
+ Number.isFinite(request.timeoutSeconds) &&
1237
+ request.timeoutSeconds > 0
1238
+ ? Math.floor(request.timeoutSeconds)
1239
+ : undefined;
1240
+
1241
+ const task: BgTask = {
1242
+ id,
1243
+ name: normalizeTaskName(request.name) ?? 'Attested Pi task',
1244
+ command: argv.map(shellQuote).join(' '),
1245
+ status: 'running',
1246
+ outputPath: paths.outputPath,
1247
+ outputAbsPath: paths.outputAbsPath,
1248
+ metadataAbsPath: paths.metadataAbsPath,
1249
+ eventsAbsPath: paths.eventsAbsPath,
1250
+ stderrAbsPath: paths.stderrAbsPath,
1251
+ wrapperAbsPath: paths.wrapperAbsPath,
1252
+ attestationAbsPath: paths.attestationAbsPath,
1253
+ cwd: ctx.cwd,
1254
+ startTime: this.now(),
1255
+ exitCode: undefined,
1256
+ pid: undefined,
1257
+ bytesWritten: 0,
1258
+ isAgent: true,
1259
+ notified: false,
1260
+ notifyOnCompletion: false,
1261
+ triggerOnCompletion: false,
1262
+ timeoutSeconds,
1263
+ attestationPath: paths.attestationPath,
1264
+ attestedPi: {
1265
+ eventsPath: paths.eventsPath,
1266
+ stderrPath: paths.stderrPath,
1267
+ wrapperPath: paths.wrapperPath,
1268
+ attestationPath: paths.attestationPath,
1269
+ },
1270
+ waiters: [],
1271
+ };
1272
+ this.tasks.set(id, task);
1273
+
1274
+ await writeFileFsynced(paths.outputAbsPath, '');
1275
+ await writeFileFsynced(paths.eventsAbsPath, '');
1276
+ await writeFileFsynced(paths.stderrAbsPath, '');
1277
+ await writeFileFsynced(
1278
+ paths.wrapperAbsPath,
1279
+ 'direct-spawn attested Pi task; no shell telemetry wrapper is used\n',
1280
+ );
1281
+ await this.writeMetadata(task);
1282
+
1283
+ const captured = spawnAndCapturePi(
1284
+ this.spawn,
1285
+ argv,
1286
+ {
1287
+ cwd: ctx.cwd,
1288
+ detached: this.platform !== 'win32',
1289
+ shell: false,
1290
+ stdio: ['ignore', 'pipe', 'pipe'],
1291
+ env: attestedPiChildEnv(this.env),
1292
+ windowsHide: true,
1293
+ },
1294
+ this.platform,
1295
+ attestedPiLaunch,
1296
+ );
1297
+ task.child = captured.child;
1298
+ task.pid = captured.child.pid;
1299
+ await this.writeMetadata(task);
1300
+ this.onChange();
1301
+
1302
+ captured.child.on('error', (error) => {
1303
+ void this.finalizeAttestedPiTask(
1304
+ task,
1305
+ paths,
1306
+ argv,
1307
+ cwdRealpath,
1308
+ repoRootRealpath,
1309
+ startAuthority,
1310
+ auth,
1311
+ promptBytes,
1312
+ reportAbsPath,
1313
+ captured.stdoutChunks,
1314
+ captured.stderrChunks,
1315
+ 'failed',
1316
+ null,
1317
+ null,
1318
+ error.message,
1319
+ );
1320
+ });
1321
+
1322
+ captured.child.on('close', (code, signalName) => {
1323
+ let status: TaskStatus = (code ?? 0) === 0 && signalName === null ? 'completed' : 'failed';
1324
+ let error: string | undefined;
1325
+ if (task.killKind === 'timeout') {
1326
+ status = 'failed';
1327
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
1328
+ } else if (task.killKind === 'user' || task.killKind === 'shutdown') {
1329
+ status = 'killed';
1330
+ error = task.error;
1331
+ } else if (status === 'failed') {
1332
+ const exitCode = code === null ? 'null' : String(code);
1333
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
1334
+ }
1335
+ void this.finalizeAttestedPiTask(
1336
+ task,
1337
+ paths,
1338
+ argv,
1339
+ cwdRealpath,
1340
+ repoRootRealpath,
1341
+ startAuthority,
1342
+ auth,
1343
+ promptBytes,
1344
+ reportAbsPath,
1345
+ captured.stdoutChunks,
1346
+ captured.stderrChunks,
1347
+ status,
1348
+ code,
1349
+ signalName,
1350
+ error,
1351
+ );
1352
+ });
1353
+
1354
+ if (timeoutSeconds !== undefined) {
1355
+ task.timeoutHandle = setTimeout(() => {
1356
+ if (task.status !== 'running') return;
1357
+ task.killKind = 'timeout';
1358
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
1359
+ try {
1360
+ this.requestKill(task, 'SIGTERM');
1361
+ } catch (error) {
1362
+ void this.finalizeAttestedPiTask(
1363
+ task,
1364
+ paths,
1365
+ argv,
1366
+ cwdRealpath,
1367
+ repoRootRealpath,
1368
+ startAuthority,
1369
+ auth,
1370
+ promptBytes,
1371
+ reportAbsPath,
1372
+ captured.stdoutChunks,
1373
+ captured.stderrChunks,
1374
+ 'failed',
1375
+ null,
1376
+ null,
1377
+ error instanceof Error ? error.message : String(error),
1378
+ );
1379
+ }
1380
+ }, timeoutSeconds * 1000);
1381
+ }
1382
+
1383
+ return task;
1384
+ }
1385
+
1386
+ private async finalizeAttestedPiTask(
1387
+ task: BgTask,
1388
+ paths: ReturnType<typeof makeAttestedTaskPaths>,
1389
+ argv: string[],
1390
+ cwdRealpath: string,
1391
+ repoRootRealpath: string,
1392
+ startAuthority: Awaited<ReturnType<typeof gitAuthoritySnapshot>>,
1393
+ auth: ReturnType<typeof observePiOAuth>,
1394
+ promptBytes: Buffer,
1395
+ reportAbsPath: string,
1396
+ stdoutChunks: Buffer[],
1397
+ stderrChunks: Buffer[],
1398
+ status: TaskStatus,
1399
+ exitCode: number | null,
1400
+ signal: NodeJS.Signals | null,
1401
+ error?: string,
1402
+ ): Promise<void> {
1403
+ if (task.finalized) return;
1404
+ task.finalized = true;
1405
+ if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
1406
+ if (task.killEscalationTimer !== undefined) {
1407
+ clearTimeout(task.killEscalationTimer);
1408
+ task.killEscalationTimer = undefined;
1409
+ }
1410
+ let finalStatus = status;
1411
+ let finalError = error;
1412
+ const forceFailure = await this.awaitWindowsForceBeforeTerminal(task);
1413
+ if (forceFailure !== undefined) {
1414
+ finalStatus = 'failed';
1415
+ finalError = BackgroundTaskRegistry.appendTaskError(finalError, forceFailure.message);
1416
+ }
1417
+ task.exitCode = exitCode;
1418
+ task.signal = signal;
1419
+ task.endTime = this.now();
1420
+ if (finalError) task.error = finalError;
1421
+
1422
+ const rawEvents = Buffer.concat(stdoutChunks);
1423
+ const rawStderr = Buffer.concat(stderrChunks);
1424
+ await writeFileFsynced(paths.eventsAbsPath, rawEvents);
1425
+ await writeFileFsynced(paths.stderrAbsPath, rawStderr);
1426
+
1427
+ let parsed: ReturnType<typeof parsePiJsonEvents> | undefined;
1428
+ if (finalStatus === 'completed') {
1429
+ try {
1430
+ parsed = parsePiJsonEvents(rawEvents);
1431
+ task.model = parsed.providerScopedModelId;
1432
+ task.tokenUsage = {
1433
+ input: parsed.tokenUsage.input,
1434
+ output: parsed.tokenUsage.output,
1435
+ cacheRead: parsed.tokenUsage.cacheRead,
1436
+ cacheWrite: parsed.tokenUsage.cacheWrite,
1437
+ totalTokens: parsed.tokenUsage.totalTokens,
1438
+ };
1439
+ if (parsed.tokenUsage.costTotal !== undefined)
1440
+ task.tokenUsage.costTotal = parsed.tokenUsage.costTotal;
1441
+ task.toolUsage = parsed.toolUsage;
1442
+ const outputBytes = Buffer.from(parsed.humanTranscript, 'utf8');
1443
+ task.bytesWritten = outputBytes.length;
1444
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1445
+ } catch (parseError) {
1446
+ finalStatus = 'failed';
1447
+ task.error = parseError instanceof Error ? parseError.message : String(parseError);
1448
+ const outputBytes = Buffer.from(`[attested Pi task error: ${task.error}]\n`, 'utf8');
1449
+ task.bytesWritten = outputBytes.length;
1450
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1451
+ }
1452
+ } else {
1453
+ const outputBytes = Buffer.from(rawStderr.toString('utf8'), 'utf8');
1454
+ task.bytesWritten = outputBytes.length;
1455
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1456
+ }
1457
+
1458
+ try {
1459
+ if (finalStatus === 'completed' && parsed) {
1460
+ const finishAuthority = await gitAuthoritySnapshot(task.cwd);
1461
+ const completedSnapshot: BgTaskSnapshot = { ...snapshot(task), status: 'completed' };
1462
+ await this.writeMetadataSnapshot(task, completedSnapshot);
1463
+ const attestation = await buildPiTaskAttestation({
1464
+ task: completedSnapshot,
1465
+ paths,
1466
+ sessionDir: dirNameFromDisplay(paths.outputPath),
1467
+ argv,
1468
+ cwdRealpath,
1469
+ repoRootRealpath,
1470
+ startAuthority,
1471
+ finishAuthority,
1472
+ parsedEvents: parsed,
1473
+ auth,
1474
+ prompt: promptBytes,
1475
+ reportAbsPath,
1476
+ });
1477
+ await writeJsonAtomic(paths.attestationAbsPath, attestation);
1478
+ } else {
1479
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
1480
+ }
1481
+ } catch (attestationError) {
1482
+ finalStatus = 'failed';
1483
+ task.error =
1484
+ attestationError instanceof Error ? attestationError.message : String(attestationError);
1485
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: 'failed' }).catch(
1486
+ (metadataError: unknown) => {
1487
+ this.logger.error(
1488
+ `[background-tasks] failed to write failed attested metadata for ${task.id}:`,
1489
+ metadataError,
1490
+ );
1491
+ },
1492
+ );
1493
+ }
1494
+
1495
+ task.status = finalStatus;
1496
+ for (const waiter of task.waiters.splice(0)) waiter();
1497
+ this.onChange();
1498
+ this.publishTerminal(task);
1499
+ this.pruneOldTasks();
1500
+ }
1501
+
1502
+ resolveTask(idOrPrefix: string): BgTask {
1503
+ const id = idOrPrefix.trim();
1504
+ if (!id) throw new Error('Task ID is required');
1505
+ const exact = this.tasks.get(id);
1506
+ if (exact) return exact;
1507
+ const matches = [...this.tasks.values()].filter((task) => task.id.startsWith(id));
1508
+ const onlyMatch = matches[0];
1509
+ if (matches.length === 1 && onlyMatch) return onlyMatch;
1510
+ if (matches.length > 1)
1511
+ throw new Error(
1512
+ `Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(', ')}`,
1513
+ );
1514
+ throw new Error(`Unknown background task ID: ${id}`);
1515
+ }
1516
+
1517
+ async stopTask(task: BgTask, kind: KillKind, reason?: string): Promise<BgTask> {
1518
+ if (task.status !== 'running') {
1519
+ throw new Error(`Task ${task.id} is ${task.status}, not running`);
1520
+ }
1521
+ task.killKind = kind;
1522
+ if (reason) task.error = reason;
1523
+ this.requestKill(task, 'SIGTERM');
1524
+ const stopWaitMs = task.managedStopWaitMs ?? this.stopWaitMs;
1525
+ const stopped =
1526
+ this.platform === 'win32' && task.managedCancel === undefined
1527
+ ? await this.waitForEndOrWindowsForceFailure(task, stopWaitMs)
1528
+ : await this.waitForEnd(task, stopWaitMs);
1529
+ const forceFailure = this.windowsKillStates.get(task)?.forceFailure;
1530
+ if (forceFailure !== undefined) throw forceFailure;
1531
+ if (!stopped) {
1532
+ throw new Error(
1533
+ `Task ${task.id} did not exit within ${formatDuration(stopWaitMs)} after cancellation`,
1534
+ );
1535
+ }
1536
+ return task;
1537
+ }
1538
+
1539
+ async stopAllRunning(
1540
+ kind: KillKind,
1541
+ reason?: string,
1542
+ ): Promise<{ stopped: number; failures: string[] }> {
1543
+ const running = this.allTasks().filter((task) => task.status === 'running');
1544
+ const failures: string[] = [];
1545
+ let stopped = 0;
1546
+ await Promise.all(
1547
+ running.map(async (task) => {
1548
+ try {
1549
+ await this.stopTask(task, kind, reason);
1550
+ stopped++;
1551
+ } catch (error) {
1552
+ failures.push(
1553
+ `${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`,
1554
+ );
1555
+ }
1556
+ }),
1557
+ );
1558
+ return { stopped, failures };
1559
+ }
1560
+
1561
+ async getTaskLogs(
1562
+ task: BgTask,
1563
+ maxBytes: number,
1564
+ tail: boolean,
1565
+ ): Promise<{ text: string; details: BgLogsDetails }> {
1566
+ if (!existsSync(task.outputAbsPath)) {
1567
+ throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
1568
+ }
1569
+ const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
1570
+ const direction = tail ? 'tail' : 'head';
1571
+ let text = read.content.length > 0 ? read.content : '(no output yet)';
1572
+ if (read.truncated) {
1573
+ const omitted = read.totalBytes - read.bytesRead;
1574
+ const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
1575
+ text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
1576
+ } else {
1577
+ text += `\n\n[Full output: ${task.outputPath}]`;
1578
+ }
1579
+ return {
1580
+ text,
1581
+ details: {
1582
+ task: snapshot(task),
1583
+ path: task.outputPath,
1584
+ bytesRead: read.bytesRead,
1585
+ truncated: read.truncated,
1586
+ tail,
1587
+ },
1588
+ };
1589
+ }
1590
+
1591
+ private async writeMetadata(task: BgTask): Promise<void> {
1592
+ await this.writeMetadataSnapshot(task, snapshot(task));
1593
+ }
1594
+
1595
+ private async writeMetadataSnapshot(task: BgTask, value: BgTaskSnapshot): Promise<void> {
1596
+ const write = async () => {
1597
+ await writeJsonAtomic(task.metadataAbsPath, value);
1598
+ };
1599
+ const previous = task.metadataWriteChain ?? Promise.resolve();
1600
+ const next = previous.then(write, write);
1601
+ task.metadataWriteChain = next.catch(() => undefined);
1602
+ await next;
1603
+ }
1604
+
1605
+ private ingestTelemetry(task: BgTask, text: string): void {
1606
+ if (!text) return;
1607
+ const telemetryText = `${task.contextUsageBuffer ?? ''}${text}`;
1608
+ let latestContext = task.contextUsage;
1609
+ let latestTokens = task.tokenUsage;
1610
+ let latestTools = task.toolUsage;
1611
+ let latestModel = task.model;
1612
+ for (const line of telemetryText.split(/\r?\n/)) {
1613
+ if (!line.includes('background-task-')) continue;
1614
+ const trimmed = line.trim();
1615
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
1616
+ try {
1617
+ const parsed = parseJsonText(trimmed);
1618
+ if (!isJsonObject(parsed)) continue;
1619
+ const payload: TelemetryControlPayload = parsed;
1620
+ if (payload.type === 'background-task-context-usage') {
1621
+ latestContext = normalizeContextUsage(payload) ?? latestContext;
1622
+ } else if (payload.type === 'background-task-telemetry') {
1623
+ latestContext = normalizeContextUsage(payload.contextUsage) ?? latestContext;
1624
+ latestTokens = normalizeTokenUsage(payload.tokenUsage) ?? latestTokens;
1625
+ latestTools = normalizeToolUsage(payload.toolUsage) ?? latestTools;
1626
+ latestModel = normalizeModel(payload.model) ?? latestModel;
1627
+ }
1628
+ } catch {
1629
+ // Ignore malformed optional telemetry; task output remains authoritative for debugging.
1630
+ }
1631
+ }
1632
+ }
1633
+ const xmlMatches = telemetryText.matchAll(
1634
+ /<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi,
1635
+ );
1636
+ for (const match of xmlMatches) latestContext = parseContextUsageXml(match[0]) ?? latestContext;
1637
+
1638
+ const lastNewline = Math.max(telemetryText.lastIndexOf('\n'), telemetryText.lastIndexOf('\r'));
1639
+ let retained = lastNewline >= 0 ? telemetryText.slice(lastNewline + 1) : telemetryText;
1640
+ const lastXmlOpen = telemetryText.toLowerCase().lastIndexOf('<background-task-context-usage');
1641
+ const lastXmlClose = telemetryText
1642
+ .toLowerCase()
1643
+ .lastIndexOf('</background-task-context-usage>');
1644
+ if (lastXmlOpen > lastXmlClose) retained = telemetryText.slice(lastXmlOpen);
1645
+ task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
1646
+
1647
+ this.commitTelemetry(task, {
1648
+ context: latestContext,
1649
+ tokens: latestTokens,
1650
+ tools: latestTools,
1651
+ model: latestModel,
1652
+ });
1653
+ }
1654
+
1655
+ /** Apply the latest parsed telemetry to a task, persisting metadata and notifying the UI only on change. */
1656
+ private commitTelemetry(task: BgTask, next: TelemetryDelta): void {
1657
+ const before = JSON.stringify({
1658
+ contextUsage: task.contextUsage,
1659
+ tokenUsage: task.tokenUsage,
1660
+ toolUsage: task.toolUsage,
1661
+ model: task.model,
1662
+ });
1663
+ if (next.context !== undefined) task.contextUsage = next.context;
1664
+ if (next.tokens !== undefined) task.tokenUsage = next.tokens;
1665
+ if (next.tools !== undefined) task.toolUsage = next.tools;
1666
+ if (next.model !== undefined) task.model = next.model;
1667
+ const after = JSON.stringify({
1668
+ contextUsage: task.contextUsage,
1669
+ tokenUsage: task.tokenUsage,
1670
+ toolUsage: task.toolUsage,
1671
+ model: task.model,
1672
+ });
1673
+ if (before !== after) {
1674
+ this.onChange();
1675
+ void this.writeMetadata(task).catch((error: unknown) => {
1676
+ this.logger.error(
1677
+ `[background-tasks] failed to write telemetry metadata for ${task.id}:`,
1678
+ error,
1679
+ );
1680
+ });
1681
+ }
1682
+ }
1683
+
1684
+ /** Cap-enforcing sink for all persisted task output; terminates the task once the byte cap is exceeded. */
1685
+ private writeToStream(task: BgTask, buffer: Buffer): void {
1686
+ if (!task.stream || task.stream.destroyed) return;
1687
+ if (buffer.length === 0) return;
1688
+
1689
+ const nextBytes = task.bytesWritten + buffer.length;
1690
+ if (nextBytes <= this.maxOutputBytes) {
1691
+ task.stream.write(buffer);
1692
+ task.bytesWritten = nextBytes;
1693
+ return;
1694
+ }
1695
+
1696
+ const remaining = Math.max(0, this.maxOutputBytes - task.bytesWritten);
1697
+ if (remaining > 0) {
1698
+ task.stream.write(buffer.subarray(0, remaining));
1699
+ task.bytesWritten += remaining;
1700
+ }
1701
+
1702
+ if (!task.capExceeded) {
1703
+ task.capExceeded = true;
1704
+ task.error = `Output exceeded cap of ${formatSize(this.maxOutputBytes)}; terminating task`;
1705
+ const notice = `\n\n[background task error: ${task.error}]\n`;
1706
+ task.stream.write(notice);
1707
+ task.bytesWritten += Buffer.byteLength(notice, 'utf8');
1708
+ task.killKind = 'output_cap';
1709
+ try {
1710
+ this.requestKill(task, 'SIGTERM');
1711
+ } catch (error) {
1712
+ task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
1713
+ void this.finalizeTask(task, 'failed', null, undefined, task.error);
1714
+ }
1715
+ }
1716
+ }
1717
+
1718
+ /** Persist an internally generated notice (spawn/timeout/cap diagnostics) verbatim. */
1719
+ private writeNotice(task: BgTask, text: string): void {
1720
+ if (!text) return;
1721
+ this.writeToStream(task, Buffer.from(text, 'utf8'));
1722
+ }
1723
+
1724
+ private appendChildOutput(
1725
+ task: BgTask,
1726
+ data: Buffer | string,
1727
+ source: 'stdout' | 'stderr',
1728
+ ): void {
1729
+ if (!task.stream || task.stream.destroyed) return;
1730
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
1731
+ if (buffer.length === 0) return;
1732
+ if (task.telemetryWrapped) {
1733
+ // Wrapped Pi agents stream control lines on stdout (telemetry + activity); child
1734
+ // stderr is raw diagnostics and is always passed through to the transcript verbatim.
1735
+ if (source === 'stdout') this.processAgentStdout(task, buffer.toString('utf8'));
1736
+ else this.writeToStream(task, buffer);
1737
+ return;
1738
+ }
1739
+ this.ingestTelemetry(task, buffer.toString('utf8'));
1740
+ this.writeToStream(task, buffer);
1741
+ }
1742
+
1743
+ /** Reconstruct wrapped-agent stdout into whole control lines, routing telemetry to metrics and activity to the transcript. */
1744
+ private processAgentStdout(task: BgTask, text: string): void {
1745
+ const buffered = `${task.agentStdoutBuffer ?? ''}${text}`;
1746
+ const lastNewline = buffered.lastIndexOf('\n');
1747
+ task.agentStdoutBuffer = lastNewline >= 0 ? buffered.slice(lastNewline + 1) : buffered;
1748
+ if (lastNewline < 0) return;
1749
+ const latest: TelemetryDelta = {};
1750
+ for (const line of buffered.slice(0, lastNewline).split('\n'))
1751
+ this.consumeAgentLine(task, line, latest);
1752
+ this.commitTelemetry(task, latest);
1753
+ }
1754
+
1755
+ /** Flush a trailing partial wrapped-agent line on finalize so the last transcript fragment is never lost. */
1756
+ private flushAgentStdout(task: BgTask): void {
1757
+ const remainder = task.agentStdoutBuffer;
1758
+ if (!remainder) return;
1759
+ task.agentStdoutBuffer = '';
1760
+ const latest: TelemetryDelta = {};
1761
+ this.consumeAgentLine(task, remainder, latest);
1762
+ this.commitTelemetry(task, latest);
1763
+ }
1764
+
1765
+ private consumeAgentLine(task: BgTask, rawLine: string, latest: TelemetryDelta): void {
1766
+ const line = rawLine.replace(/\r$/, '');
1767
+ const trimmed = line.trim();
1768
+ if (!trimmed) return;
1769
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
1770
+ this.writeNotice(task, `${line}\n`);
1771
+ return;
1772
+ }
1773
+ let parsed: unknown;
1774
+ try {
1775
+ parsed = parseJsonText(trimmed);
1776
+ } catch {
1777
+ this.writeNotice(task, `${line}\n`);
1778
+ return;
1779
+ }
1780
+ if (!isJsonObject(parsed)) {
1781
+ this.writeNotice(task, `${line}\n`);
1782
+ return;
1783
+ }
1784
+ const record: TelemetryControlPayload = parsed;
1785
+ const type = record.type;
1786
+ if (type === 'background-task-context-usage') {
1787
+ const context = normalizeContextUsage(record);
1788
+ if (context) latest.context = context;
1789
+ return;
1790
+ }
1791
+ if (type === 'background-task-telemetry') {
1792
+ const context = normalizeContextUsage(record.contextUsage);
1793
+ if (context) latest.context = context;
1794
+ const tokens = normalizeTokenUsage(record.tokenUsage);
1795
+ if (tokens) latest.tokens = tokens;
1796
+ const tools = normalizeToolUsage(record.toolUsage);
1797
+ if (tools) latest.tools = tools;
1798
+ const model = normalizeModel(record.model);
1799
+ if (model) latest.model = model;
1800
+ return;
1801
+ }
1802
+ const activity = parseAgentActivity(parsed);
1803
+ if (activity) {
1804
+ const formatted = formatAgentActivityLine(activity);
1805
+ if (formatted) this.writeNotice(task, `${formatted}\n`);
1806
+ return;
1807
+ }
1808
+ // Unknown JSON object: pass through to the transcript rather than silently dropping it.
1809
+ this.writeNotice(task, `${line}\n`);
1810
+ }
1811
+
1812
+ private getWindowsKillState(task: BgTask): WindowsKillState {
1813
+ let state = this.windowsKillStates.get(task);
1814
+ if (state === undefined) {
1815
+ state = {};
1816
+ this.windowsKillStates.set(task, state);
1817
+ }
1818
+ return state;
1819
+ }
1820
+
1821
+ private static errorMessage(error: unknown): string {
1822
+ return error instanceof Error ? error.message : String(error);
1823
+ }
1824
+
1825
+ private static appendTaskError(existing: string | undefined, next: string): string {
1826
+ if (existing === undefined || existing.length === 0) return next;
1827
+ if (existing.includes(next)) return existing;
1828
+ return `${existing}; ${next}`;
1829
+ }
1830
+
1831
+ private static describeTaskkillOutcome(outcome: TaskkillOutcome): string {
1832
+ const exitCode = outcome.exitCode === null ? 'null' : String(outcome.exitCode);
1833
+ const signal = outcome.signal === null ? 'null' : outcome.signal;
1834
+ const stdout = outcome.stdout.length > 0 ? ` stdout=${JSON.stringify(outcome.stdout)}` : '';
1835
+ const stderr = outcome.stderr.length > 0 ? ` stderr=${JSON.stringify(outcome.stderr)}` : '';
1836
+ const stdoutTruncated = outcome.stdoutTruncated ? ' stdout_truncated=true' : '';
1837
+ const stderrTruncated = outcome.stderrTruncated ? ' stderr_truncated=true' : '';
1838
+ return `exit=${exitCode} signal=${signal}${stdout}${stderr}${stdoutTruncated}${stderrTruncated}`;
1839
+ }
1840
+
1841
+ private isWindowsTaskkillTerminalRace(task: BgTask): boolean {
1842
+ return task.status !== 'running' || task.finalized === true;
1843
+ }
1844
+
1845
+ private clearKillEscalationTimer(task: BgTask): void {
1846
+ if (task.killEscalationTimer !== undefined) {
1847
+ clearTimeout(task.killEscalationTimer);
1848
+ task.killEscalationTimer = undefined;
1849
+ }
1850
+ }
1851
+
1852
+ private recordWindowsTaskkillNotice(task: BgTask, message: string): void {
1853
+ this.writeNotice(task, `\n[background task Windows termination: ${message}]\n`);
1854
+ }
1855
+
1856
+ private recordWindowsSoftFailure(task: BgTask, pid: number, detail: string): void {
1857
+ const message =
1858
+ `Windows taskkill /T logical termination request failed for task ${task.id} pid ${String(pid)}: ` +
1859
+ `${detail}; force escalation remains scheduled`;
1860
+ task.error = BackgroundTaskRegistry.appendTaskError(task.error, message);
1861
+ this.recordWindowsTaskkillNotice(task, message);
1862
+ this.onChange();
1863
+ void this.writeMetadata(task).catch((metadataError: unknown) => {
1864
+ this.logger.error(
1865
+ `[background-tasks] failed to write Windows taskkill soft-failure metadata for ${task.id}:`,
1866
+ metadataError,
1867
+ );
1868
+ });
1869
+ }
1870
+
1871
+ private makeWindowsForceFailure(task: BgTask, pid: number, detail: string): Error {
1872
+ return new Error(
1873
+ `Windows taskkill /T /F force termination failed for task ${task.id} pid ${String(pid)}: ${detail}. Descendant processes may have leaked.`,
1874
+ );
1875
+ }
1876
+
1877
+ private recordWindowsForceFailure(task: BgTask, error: Error): void {
1878
+ const state = this.getWindowsKillState(task);
1879
+ state.forceFailure = error;
1880
+ task.error = BackgroundTaskRegistry.appendTaskError(task.error, error.message);
1881
+ this.recordWindowsTaskkillNotice(task, error.message);
1882
+ this.onChange();
1883
+ void this.writeMetadata(task).catch((metadataError: unknown) => {
1884
+ this.logger.error(
1885
+ `[background-tasks] failed to write Windows taskkill force-failure metadata for ${task.id}:`,
1886
+ metadataError,
1887
+ );
1888
+ });
1889
+ const listeners = state.forceFailureListeners;
1890
+ if (listeners !== undefined) {
1891
+ delete state.forceFailureListeners;
1892
+ for (const listener of listeners) listener(error);
1893
+ }
1894
+ }
1895
+
1896
+ private evaluateWindowsTaskkillOutcome(
1897
+ task: BgTask,
1898
+ pid: number,
1899
+ phase: WindowsKillPhase,
1900
+ outcome: TaskkillOutcome,
1901
+ ): Error | undefined {
1902
+ if (outcome.exitCode === 0) return undefined;
1903
+ const detail = BackgroundTaskRegistry.describeTaskkillOutcome(outcome);
1904
+ if (outcome.exitCode === 128) {
1905
+ this.recordWindowsTaskkillNotice(
1906
+ task,
1907
+ `taskkill ${phase} reported process not found for pid ${String(pid)} (${detail}); treating as an already-exited race`,
1908
+ );
1909
+ return undefined;
1910
+ }
1911
+ if (this.isWindowsTaskkillTerminalRace(task)) {
1912
+ this.recordWindowsTaskkillNotice(
1913
+ task,
1914
+ `taskkill ${phase} finished after the task became terminal for pid ${String(pid)} (${detail}); treating as a terminal race`,
1915
+ );
1916
+ return undefined;
1917
+ }
1918
+ if (phase === 'terminate') {
1919
+ this.recordWindowsSoftFailure(task, pid, detail);
1920
+ return undefined;
1921
+ }
1922
+ return this.makeWindowsForceFailure(task, pid, detail);
1923
+ }
1924
+
1925
+ private handleWindowsSoftException(
1926
+ task: BgTask,
1927
+ pid: number,
1928
+ error: unknown,
1929
+ state: WindowsKillState,
1930
+ ): void {
1931
+ const message = BackgroundTaskRegistry.errorMessage(error);
1932
+ if (state.forcePromise !== undefined || this.isWindowsTaskkillTerminalRace(task)) return;
1933
+ this.recordWindowsSoftFailure(task, pid, message);
1934
+ }
1935
+
1936
+ private startWindowsSoftKill(task: BgTask, pid: number): Promise<void> {
1937
+ const state = this.getWindowsKillState(task);
1938
+ if (state.softPromise !== undefined) return state.softPromise;
1939
+ const controller = new AbortController();
1940
+ state.softController = controller;
1941
+
1942
+ let launched: Promise<TaskkillOutcome>;
1943
+ try {
1944
+ launched = this.killTree(pid, 'terminate', controller.signal);
1945
+ } catch (error) {
1946
+ delete state.softController;
1947
+ throw new Error(
1948
+ `Could not kill task ${task.id}: Windows taskkill /T failed to start: ${BackgroundTaskRegistry.errorMessage(error)}`,
1949
+ );
1950
+ }
1951
+
1952
+ const promise = launched
1953
+ .then((outcome) => {
1954
+ if (state.forcePromise !== undefined || this.isWindowsTaskkillTerminalRace(task)) return;
1955
+ const failure = this.evaluateWindowsTaskkillOutcome(task, pid, 'terminate', outcome);
1956
+ if (failure !== undefined) throw failure;
1957
+ })
1958
+ .catch((error: unknown) => {
1959
+ this.handleWindowsSoftException(task, pid, error, state);
1960
+ })
1961
+ .finally(() => {
1962
+ if (state.softController === controller) delete state.softController;
1963
+ });
1964
+ state.softPromise = promise;
1965
+ return promise;
1966
+ }
1967
+
1968
+ private startWindowsForceKill(task: BgTask, pid: number): Promise<void> {
1969
+ const state = this.getWindowsKillState(task);
1970
+ if (state.forcePromise !== undefined) return state.forcePromise;
1971
+
1972
+ let resolveForce: (() => void) | undefined;
1973
+ let rejectForce: ((error: unknown) => void) | undefined;
1974
+ const forcePromise = new Promise<void>((resolve, reject) => {
1975
+ resolveForce = resolve;
1976
+ rejectForce = reject;
1977
+ });
1978
+ if (resolveForce === undefined || rejectForce === undefined) {
1979
+ throw new Error('Windows force termination promise could not be initialized');
1980
+ }
1981
+ const resolveForceReady = resolveForce;
1982
+ const rejectForceReady = rejectForce;
1983
+ state.forcePromise = forcePromise;
1984
+ void forcePromise.catch((error: unknown) => {
1985
+ this.logger.error(
1986
+ `[background-tasks] Windows force tree termination failed for ${task.id}:`,
1987
+ error,
1988
+ );
1989
+ });
1990
+
1991
+ this.clearKillEscalationTimer(task);
1992
+ if (state.softController !== undefined && !state.softController.signal.aborted) {
1993
+ state.softController.abort();
1994
+ }
1995
+
1996
+ let launched: Promise<TaskkillOutcome>;
1997
+ try {
1998
+ launched = this.killTree(pid, 'force');
1999
+ } catch (error) {
2000
+ const failure = this.makeWindowsForceFailure(
2001
+ task,
2002
+ pid,
2003
+ `helper failed to start: ${BackgroundTaskRegistry.errorMessage(error)}`,
2004
+ );
2005
+ delete state.forcePromise;
2006
+ this.recordWindowsForceFailure(task, failure);
2007
+ rejectForceReady(failure);
2008
+ throw failure;
2009
+ }
2010
+
2011
+ launched.then(
2012
+ (outcome) => {
2013
+ const failure = this.evaluateWindowsTaskkillOutcome(task, pid, 'force', outcome);
2014
+ if (failure !== undefined) {
2015
+ this.recordWindowsForceFailure(task, failure);
2016
+ rejectForceReady(failure);
2017
+ return;
2018
+ }
2019
+ resolveForceReady();
2020
+ },
2021
+ (error: unknown) => {
2022
+ if (this.isWindowsTaskkillTerminalRace(task)) {
2023
+ this.recordWindowsTaskkillNotice(
2024
+ task,
2025
+ `taskkill force rejected after the task became terminal for pid ${String(pid)} (${BackgroundTaskRegistry.errorMessage(error)}); treating as a terminal race`,
2026
+ );
2027
+ resolveForceReady();
2028
+ return;
2029
+ }
2030
+ const failure = this.makeWindowsForceFailure(
2031
+ task,
2032
+ pid,
2033
+ BackgroundTaskRegistry.errorMessage(error),
2034
+ );
2035
+ this.recordWindowsForceFailure(task, failure);
2036
+ rejectForceReady(failure);
2037
+ },
2038
+ );
2039
+
2040
+ return forcePromise;
2041
+ }
2042
+
2043
+ private requestWindowsKill(task: BgTask, pid: number, signal: NodeJS.Signals): void {
2044
+ if (signal === 'SIGKILL') {
2045
+ this.startWindowsForceKill(task, pid);
2046
+ task.killSignalSent = true;
2047
+ return;
2048
+ }
2049
+
2050
+ this.startWindowsSoftKill(task, pid);
2051
+ task.killSignalSent = true;
2052
+ if (task.killEscalationTimer !== undefined) return;
2053
+ task.killEscalationTimer = setTimeout(() => {
2054
+ task.killEscalationTimer = undefined;
2055
+ if (task.status !== 'running') return;
2056
+ try {
2057
+ this.requestKill(task, 'SIGKILL');
2058
+ } catch (error) {
2059
+ task.error = BackgroundTaskRegistry.appendTaskError(
2060
+ task.error,
2061
+ `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`,
2062
+ );
2063
+ void this.writeMetadata(task).catch((metadataError: unknown) => {
2064
+ this.logger.error(
2065
+ `[background-tasks] failed to write metadata for ${task.id}:`,
2066
+ metadataError,
2067
+ );
2068
+ });
2069
+ }
2070
+ }, this.killGraceMs).unref();
2071
+ }
2072
+
2073
+ private requestKill(task: BgTask, signal: NodeJS.Signals = 'SIGTERM'): void {
2074
+ if (task.status !== 'running') {
2075
+ throw new Error(`Task ${task.id} is ${task.status}, not running`);
2076
+ }
2077
+ if (task.managedCancel !== undefined) {
2078
+ if (task.managedCancelRequested) return;
2079
+ task.managedCancelRequested = true;
2080
+ try {
2081
+ task.managedCancel();
2082
+ } catch (error) {
2083
+ throw new Error(
2084
+ `Could not cancel managed task ${task.id}: ${BackgroundTaskRegistry.errorMessage(error)}`,
2085
+ );
2086
+ }
2087
+ task.killSignalSent = true;
2088
+ return;
2089
+ }
2090
+ if (!task.child) {
2091
+ throw new Error(`Task ${task.id} has no child process handle`);
2092
+ }
2093
+ if (!task.pid) {
2094
+ throw new Error(`Task ${task.id} has no process id`);
2095
+ }
2096
+ if (task.killSignalSent && signal === 'SIGTERM') return;
2097
+
2098
+ if (this.platform === 'win32') {
2099
+ this.requestWindowsKill(task, task.pid, signal);
2100
+ return;
2101
+ }
2102
+
2103
+ const errors: string[] = [];
2104
+ let killed = false;
2105
+
2106
+ try {
2107
+ this.killProcess(-task.pid, signal);
2108
+ killed = true;
2109
+ } catch (error) {
2110
+ errors.push(
2111
+ `process group kill failed: ${error instanceof Error ? error.message : String(error)}`,
2112
+ );
2113
+ }
2114
+
2115
+ if (!killed) {
2116
+ try {
2117
+ task.child.kill(signal);
2118
+ killed = true;
2119
+ } catch (error) {
2120
+ errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
2121
+ }
2122
+ }
2123
+
2124
+ if (!killed) {
2125
+ throw new Error(`Could not kill task ${task.id}: ${errors.join('; ')}`);
2126
+ }
2127
+
2128
+ task.killSignalSent = true;
2129
+ // SIGKILL is the terminal escalation; it must never schedule a further one.
2130
+ if (signal === 'SIGKILL') return;
2131
+ // Only one escalation timer may be outstanding. Concurrent stop requests
2132
+ // previously each scheduled their own, producing duplicate SIGKILLs.
2133
+ if (task.killEscalationTimer !== undefined) return;
2134
+ task.killEscalationTimer = setTimeout(() => {
2135
+ task.killEscalationTimer = undefined;
2136
+ if (task.status !== 'running') return;
2137
+ try {
2138
+ this.requestKill(task, 'SIGKILL');
2139
+ } catch (error) {
2140
+ task.error = `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`;
2141
+ void this.writeMetadata(task).catch((metadataError: unknown) => {
2142
+ this.logger.error(
2143
+ `[background-tasks] failed to write metadata for ${task.id}:`,
2144
+ metadataError,
2145
+ );
2146
+ });
2147
+ }
2148
+ }, this.killGraceMs).unref();
2149
+ }
2150
+
2151
+ private waitForEnd(task: BgTask, timeoutMs: number): Promise<boolean> {
2152
+ if (task.status !== 'running') return Promise.resolve(true);
2153
+ return new Promise((resolve) => {
2154
+ const timeout = setTimeout(() => {
2155
+ const idx = task.waiters.indexOf(done);
2156
+ if (idx >= 0) task.waiters.splice(idx, 1);
2157
+ resolve(false);
2158
+ }, timeoutMs);
2159
+ const done = () => {
2160
+ clearTimeout(timeout);
2161
+ resolve(true);
2162
+ };
2163
+ task.waiters.push(done);
2164
+ });
2165
+ }
2166
+
2167
+ private waitForEndOrWindowsForceFailure(task: BgTask, timeoutMs: number): Promise<boolean> {
2168
+ const state = this.getWindowsKillState(task);
2169
+ if (state.forceFailure !== undefined) return Promise.reject(state.forceFailure);
2170
+ if (task.status !== 'running') return Promise.resolve(true);
2171
+ return new Promise((resolve, reject) => {
2172
+ const cleanup = () => {
2173
+ clearTimeout(timeout);
2174
+ const waiterIndex = task.waiters.indexOf(done);
2175
+ if (waiterIndex >= 0) task.waiters.splice(waiterIndex, 1);
2176
+ const listeners = state.forceFailureListeners;
2177
+ if (listeners !== undefined) {
2178
+ const listenerIndex = listeners.indexOf(failed);
2179
+ if (listenerIndex >= 0) listeners.splice(listenerIndex, 1);
2180
+ if (listeners.length === 0) delete state.forceFailureListeners;
2181
+ }
2182
+ };
2183
+ const timeout = setTimeout(() => {
2184
+ cleanup();
2185
+ resolve(false);
2186
+ }, timeoutMs);
2187
+ const done = () => {
2188
+ cleanup();
2189
+ resolve(true);
2190
+ };
2191
+ const failed = (error: Error) => {
2192
+ cleanup();
2193
+ reject(error);
2194
+ };
2195
+ task.waiters.push(done);
2196
+ if (state.forceFailureListeners === undefined) state.forceFailureListeners = [];
2197
+ state.forceFailureListeners.push(failed);
2198
+ });
2199
+ }
2200
+
2201
+ private async awaitWindowsForceBeforeTerminal(task: BgTask): Promise<Error | undefined> {
2202
+ const state = this.windowsKillStates.get(task);
2203
+ if (state === undefined) return undefined;
2204
+ const forcePromise = state.forcePromise;
2205
+ if (forcePromise === undefined) return state.forceFailure;
2206
+ try {
2207
+ await forcePromise;
2208
+ } catch (error) {
2209
+ return error instanceof Error ? error : new Error(String(error));
2210
+ }
2211
+ return state.forceFailure;
2212
+ }
2213
+
2214
+ private publishTerminal(task: BgTask): void {
2215
+ if (task.terminalPublished || task.terminalPublishInFlight) return;
2216
+ task.terminalPublishInFlight = true;
2217
+ if (task.terminalPublicationGate === undefined) {
2218
+ this.tryPublishTerminalNow(task);
2219
+ return;
2220
+ }
2221
+ void this.publishTerminalWhenReady(task);
2222
+ }
2223
+
2224
+ private async publishTerminalWhenReady(task: BgTask): Promise<void> {
2225
+ try {
2226
+ await task.terminalPublicationGate;
2227
+ } catch (error) {
2228
+ this.handleTerminalPublishFailure(task, error);
2229
+ return;
2230
+ }
2231
+ this.tryPublishTerminalNow(task);
2232
+ }
2233
+
2234
+ private tryPublishTerminalNow(task: BgTask): void {
2235
+ try {
2236
+ if (task.terminalPublished) return;
2237
+ this.publishTerminalSnapshot(snapshot(task));
2238
+ task.terminalPublished = true;
2239
+ if (task.terminalPublishRetryHandle) {
2240
+ clearTimeout(task.terminalPublishRetryHandle);
2241
+ task.terminalPublishRetryHandle = undefined;
2242
+ }
2243
+ } catch (error) {
2244
+ this.handleTerminalPublishFailure(task, error);
2245
+ return;
2246
+ } finally {
2247
+ task.terminalPublishInFlight = false;
2248
+ }
2249
+ }
2250
+
2251
+ private handleTerminalPublishFailure(task: BgTask, error: unknown): void {
2252
+ this.logger.error(`[background-tasks] terminal publication failed for ${task.id}:`, error);
2253
+ task.terminalPublishInFlight = false;
2254
+ if (!task.terminalPublished && task.terminalPublishRetryHandle === undefined) {
2255
+ task.terminalPublishRetryHandle = setTimeout(() => {
2256
+ task.terminalPublishRetryHandle = undefined;
2257
+ this.publishTerminal(task);
2258
+ }, 100);
2259
+ task.terminalPublishRetryHandle.unref();
2260
+ }
2261
+ }
2262
+
2263
+ private notifyCompletion(task: BgTask): void {
2264
+ if (!task.notifyOnCompletion || task.notified || this.shuttingDown) return;
2265
+ task.notified = true;
2266
+ const exit =
2267
+ task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
2268
+ const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
2269
+ const taskName = taskDisplayName(task);
2270
+ const guidance =
2271
+ task.fusion === undefined
2272
+ ? 'Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.'
2273
+ : task.status === 'completed'
2274
+ ? `Fusion result is durably committed at ${task.fusion.artifactDir}. Call bg_result({taskId:${JSON.stringify(task.id)}}) once to retrieve it; do not poll.`
2275
+ : `Fusion ended ${task.status}. Inspect the preserved artifacts at ${task.fusion.artifactDir}; do not poll.`;
2276
+ const content = [
2277
+ '<background-task-notification>',
2278
+ ` <task-id>${task.id}</task-id>`,
2279
+ ` <task-name>${escapeXml(taskName)}</task-name>`,
2280
+ ` <status>${task.status}</status>`,
2281
+ exit,
2282
+ error,
2283
+ ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
2284
+ ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
2285
+ ` <guidance>${escapeXml(guidance)}</guidance>`,
2286
+ '</background-task-notification>',
2287
+ ]
2288
+ .filter(Boolean)
2289
+ .join('\n');
2290
+
2291
+ try {
2292
+ this.sendCompletionNotification(
2293
+ {
2294
+ customType: 'background-task-notification',
2295
+ content,
2296
+ display: true,
2297
+ details: snapshot(task),
2298
+ },
2299
+ { deliverAs: 'followUp', triggerTurn: task.triggerOnCompletion },
2300
+ );
2301
+ } catch (error) {
2302
+ task.notified = false;
2303
+ throw new Error(
2304
+ `Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
2305
+ );
2306
+ }
2307
+ }
2308
+
2309
+ private async finalizeTask(
2310
+ task: BgTask,
2311
+ status: TaskStatus,
2312
+ exitCode: number | null,
2313
+ signal?: string | null,
2314
+ error?: string,
2315
+ ): Promise<void> {
2316
+ if (task.finalized) return;
2317
+ task.finalized = true;
2318
+ if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
2319
+ if (task.killEscalationTimer !== undefined) {
2320
+ clearTimeout(task.killEscalationTimer);
2321
+ task.killEscalationTimer = undefined;
2322
+ }
2323
+ let finalStatus = status;
2324
+ let finalError = error;
2325
+ const forceFailure = await this.awaitWindowsForceBeforeTerminal(task);
2326
+ if (forceFailure !== undefined) {
2327
+ finalStatus = 'failed';
2328
+ finalError = BackgroundTaskRegistry.appendTaskError(finalError, forceFailure.message);
2329
+ }
2330
+ task.exitCode = exitCode;
2331
+ task.signal = signal ?? null;
2332
+
2333
+ // Keep status="running" until the final wrapped-agent fragment has been
2334
+ // consumed and the output plus terminal metadata are durable. Publishing a
2335
+ // terminal state earlier lets bg_status observe the previous assistant
2336
+ // turn's context snapshot and recreates the same false-completion race the
2337
+ // attested producer is required to prevent.
2338
+ try {
2339
+ if (task.telemetryWrapped) {
2340
+ // Child-process close can be observed before the wrapper stdout listener has
2341
+ // committed its last parsed telemetry batch. Wait for a short quiet window,
2342
+ // then flush the trailing partial line, so completed status never races
2343
+ // ahead of the final assistant-turn context/token/tool snapshot.
2344
+ await new Promise<void>((resolve) => setTimeout(resolve, 25));
2345
+ this.flushAgentStdout(task);
2346
+ }
2347
+ if (task.stream && !task.stream.destroyed) await closeAndFsyncOutputStream(task.stream);
2348
+ } catch (finalizeError) {
2349
+ finalStatus = 'failed';
2350
+ const message =
2351
+ finalizeError instanceof Error ? finalizeError.message : String(finalizeError);
2352
+ finalError = finalError
2353
+ ? `${finalError}; final output durability failed: ${message}`
2354
+ : `Final output durability failed: ${message}`;
2355
+ }
2356
+
2357
+ task.endTime = this.now();
2358
+ if (finalError) task.error = finalError;
2359
+ try {
2360
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
2361
+ task.status = finalStatus;
2362
+ } catch (metadataError) {
2363
+ finalStatus = 'failed';
2364
+ task.status = 'failed';
2365
+ task.error = `Terminal metadata write failed: ${metadataError instanceof Error ? metadataError.message : String(metadataError)}`;
2366
+ this.logger.error(
2367
+ `[background-tasks] failed to write metadata for ${task.id}:`,
2368
+ metadataError,
2369
+ );
2370
+ await this.writeMetadata(task).catch((retryError: unknown) => {
2371
+ this.logger.error(
2372
+ `[background-tasks] failed to write failed terminal metadata for ${task.id}:`,
2373
+ retryError,
2374
+ );
2375
+ });
2376
+ }
2377
+
2378
+ for (const waiter of task.waiters.splice(0)) waiter();
2379
+ this.onChange();
2380
+ this.publishTerminal(task);
2381
+ let deliveryGateReady = true;
2382
+ if (task.terminalPublicationGate !== undefined) {
2383
+ try {
2384
+ await task.terminalPublicationGate;
2385
+ } catch (error) {
2386
+ deliveryGateReady = false;
2387
+ this.logger.error(
2388
+ `[background-tasks] completion delivery gate failed for ${task.id}:`,
2389
+ error,
2390
+ );
2391
+ }
2392
+ }
2393
+ if (deliveryGateReady) {
2394
+ try {
2395
+ this.notifyCompletion(task);
2396
+ } catch (notificationError) {
2397
+ this.logger.error(
2398
+ `[background-tasks] notification failed for ${task.id}:`,
2399
+ notificationError,
2400
+ );
2401
+ }
2402
+ }
2403
+ try {
2404
+ await this.writeMetadata(task);
2405
+ } catch (metadataError) {
2406
+ this.logger.error(
2407
+ `[background-tasks] failed to update notification metadata for ${task.id}:`,
2408
+ metadataError,
2409
+ );
2410
+ }
2411
+ this.pruneOldTasks();
2412
+ }
2413
+
2414
+ private pruneOldTasks(): void {
2415
+ if (this.tasks.size <= this.maxRecentTasks) return;
2416
+ const removable = [...this.tasks.values()]
2417
+ .filter((task) => task.status !== 'running')
2418
+ .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
2419
+ while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
2420
+ const task = removable.shift();
2421
+ if (task) this.tasks.delete(task.id);
2422
+ }
2423
+ }
2424
+ }