@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
package/src/types.ts ADDED
@@ -0,0 +1,963 @@
1
+ /**
2
+ * @pi-unipi/background-tasks — Shared types & helpers
3
+ *
4
+ * Ported from pi-background-tasks src/core/common.ts. Conventions changed to
5
+ * ours: storage under ~/.unipi/background-tasks/ + os.tmpdir()/unipi-bg-tasks-*,
6
+ * env prefix UNIPI_BG_* (their PI_BG_*). Their update-check surface is dropped
7
+ * (our updater module owns updates).
8
+ *
9
+ * ISC-licensed reference: Copyright Ismail <ismailsalikhodjaev@gmail.com>.
10
+ */
11
+
12
+ import { statSync, type WriteStream } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type { BackgroundTaskChildProcess } from "./child-process.js";
15
+ import type { FusionResultDetails, FusionUsage, FusionWorkflowId } from "./fusion/types.js";
16
+
17
+ export const TASK_STATUS_VALUES = ["running", "completed", "failed", "killed"] as const;
18
+ export const TERMINAL_TASK_STATUS_VALUES = ["completed", "failed", "killed"] as const;
19
+
20
+ export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
21
+ export type TerminalTaskStatus = (typeof TERMINAL_TASK_STATUS_VALUES)[number];
22
+ export type KillKind = "user" | "timeout" | "output_cap" | "shutdown";
23
+
24
+ export type JsonObject = Readonly<Record<PropertyKey, unknown>>;
25
+
26
+ export interface TaskContextUsage {
27
+ tokens: number | null;
28
+ contextWindow: number;
29
+ percent: number | null;
30
+ }
31
+
32
+ export interface TaskTokenUsage {
33
+ input: number;
34
+ output: number;
35
+ cacheRead: number;
36
+ cacheWrite: number;
37
+ totalTokens: number;
38
+ costTotal?: number;
39
+ }
40
+
41
+ export interface TaskToolUsage {
42
+ total: number;
43
+ failed: number;
44
+ byName: Record<string, number>;
45
+ }
46
+
47
+ export interface BgTaskSnapshot {
48
+ id: string;
49
+ name?: string | undefined;
50
+ command: string;
51
+ description?: string | undefined;
52
+ status: TaskStatus;
53
+ outputPath: string;
54
+ cwd: string;
55
+ startTime: number;
56
+ endTime?: number | undefined;
57
+ exitCode?: number | null | undefined;
58
+ signal?: string | null | undefined;
59
+ pid?: number | undefined;
60
+ bytesWritten: number;
61
+ isAgent: boolean;
62
+ error?: string | undefined;
63
+ notified: boolean;
64
+ notifyOnCompletion: boolean;
65
+ triggerOnCompletion: boolean;
66
+ timeoutSeconds?: number | undefined;
67
+ contextUsage?: TaskContextUsage | undefined;
68
+ tokenUsage?: TaskTokenUsage | undefined;
69
+ toolUsage?: TaskToolUsage | undefined;
70
+ model?: string | undefined;
71
+ telemetryUnavailableReason?: string | undefined;
72
+ attestationPath?: string | undefined;
73
+ delegate?: DelegateTaskFacts | undefined;
74
+ fusion?: FusionTaskFacts | undefined;
75
+ }
76
+
77
+ export interface AttestedPiTaskFiles {
78
+ eventsPath: string;
79
+ stderrPath: string;
80
+ wrapperPath: string;
81
+ attestationPath: string;
82
+ }
83
+
84
+ export interface AttestedPiTaskSnapshot extends BgTaskSnapshot {
85
+ attestedPi?: AttestedPiTaskFiles | undefined;
86
+ }
87
+
88
+ /** Delegate-specific task facts surfaced through snapshots and `bg_result`. */
89
+ export interface DelegateTaskFacts {
90
+ taskId: string;
91
+ launchNonce: string;
92
+ artifactDir: string;
93
+ artifactDirAbs: string;
94
+ seedSha256: string;
95
+ childSessionId: string;
96
+ route: { provider: string; model: string; qualifiedId: string };
97
+ budget: DelegateBudgetRouteSource;
98
+ extensionMode: DelegateExtensionMode;
99
+ autoDeliver: "never" | "when_small" | "always";
100
+ /** Set once the run reaches a terminal state and its result has been evaluated. */
101
+ outcome?: DelegateTaskOutcome | undefined;
102
+ }
103
+
104
+ export interface DelegateTaskOutcome {
105
+ status: "committed" | "failed" | "cancelled";
106
+ errorCode?: string | undefined;
107
+ answerBytes?: number | undefined;
108
+ answerSha256?: number | string | undefined;
109
+ turns?: number | undefined;
110
+ toolCalls?: number | undefined;
111
+ }
112
+
113
+ /** Forward declarations satisfied by ./delegate/types.js (kept here to avoid cycles). */
114
+ export type DelegateBudgetRouteSource = import("./delegate/types.js").DelegateBudgetRouteSource;
115
+ export type DelegateExtensionMode = import("./delegate/types.js").DelegateExtensionMode;
116
+
117
+ /** Fusion-specific task facts surfaced through snapshots and `bg_result`. */
118
+ export interface FusionTaskFacts {
119
+ runId: string;
120
+ workflow: FusionWorkflowId;
121
+ artifactDir: string;
122
+ artifactDirAbs: string;
123
+ state: string;
124
+ outcome?: FusionTaskOutcome | undefined;
125
+ /** Durable once-only accounting claim made by the first successful bg_result retrieval. */
126
+ usageDelivered: boolean;
127
+ }
128
+
129
+ export interface FusionTaskOutcome {
130
+ status: "committed" | "failed" | "cancelled";
131
+ resultDetails?: FusionResultDetails | undefined;
132
+ usage?: FusionUsage | undefined;
133
+ error?: string | undefined;
134
+ }
135
+
136
+ export interface BgTask extends Omit<BgTaskSnapshot, "name"> {
137
+ name: string;
138
+ outputAbsPath: string;
139
+ metadataAbsPath: string;
140
+ eventsAbsPath?: string | undefined;
141
+ stderrAbsPath?: string | undefined;
142
+ wrapperAbsPath?: string | undefined;
143
+ attestationAbsPath?: string | undefined;
144
+ child?: BackgroundTaskChildProcess | undefined;
145
+ stream?: WriteStream | undefined;
146
+ timeoutHandle?: NodeJS.Timeout | undefined;
147
+ killKind?: KillKind | undefined;
148
+ killSignalSent?: boolean | undefined;
149
+ killEscalationTimer?: NodeJS.Timeout | undefined;
150
+ capExceeded?: boolean | undefined;
151
+ finalized?: boolean | undefined;
152
+ terminalPublished?: boolean | undefined;
153
+ terminalPublishInFlight?: boolean | undefined;
154
+ terminalPublishRetryHandle?: NodeJS.Timeout | undefined;
155
+ /** Optional protocol barrier used by EventBus run requests so early child exits cannot publish before the run response is observable. */
156
+ terminalPublicationGate?: Promise<void> | undefined;
157
+ contextUsageBuffer?: string | undefined;
158
+ /** True when this task launched a telemetry-wrapped Pi agent; its stdout carries control lines, not raw output. */
159
+ telemetryWrapped?: boolean | undefined;
160
+ /** Partial trailing stdout line held between chunks while reconstructing wrapped-agent control lines. */
161
+ agentStdoutBuffer?: string | undefined;
162
+ telemetryUnavailableReason?: string | undefined;
163
+ attestationPath?: string | undefined;
164
+ attestedPi?: AttestedPiTaskFiles | undefined;
165
+ delegate?: DelegateTaskFacts | undefined;
166
+ fusion?: FusionTaskFacts | undefined;
167
+ /** Cancellation hook for an in-process managed task such as Fusion. */
168
+ managedCancel?: (() => void) | undefined;
169
+ managedCancelRequested?: boolean | undefined;
170
+ managedStopWaitMs?: number | undefined;
171
+ metadataWriteChain?: Promise<void> | undefined;
172
+ waiters: Array<() => void>;
173
+ }
174
+
175
+ export type CompletionDeliveryMode =
176
+ | "notification-and-wake"
177
+ | "notification-only"
178
+ | "manual-monitoring";
179
+
180
+ export interface CompletionDeliveryGuidance {
181
+ readonly mode: CompletionDeliveryMode;
182
+ readonly notificationEnabled: boolean;
183
+ readonly automaticWakeEnabled: boolean;
184
+ readonly text: string;
185
+ }
186
+
187
+ /**
188
+ * Describe the actual parent-agent completion path for one bg_run launch.
189
+ * A wake request cannot take effect without the notification that carries it.
190
+ */
191
+ export function deriveCompletionDeliveryGuidance(
192
+ notifyOnCompletion: boolean,
193
+ triggerOnCompletion: boolean,
194
+ ): CompletionDeliveryGuidance {
195
+ if (notifyOnCompletion && triggerOnCompletion) {
196
+ return {
197
+ mode: "notification-and-wake",
198
+ notificationEnabled: true,
199
+ automaticWakeEnabled: true,
200
+ text: [
201
+ "Terminal notification: enabled.",
202
+ "Automatic follow-up turn: enabled.",
203
+ "Next action: do not poll or sleep merely to wait; continue only independent useful work, otherwise end this turn and wait for <background-task-notification>.",
204
+ ].join("\n"),
205
+ };
206
+ }
207
+
208
+ if (notifyOnCompletion) {
209
+ return {
210
+ mode: "notification-only",
211
+ notificationEnabled: true,
212
+ automaticWakeEnabled: false,
213
+ text: [
214
+ "Terminal notification: enabled.",
215
+ "Automatic follow-up turn: disabled. The terminal notification will be delivered, but it will not start an agent turn.",
216
+ "Next action: automatic wake-up was explicitly disabled; use bg_status/bg_logs only when deliberate monitoring is required, without tight polling.",
217
+ ].join("\n"),
218
+ };
219
+ }
220
+
221
+ return {
222
+ mode: "manual-monitoring",
223
+ notificationEnabled: false,
224
+ automaticWakeEnabled: false,
225
+ text: [
226
+ "Terminal notification: disabled.",
227
+ triggerOnCompletion
228
+ ? "Automatic follow-up turn: disabled because terminal notifications are disabled. triggerOnCompletion has no effect while notifyOnCompletion is false."
229
+ : "Automatic follow-up turn: disabled.",
230
+ "Next action: completion delivery was explicitly disabled; use bg_status/bg_logs only for deliberate manual monitoring, without tight polling.",
231
+ ].join("\n"),
232
+ };
233
+ }
234
+
235
+ export interface BgRunDetails {
236
+ task: BgTaskSnapshot;
237
+ }
238
+
239
+ export interface BgStatusDetails {
240
+ tasks: BgTaskSnapshot[];
241
+ }
242
+
243
+ export interface BgLogsDetails {
244
+ task: BgTaskSnapshot;
245
+ path: string;
246
+ bytesRead: number;
247
+ truncated: boolean;
248
+ tail: boolean;
249
+ }
250
+
251
+ export interface BgKillDetails {
252
+ task: BgTaskSnapshot;
253
+ message: string;
254
+ }
255
+
256
+ export interface StartTaskOptions {
257
+ name?: string | undefined;
258
+ description?: string | undefined;
259
+ isAgent?: boolean | undefined;
260
+ timeoutSeconds?: number | undefined;
261
+ notifyOnCompletion?: boolean | undefined;
262
+ triggerOnCompletion?: boolean | undefined;
263
+ /** @internal EventBus protocol barrier; callers should not set this outside the extension service. */
264
+ terminalPublicationGate?: Promise<void> | undefined;
265
+ }
266
+
267
+ /** Prepared managed launch handed to the registry after preflight has succeeded. */
268
+ export interface StartManagedTaskOptions {
269
+ id: string;
270
+ name: string;
271
+ command: string;
272
+ description?: string | undefined;
273
+ isAgent: boolean;
274
+ completion: Promise<void>;
275
+ cancel: () => void;
276
+ notifyOnCompletion: boolean;
277
+ triggerOnCompletion: boolean;
278
+ fusion: FusionTaskFacts;
279
+ stopWaitMs?: number | undefined;
280
+ /** Prevent terminal publication until the launch receipt handoff is observable. */
281
+ terminalPublicationGate?: Promise<void> | undefined;
282
+ }
283
+
284
+ export interface StartDelegateTaskOptions {
285
+ name: string;
286
+ argv: readonly string[];
287
+ /** Prompt bytes delivered over stdin, never as a shell or positional argument. */
288
+ stdinBytes: Buffer;
289
+ env: NodeJS.ProcessEnv;
290
+ facts: DelegateTaskFacts;
291
+ notifyOnCompletion: boolean;
292
+ triggerOnCompletion: boolean;
293
+ timeoutSeconds?: number | undefined;
294
+ }
295
+
296
+ export interface StartAttestedPiTaskOptions {
297
+ name: string;
298
+ provider: string;
299
+ model: string;
300
+ prompt: string;
301
+ reportPath: string;
302
+ extraPiArgs?: string[] | undefined;
303
+ thinking?: string | undefined;
304
+ timeoutSeconds?: number | undefined;
305
+ }
306
+
307
+ /** Default tool-output byte cap (mirrors pi's DEFAULT_MAX_BYTES). */
308
+ export const DEFAULT_MAX_BYTES = 30 * 1024;
309
+
310
+ export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
311
+ export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
312
+ export const COMMAND_PREVIEW_CHARS = 90;
313
+
314
+ // ── Our storage roots (convention: ~/.unipi + os.tmpdir, never .pi/) ────────
315
+
316
+ /** Durable state root: ~/.unipi/background-tasks/<project-hash>/<session-id>-<pid>/ */
317
+ export function bgStateRoot(projectHash: string): string {
318
+ return join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".unipi", "background-tasks", projectHash);
319
+ }
320
+
321
+ /** Runtime artifact root under our temp dir: os.tmpdir()/unipi-bg-tasks-<scope>/ */
322
+ export function bgTempRoot(scope: string): string {
323
+ return join(process.env.UNIPI_BG_TMP_DIR ?? (process.env.TMPDIR ?? "/tmp"), `unipi-bg-tasks-${scope}`);
324
+ }
325
+
326
+ const parseJsonValue: (text: string) => unknown = globalThis.JSON.parse;
327
+
328
+ export function isJsonObject(value: unknown): value is JsonObject {
329
+ return typeof value === "object" && value !== null;
330
+ }
331
+
332
+ export function parseJsonText(text: string): unknown {
333
+ return parseJsonValue(text);
334
+ }
335
+
336
+ export function sanitizePathSegment(value: string): string {
337
+ const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
338
+ return sanitized || "session";
339
+ }
340
+
341
+ export function stripMatchingQuotes(value: string): string {
342
+ const trimmed = value.trim();
343
+ if (trimmed.length >= 2) {
344
+ const first = trimmed[0];
345
+ const last = trimmed[trimmed.length - 1];
346
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
347
+ return trimmed.slice(1, -1);
348
+ }
349
+ }
350
+ return trimmed;
351
+ }
352
+
353
+ export function compactWhitespace(value: string): string {
354
+ return value.replace(/\s+/g, " ").trim();
355
+ }
356
+
357
+ export function truncateChars(value: string, maxChars: number): string {
358
+ if (value.length <= maxChars) return value;
359
+ return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
360
+ }
361
+
362
+ export function normalizeTaskName(value: unknown): string | undefined {
363
+ if (typeof value !== "string") return undefined;
364
+ const normalized = compactWhitespace(stripMatchingQuotes(value));
365
+ if (!normalized) return undefined;
366
+ return truncateChars(normalized, 80);
367
+ }
368
+
369
+ export function deriveTaskNameFromCommand(command: string): string {
370
+ const normalized = compactWhitespace(stripMatchingQuotes(command));
371
+ if (!normalized) return "Background task";
372
+
373
+ const packageScript = /^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/.exec(normalized);
374
+ if (packageScript) {
375
+ const runner = packageScript[1] ?? "npm";
376
+ const run = packageScript[2] !== undefined ? " run" : "";
377
+ const script = packageScript[3] ?? "";
378
+ return truncateChars(`${runner}${run} ${script}`, 48);
379
+ }
380
+
381
+ const words = normalized.split(/\s+/).slice(0, 5).join(" ");
382
+ return truncateChars(words.length > 0 ? words : normalized, 48);
383
+ }
384
+
385
+ export function taskDisplayName(task: {
386
+ name?: string | undefined;
387
+ description?: string | undefined;
388
+ command?: string | undefined;
389
+ id?: string | undefined;
390
+ }): string {
391
+ const commandName =
392
+ task.command && task.command.length > 0 ? deriveTaskNameFromCommand(task.command) : undefined;
393
+ return (
394
+ normalizeTaskName(task.name) ??
395
+ normalizeTaskName(task.description) ??
396
+ commandName ??
397
+ task.id ??
398
+ "Background task"
399
+ );
400
+ }
401
+
402
+ function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
403
+ const input = valueAndRest.trimStart();
404
+ if (!input) return undefined;
405
+ const quote = input[0];
406
+ if (quote === '"' || quote === "'") {
407
+ let escaped = false;
408
+ let value = "";
409
+ for (let i = 1; i < input.length; i++) {
410
+ const char = input.charAt(i);
411
+ if (escaped) {
412
+ value += char;
413
+ escaped = false;
414
+ continue;
415
+ }
416
+ if (char === "\\") {
417
+ escaped = true;
418
+ continue;
419
+ }
420
+ if (char === quote) {
421
+ return { value, rest: input.slice(i + 1).trimStart() };
422
+ }
423
+ value += char;
424
+ }
425
+ return undefined;
426
+ }
427
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(input);
428
+ if (!match) return undefined;
429
+ const parsedValue = match[1];
430
+ if (parsedValue === undefined) return undefined;
431
+ return { value: parsedValue, rest: match[2]?.trimStart() ?? "" };
432
+ }
433
+
434
+ export function parseBgCommandArgs(args: string): {
435
+ name?: string;
436
+ command: string;
437
+ isAgent: boolean;
438
+ } {
439
+ let input = args.trim();
440
+ let name: string | undefined;
441
+ let isAgent = false;
442
+
443
+ while (input) {
444
+ let consumed = false;
445
+ for (const prefix of ["--name=", "-n="]) {
446
+ if (input.startsWith(prefix)) {
447
+ const parsed = parseNameValueAndRest(input.slice(prefix.length));
448
+ if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
449
+ name = normalizeTaskName(parsed.value);
450
+ input = parsed.rest;
451
+ consumed = true;
452
+ break;
453
+ }
454
+ }
455
+ if (consumed) continue;
456
+
457
+ for (const prefix of ["--name", "-n"]) {
458
+ if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
459
+ const parsed = parseNameValueAndRest(input.slice(prefix.length));
460
+ if (!parsed) throw new Error(`${prefix} requires a task name`);
461
+ name = normalizeTaskName(parsed.value);
462
+ input = parsed.rest;
463
+ consumed = true;
464
+ break;
465
+ }
466
+ }
467
+ if (consumed) continue;
468
+
469
+ for (const flag of ["--agent", "--llm-agent"]) {
470
+ if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
471
+ isAgent = true;
472
+ input = input.slice(flag.length).trimStart();
473
+ consumed = true;
474
+ break;
475
+ }
476
+ }
477
+ if (consumed) continue;
478
+
479
+ for (const flag of ["--script", "--no-agent"]) {
480
+ if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
481
+ isAgent = false;
482
+ input = input.slice(flag.length).trimStart();
483
+ consumed = true;
484
+ break;
485
+ }
486
+ }
487
+ if (consumed) continue;
488
+
489
+ if (input === "--") {
490
+ input = "";
491
+ break;
492
+ }
493
+ if (input.startsWith("-- ")) {
494
+ input = input.slice(3).trimStart();
495
+ break;
496
+ }
497
+ break;
498
+ }
499
+
500
+ return name ? { name, command: input, isAgent } : { command: input, isAgent };
501
+ }
502
+
503
+ export function formatDuration(ms: number): string {
504
+ if (ms < 1000) return `${String(ms)}ms`;
505
+ const seconds = Math.floor(ms / 1000);
506
+ if (seconds < 60) return `${String(seconds)}s`;
507
+ const minutes = Math.floor(seconds / 60);
508
+ const remSeconds = seconds % 60;
509
+ if (minutes < 60) return `${String(minutes)}m${remSeconds > 0 ? `${String(remSeconds)}s` : ""}`;
510
+ const hours = Math.floor(minutes / 60);
511
+ const remMinutes = minutes % 60;
512
+ return `${String(hours)}h${remMinutes > 0 ? `${String(remMinutes)}m` : ""}`;
513
+ }
514
+
515
+ export function formatCompactNumber(count: number): string {
516
+ const normalized = Math.max(0, Math.floor(count));
517
+ if (normalized < 1000) return normalized.toString();
518
+ if (normalized < 10000) return `${(normalized / 1000).toFixed(1)}k`;
519
+ if (normalized < 1000000) return `${String(Math.round(normalized / 1000))}k`;
520
+ if (normalized < 10000000) return `${(normalized / 1000000).toFixed(1)}M`;
521
+ return `${String(Math.round(normalized / 1000000))}M`;
522
+ }
523
+
524
+ export function formatContextUsageSummary(usage?: TaskContextUsage): string | undefined {
525
+ if (usage?.contextWindow === undefined || usage.contextWindow <= 0) return undefined;
526
+ const window = formatCompactNumber(usage.contextWindow);
527
+ if (usage.percent === null || usage.tokens === null) return `ctx=?/${window}`;
528
+ return `ctx=${usage.percent.toFixed(1)}%/${window}`;
529
+ }
530
+
531
+ export function formatTokenUsageSummary(usage?: TaskTokenUsage): string | undefined {
532
+ if (!usage || usage.totalTokens <= 0) return undefined;
533
+ return `tokens=${formatCompactNumber(usage.totalTokens)}`;
534
+ }
535
+
536
+ export function formatToolUsageSummary(usage?: TaskToolUsage): string | undefined {
537
+ if (!usage || (usage.total <= 0 && usage.failed <= 0)) return undefined;
538
+ const failed = usage.failed > 0 ? ` failed=${String(usage.failed)}` : "";
539
+ return `tools=${String(usage.total)}${failed}`;
540
+ }
541
+
542
+ export function formatModelSummary(model?: string): string | undefined {
543
+ if (!model) return undefined;
544
+ return `model=${model}`;
545
+ }
546
+
547
+ /**
548
+ * Human-readable activity transcript for telemetry-wrapped Pi agents.
549
+ *
550
+ * The wrapper emits one `background-task-activity` control line per meaningful
551
+ * child-agent event (assistant text, reasoning, tool start, tool end) so the
552
+ * registry can render "what the agent is actually doing" into the task output
553
+ * file instead of leaking raw telemetry JSON. Both the parser and the formatter
554
+ * are pure so the visible transcript is fully unit-testable.
555
+ */
556
+ export const AGENT_ACTIVITY_TYPE = "background-task-activity";
557
+ const AGENT_ACTIVITY_DETAIL_MAX = 80;
558
+
559
+ export type AgentActivity =
560
+ | { kind: "assistant_text"; text: string }
561
+ | { kind: "reasoning"; text: string }
562
+ | { kind: "tool_start"; tool: string; argsSummary: string }
563
+ | { kind: "tool_end"; tool: string; isError: boolean; error?: string };
564
+
565
+ interface AgentActivityPayload extends JsonObject {
566
+ readonly type?: unknown;
567
+ readonly kind?: unknown;
568
+ readonly text?: unknown;
569
+ readonly tool?: unknown;
570
+ readonly argsSummary?: unknown;
571
+ readonly isError?: unknown;
572
+ readonly error?: unknown;
573
+ }
574
+
575
+ function readActivityString(
576
+ record: AgentActivityPayload,
577
+ key: "text" | "tool" | "argsSummary" | "error",
578
+ ): string | undefined {
579
+ const value = record[key];
580
+ return typeof value === "string" ? value : undefined;
581
+ }
582
+
583
+ /** Narrow a parsed `background-task-activity` control payload into a typed {@link AgentActivity}. */
584
+ export function parseAgentActivity(payload: unknown): AgentActivity | undefined {
585
+ if (!isJsonObject(payload)) return undefined;
586
+ const record: AgentActivityPayload = payload;
587
+ if (record.type !== AGENT_ACTIVITY_TYPE) return undefined;
588
+ const kind = record.kind;
589
+ if (kind === "assistant_text" || kind === "reasoning") {
590
+ const text = readActivityString(record, "text");
591
+ if (typeof text !== "string") return undefined;
592
+ return { kind, text: truncateChars(text, AGENT_ACTIVITY_DETAIL_MAX) };
593
+ }
594
+ if (kind === "tool_start") {
595
+ const tool = readActivityString(record, "tool");
596
+ if (typeof tool !== "string") return undefined;
597
+ return {
598
+ kind,
599
+ tool,
600
+ argsSummary: truncateChars(readActivityString(record, "argsSummary") ?? "", AGENT_ACTIVITY_DETAIL_MAX),
601
+ };
602
+ }
603
+ if (kind === "tool_end") {
604
+ const tool = readActivityString(record, "tool");
605
+ if (typeof tool !== "string") return undefined;
606
+ const isError = record.isError === true;
607
+ const error = readActivityString(record, "error");
608
+ return {
609
+ kind,
610
+ tool,
611
+ isError,
612
+ error: isError && error !== undefined ? truncateChars(error, AGENT_ACTIVITY_DETAIL_MAX) : undefined,
613
+ };
614
+ }
615
+ return undefined;
616
+ }
617
+
618
+ /** Format one activity event into its human transcript line. */
619
+ export function formatAgentActivity(activity: AgentActivity): string {
620
+ switch (activity.kind) {
621
+ case "assistant_text":
622
+ return `assistant: ${activity.text}`;
623
+ case "reasoning":
624
+ return `reasoning: ${activity.text}`;
625
+ case "tool_start":
626
+ return `tool ${activity.tool}: ${activity.argsSummary}`;
627
+ case "tool_end":
628
+ return activity.isError ? `tool ${activity.tool}: error${activity.error ? ` — ${activity.error}` : ""}` : `tool ${activity.tool}: done`;
629
+ }
630
+ }
631
+
632
+ /** statSync-based file size probe; undefined when the file does not exist. */
633
+ export function fileSizeOrNull(path: string): number | undefined {
634
+ try {
635
+ return statSync(path).size;
636
+ } catch {
637
+ return undefined;
638
+ }
639
+ }
640
+
641
+ // ── Registry-support helpers (ported from reference common.ts) ──────────────
642
+
643
+ import { open } from "node:fs/promises";
644
+ import { extname, isAbsolute, win32 } from "node:path";
645
+
646
+ export function escapeXml(value: string): string {
647
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
648
+ }
649
+
650
+ /** Render an activity event into a transcript line, or undefined when nothing worth showing. */
651
+ export function formatAgentActivityLine(activity: AgentActivity): string | undefined {
652
+ if (activity.kind === "assistant_text") {
653
+ const text = activity.text.replace(/\s+$/u, "");
654
+ return text.trim().length > 0 ? text : undefined;
655
+ }
656
+ if (activity.kind === "reasoning") {
657
+ const text = activity.text.replace(/\s+$/u, "");
658
+ return text.trim().length > 0 ? `\u2026 ${text}` : undefined;
659
+ }
660
+ if (activity.kind === "tool_start") {
661
+ const summary = compactWhitespace(activity.argsSummary);
662
+ const suffix = summary.length > 0 ? ` ${truncateChars(summary, AGENT_ACTIVITY_DETAIL_MAX)}` : "";
663
+ return `\u2192 ${activity.tool}${suffix}`;
664
+ }
665
+ if (!activity.isError) return undefined;
666
+ const detail = activity.error
667
+ ? `: ${truncateChars(compactWhitespace(activity.error), AGENT_ACTIVITY_DETAIL_MAX)}`
668
+ : "";
669
+ return `\u2717 ${activity.tool} failed${detail}`;
670
+ }
671
+
672
+ export function shellQuote(value: string): string {
673
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
674
+ }
675
+
676
+ export type ShellDialect = "cmd" | "posix";
677
+
678
+ export interface ShellInvocation {
679
+ shell: string;
680
+ args: string[];
681
+ dialect: ShellDialect;
682
+ windowsVerbatimArguments: boolean;
683
+ }
684
+
685
+ export class ShellInvocationError extends Error {
686
+ readonly code = "unipi_bg_shell_invalid";
687
+
688
+ constructor(message: string) {
689
+ super(`unipi_bg_shell_invalid: ${message}`);
690
+ this.name = "ShellInvocationError";
691
+ }
692
+ }
693
+
694
+ function failShellInvocation(message: string): never {
695
+ throw new ShellInvocationError(message);
696
+ }
697
+
698
+ function shellErrorMessage(error: unknown): string {
699
+ return error instanceof Error ? error.message : String(error);
700
+ }
701
+
702
+ function isWindowsExecutablePath(path: string): boolean {
703
+ const extension = extname(path).toLowerCase();
704
+ return extension === ".exe" || extension === ".com";
705
+ }
706
+
707
+ function validateWindowsShellPath(path: string, label: string): string {
708
+ if (path.length === 0) failShellInvocation(`${label} is empty`);
709
+ if (!isAbsolute(path) && !win32.isAbsolute(path)) {
710
+ failShellInvocation(`${label} must be an absolute path`);
711
+ }
712
+ if (!isWindowsExecutablePath(path)) {
713
+ failShellInvocation(`${label} must point to a .exe or .com file`);
714
+ }
715
+ let stats: ReturnType<typeof statSync>;
716
+ try {
717
+ stats = statSync(path);
718
+ } catch (error) {
719
+ failShellInvocation(`${label} stat failed: ${shellErrorMessage(error)}`);
720
+ }
721
+ if (!stats.isFile()) failShellInvocation(`${label} must point to a regular file`);
722
+ return path;
723
+ }
724
+
725
+ function inspectWindowsShellCandidate(path: string): { found: true } | { found: false; diagnostic: string } {
726
+ if (!isWindowsExecutablePath(path)) {
727
+ return { found: false, diagnostic: `${path} is not a .exe or .com path` };
728
+ }
729
+ try {
730
+ const stats = statSync(path);
731
+ if (stats.isFile()) return { found: true };
732
+ return { found: false, diagnostic: `${path} is not a regular file` };
733
+ } catch (error) {
734
+ return { found: false, diagnostic: `${path}: ${shellErrorMessage(error)}` };
735
+ }
736
+ }
737
+
738
+ function windowsPathValue(env: NodeJS.ProcessEnv): string {
739
+ return env["PATH"] ?? env["Path"] ?? env["path"] ?? "";
740
+ }
741
+
742
+ function resolveWindowsBash(env: NodeJS.ProcessEnv): string {
743
+ const pathValue = windowsPathValue(env);
744
+ const diagnostics: string[] = [];
745
+ for (const dir of pathValue.split(";").filter((entry) => entry.length > 0)) {
746
+ for (const name of ["bash.exe", "bash.com"]) {
747
+ const candidate = join(dir, name);
748
+ const result = inspectWindowsShellCandidate(candidate);
749
+ if (result.found) return candidate;
750
+ diagnostics.push(result.diagnostic);
751
+ }
752
+ }
753
+ const suffix = diagnostics.length > 0 ? `: ${diagnostics.join("; ")}` : "";
754
+ failShellInvocation(`UNIPI_BG_SHELL=bash could not resolve bash.exe or bash.com on PATH${suffix}`);
755
+ }
756
+
757
+ function cmdShellInvocation(command: string, shell: string): ShellInvocation {
758
+ return {
759
+ shell,
760
+ args: ["/d", "/s", "/c", `"${command}"`],
761
+ dialect: "cmd",
762
+ windowsVerbatimArguments: true,
763
+ };
764
+ }
765
+
766
+ function posixShellInvocation(command: string, shell: string): ShellInvocation {
767
+ return { shell, args: ["-c", command], dialect: "posix", windowsVerbatimArguments: false };
768
+ }
769
+
770
+ export function shellInvocation(
771
+ command: string,
772
+ platform: NodeJS.Platform = process.platform,
773
+ env: NodeJS.ProcessEnv = process.env,
774
+ ): ShellInvocation {
775
+ if (platform !== "win32") {
776
+ const shell = env["SHELL"];
777
+ return posixShellInvocation(command, shell && shell.length > 0 ? shell : "/bin/sh");
778
+ }
779
+
780
+ const requestedShell = env["UNIPI_BG_SHELL"];
781
+ const requestedPath = env["UNIPI_BG_SHELL_PATH"];
782
+ if (requestedShell === undefined) {
783
+ if (requestedPath !== undefined) failShellInvocation("UNIPI_BG_SHELL_PATH requires UNIPI_BG_SHELL");
784
+ const comSpec = env["ComSpec"];
785
+ return cmdShellInvocation(command, comSpec && comSpec.length > 0 ? comSpec : "cmd.exe");
786
+ }
787
+ if (requestedShell !== "cmd" && requestedShell !== "bash") {
788
+ failShellInvocation("UNIPI_BG_SHELL must be exactly cmd or bash");
789
+ }
790
+ const explicitPath =
791
+ requestedPath !== undefined ? validateWindowsShellPath(requestedPath, "UNIPI_BG_SHELL_PATH") : undefined;
792
+ if (requestedShell === "cmd") {
793
+ const comSpec = env["ComSpec"];
794
+ return cmdShellInvocation(
795
+ command,
796
+ explicitPath ?? (comSpec && comSpec.length > 0 ? comSpec : "cmd.exe"),
797
+ );
798
+ }
799
+ return posixShellInvocation(command, explicitPath ?? resolveWindowsBash(env));
800
+ }
801
+
802
+ export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
803
+ const raw = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
804
+ return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
805
+ }
806
+
807
+ export function snapshot(task: BgTask): BgTaskSnapshot {
808
+ return {
809
+ id: task.id,
810
+ name: taskDisplayName(task),
811
+ command: task.command,
812
+ description: task.description,
813
+ status: task.status,
814
+ outputPath: task.outputPath,
815
+ cwd: task.cwd,
816
+ startTime: task.startTime,
817
+ endTime: task.endTime,
818
+ exitCode: task.exitCode,
819
+ signal: task.signal,
820
+ pid: task.pid,
821
+ bytesWritten: task.bytesWritten,
822
+ isAgent: task.isAgent,
823
+ error: task.error,
824
+ notified: task.notified,
825
+ notifyOnCompletion: task.notifyOnCompletion,
826
+ triggerOnCompletion: task.triggerOnCompletion,
827
+ timeoutSeconds: task.timeoutSeconds,
828
+ contextUsage: task.contextUsage,
829
+ tokenUsage: task.tokenUsage,
830
+ toolUsage: task.toolUsage,
831
+ model: task.model,
832
+ telemetryUnavailableReason: task.telemetryUnavailableReason,
833
+ attestationPath: task.attestationPath,
834
+ delegate: task.delegate,
835
+ fusion: task.fusion,
836
+ };
837
+ }
838
+
839
+ export async function boundedRead(
840
+ filePath: string,
841
+ maxBytes: number,
842
+ tail: boolean,
843
+ ): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
844
+ const stats = statSync(filePath);
845
+ const totalBytes = stats.size;
846
+ const bytesToRead = Math.min(totalBytes, maxBytes);
847
+ if (bytesToRead === 0) return { content: "", truncated: false, bytesRead: 0, totalBytes };
848
+
849
+ const file = await open(filePath, "r");
850
+ try {
851
+ const buffer = Buffer.alloc(bytesToRead);
852
+ const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
853
+ const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
854
+ return {
855
+ content: buffer.subarray(0, bytesRead).toString("utf8"),
856
+ truncated: totalBytes > bytesRead,
857
+ bytesRead,
858
+ totalBytes,
859
+ };
860
+ } finally {
861
+ await file.close();
862
+ }
863
+ }
864
+
865
+ // ── Semver comparison (ported; used by tests + potential updater integration) ──
866
+
867
+ interface ParsedSemver {
868
+ major: number;
869
+ minor: number;
870
+ patch: number;
871
+ prerelease: string[];
872
+ }
873
+
874
+ const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
875
+
876
+ export function parseSemver(value: string): ParsedSemver | undefined {
877
+ if (typeof value !== "string") return undefined;
878
+ const match = SEMVER_PATTERN.exec(value.trim());
879
+ if (!match) return undefined;
880
+ const majorRaw = match[1];
881
+ const minorRaw = match[2];
882
+ const patchRaw = match[3];
883
+ if (majorRaw === undefined || minorRaw === undefined || patchRaw === undefined) return undefined;
884
+ const major = Number(majorRaw);
885
+ const minor = Number(minorRaw);
886
+ const patch = Number(patchRaw);
887
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) return undefined;
888
+ const prerelease = match[4] !== undefined ? match[4].split(".") : [];
889
+ return { major, minor, patch, prerelease };
890
+ }
891
+
892
+ function comparePrerelease(a: string[], b: string[]): number {
893
+ if (a.length === 0 && b.length === 0) return 0;
894
+ if (a.length === 0) return 1;
895
+ if (b.length === 0) return -1;
896
+ const shared = Math.min(a.length, b.length);
897
+ for (let i = 0; i < shared; i++) {
898
+ const idA = a[i];
899
+ const idB = b[i];
900
+ if (idA === undefined || idB === undefined) break;
901
+ if (idA === idB) continue;
902
+ const numericA = /^\d+$/.test(idA);
903
+ const numericB = /^\d+$/.test(idB);
904
+ if (numericA && numericB) {
905
+ const diff = Number(idA) - Number(idB);
906
+ if (diff !== 0) return diff < 0 ? -1 : 1;
907
+ continue;
908
+ }
909
+ if (numericA) return -1;
910
+ if (numericB) return 1;
911
+ return idA < idB ? -1 : 1;
912
+ }
913
+ if (a.length === b.length) return 0;
914
+ return a.length < b.length ? -1 : 1;
915
+ }
916
+
917
+ /** Compare two semver strings. Returns -1/0/1, or undefined when either side is not valid semver. */
918
+ export function compareSemver(a: string, b: string): number | undefined {
919
+ const left = parseSemver(a);
920
+ const right = parseSemver(b);
921
+ if (!left || !right) return undefined;
922
+ if (left.major !== right.major) return left.major < right.major ? -1 : 1;
923
+ if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
924
+ if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
925
+ return comparePrerelease(left.prerelease, right.prerelease);
926
+ }
927
+
928
+ export function isNewerVersion(latest: string, current: string): boolean {
929
+ return compareSemver(latest, current) === 1;
930
+ }
931
+
932
+ export function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
933
+ if (tasks.length === 0) return "No background tasks in this Pi extension runtime.";
934
+ return tasks
935
+ .map((task) => {
936
+ const statusIcon =
937
+ task.status === "running" ? "\u25b6" : task.status === "completed" ? "\u2713" : task.status === "killed" ? "\u25a0" : "\u2717";
938
+ const age = formatDuration((task.endTime ?? now) - task.startTime);
939
+ const code = task.exitCode !== undefined ? ` exit=${String(task.exitCode)}` : "";
940
+ const pid = task.pid !== undefined ? ` pid=${String(task.pid)}` : "";
941
+ const error = task.error ? ` error=${truncateChars(task.error, 80)}` : "";
942
+ const telemetry = [
943
+ formatContextUsageSummary(task.contextUsage),
944
+ formatModelSummary(task.model),
945
+ formatTokenUsageSummary(task.tokenUsage),
946
+ formatToolUsageSummary(task.toolUsage),
947
+ ]
948
+ .filter(Boolean)
949
+ .join(" ");
950
+ const telemetryText = telemetry ? ` ${telemetry}` : "";
951
+ return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid}${telemetryText} \u2014 ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
952
+ })
953
+ .join("\n");
954
+ }
955
+
956
+ export const UPDATE_COMMAND = "/unipi:bg-update";
957
+
958
+ /** Footer segment shown only when a newer published version exists; undefined otherwise. */
959
+ export function formatUpdateSegment(latest: string | undefined, current: string): string | undefined {
960
+ if (!latest) return undefined;
961
+ if (!isNewerVersion(latest, current)) return undefined;
962
+ return `\u2b06 v${latest} ${UPDATE_COMMAND}`;
963
+ }