pi-background-tasks 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,469 +1,655 @@
1
- import { statSync } from "node:fs";
2
- import { open } from "node:fs/promises";
3
- import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
4
-
5
- export type TaskStatus = "running" | "completed" | "failed" | "killed";
6
- export type KillKind = "user" | "timeout" | "output_cap" | "shutdown";
7
-
8
- export type TaskContextUsage = {
9
- tokens: number | null;
10
- contextWindow: number;
11
- percent: number | null;
12
- };
13
-
14
- export type TaskTokenUsage = {
15
- input: number;
16
- output: number;
17
- cacheRead: number;
18
- cacheWrite: number;
19
- totalTokens: number;
20
- costTotal?: number;
21
- };
22
-
23
- export type TaskToolUsage = {
24
- total: number;
25
- failed: number;
26
- byName: Record<string, number>;
27
- };
28
-
29
- export type BgTaskSnapshot = {
30
- id: string;
31
- name?: string | undefined;
32
- command: string;
33
- description?: string | undefined;
34
- status: TaskStatus;
35
- outputPath: string;
36
- cwd: string;
37
- startTime: number;
38
- endTime?: number | undefined;
39
- exitCode?: number | null | undefined;
40
- signal?: string | null | undefined;
41
- pid?: number | undefined;
42
- bytesWritten: number;
43
- isAgent: boolean;
44
- error?: string | undefined;
45
- notified: boolean;
46
- notifyOnCompletion: boolean;
47
- triggerOnCompletion: boolean;
48
- timeoutSeconds?: number | undefined;
49
- contextUsage?: TaskContextUsage | undefined;
50
- tokenUsage?: TaskTokenUsage | undefined;
51
- toolUsage?: TaskToolUsage | undefined;
52
- model?: string | undefined;
53
- };
54
-
55
- export type BgTask = Omit<BgTaskSnapshot, "name"> & {
56
- name: string;
57
- outputAbsPath: string;
58
- metadataAbsPath: string;
59
- child?: import("./registry.js").BackgroundTaskChildProcess | undefined;
60
- stream?: import("node:fs").WriteStream | undefined;
61
- timeoutHandle?: NodeJS.Timeout | undefined;
62
- killKind?: KillKind | undefined;
63
- killSignalSent?: boolean | undefined;
64
- capExceeded?: boolean | undefined;
65
- finalized?: boolean | undefined;
66
- contextUsageBuffer?: string | undefined;
67
- waiters: Array<() => void>;
68
- };
69
-
70
- export type BgRunDetails = {
71
- task: BgTaskSnapshot;
72
- };
73
-
74
- export type BgStatusDetails = {
75
- tasks: BgTaskSnapshot[];
76
- };
77
-
78
- export type BgLogsDetails = {
79
- task: BgTaskSnapshot;
80
- path: string;
81
- bytesRead: number;
82
- truncated: boolean;
83
- tail: boolean;
84
- };
85
-
86
- export type BgKillDetails = {
87
- task: BgTaskSnapshot;
88
- message: string;
89
- };
90
-
91
- export type StartTaskOptions = {
92
- name?: string | undefined;
93
- description?: string | undefined;
94
- isAgent?: boolean | undefined;
95
- timeoutSeconds?: number | undefined;
96
- notifyOnCompletion?: boolean | undefined;
97
- triggerOnCompletion?: boolean | undefined;
98
- };
1
+ import { statSync, type WriteStream } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent';
4
+ import type { BackgroundTaskChildProcess } from './registry.js';
5
+
6
+ export const TASK_STATUS_VALUES = ['running', 'completed', 'failed', 'killed'] as const;
7
+ export const TERMINAL_TASK_STATUS_VALUES = ['completed', 'failed', 'killed'] as const;
8
+
9
+ export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
10
+ export type TerminalTaskStatus = (typeof TERMINAL_TASK_STATUS_VALUES)[number];
11
+ export type KillKind = 'user' | 'timeout' | 'output_cap' | 'shutdown';
12
+
13
+ export type JsonObject = Readonly<Record<PropertyKey, unknown>>;
14
+
15
+ export interface TaskContextUsage {
16
+ tokens: number | null;
17
+ contextWindow: number;
18
+ percent: number | null;
19
+ }
20
+
21
+ export interface TaskTokenUsage {
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheWrite: number;
26
+ totalTokens: number;
27
+ costTotal?: number;
28
+ }
29
+
30
+ export interface TaskToolUsage {
31
+ total: number;
32
+ failed: number;
33
+ byName: Record<string, number>;
34
+ }
35
+
36
+ export interface BgTaskSnapshot {
37
+ id: string;
38
+ name?: string | undefined;
39
+ command: string;
40
+ description?: string | undefined;
41
+ status: TaskStatus;
42
+ outputPath: string;
43
+ cwd: string;
44
+ startTime: number;
45
+ endTime?: number | undefined;
46
+ exitCode?: number | null | undefined;
47
+ signal?: string | null | undefined;
48
+ pid?: number | undefined;
49
+ bytesWritten: number;
50
+ isAgent: boolean;
51
+ error?: string | undefined;
52
+ notified: boolean;
53
+ notifyOnCompletion: boolean;
54
+ triggerOnCompletion: boolean;
55
+ timeoutSeconds?: number | undefined;
56
+ contextUsage?: TaskContextUsage | undefined;
57
+ tokenUsage?: TaskTokenUsage | undefined;
58
+ toolUsage?: TaskToolUsage | undefined;
59
+ model?: string | undefined;
60
+ attestationPath?: string | undefined;
61
+ }
62
+
63
+ export interface AttestedPiTaskFiles {
64
+ eventsPath: string;
65
+ stderrPath: string;
66
+ wrapperPath: string;
67
+ attestationPath: string;
68
+ }
69
+
70
+ export interface AttestedPiTaskSnapshot extends BgTaskSnapshot {
71
+ attestedPi?: AttestedPiTaskFiles | undefined;
72
+ }
73
+
74
+ export interface BgTask extends Omit<BgTaskSnapshot, 'name'> {
75
+ name: string;
76
+ outputAbsPath: string;
77
+ metadataAbsPath: string;
78
+ eventsAbsPath?: string | undefined;
79
+ stderrAbsPath?: string | undefined;
80
+ wrapperAbsPath?: string | undefined;
81
+ attestationAbsPath?: string | undefined;
82
+ child?: BackgroundTaskChildProcess | undefined;
83
+ stream?: WriteStream | undefined;
84
+ timeoutHandle?: NodeJS.Timeout | undefined;
85
+ killKind?: KillKind | undefined;
86
+ killSignalSent?: boolean | undefined;
87
+ capExceeded?: boolean | undefined;
88
+ finalized?: boolean | undefined;
89
+ terminalPublished?: boolean | undefined;
90
+ terminalPublishInFlight?: boolean | undefined;
91
+ terminalPublishRetryHandle?: NodeJS.Timeout | undefined;
92
+ /** Optional protocol barrier used by EventBus run requests so early child exits cannot publish before the run response is observable. */
93
+ terminalPublicationGate?: Promise<void> | undefined;
94
+ contextUsageBuffer?: string | undefined;
95
+ /** True when this task launched a telemetry-wrapped Pi agent; its stdout carries control lines, not raw output. */
96
+ telemetryWrapped?: boolean | undefined;
97
+ /** Partial trailing stdout line held between chunks while reconstructing wrapped-agent control lines. */
98
+ agentStdoutBuffer?: string | undefined;
99
+ attestationPath?: string | undefined;
100
+ attestedPi?: AttestedPiTaskFiles | undefined;
101
+ metadataWriteChain?: Promise<void> | undefined;
102
+ waiters: Array<() => void>;
103
+ }
104
+
105
+ export interface BgRunDetails {
106
+ task: BgTaskSnapshot;
107
+ }
108
+
109
+ export interface BgStatusDetails {
110
+ tasks: BgTaskSnapshot[];
111
+ }
112
+
113
+ export interface BgLogsDetails {
114
+ task: BgTaskSnapshot;
115
+ path: string;
116
+ bytesRead: number;
117
+ truncated: boolean;
118
+ tail: boolean;
119
+ }
120
+
121
+ export interface BgKillDetails {
122
+ task: BgTaskSnapshot;
123
+ message: string;
124
+ }
125
+
126
+ export interface StartTaskOptions {
127
+ name?: string | undefined;
128
+ description?: string | undefined;
129
+ isAgent?: boolean | undefined;
130
+ timeoutSeconds?: number | undefined;
131
+ notifyOnCompletion?: boolean | undefined;
132
+ triggerOnCompletion?: boolean | undefined;
133
+ /** @internal EventBus protocol barrier; callers should not set this outside the extension service. */
134
+ terminalPublicationGate?: Promise<void> | undefined;
135
+ }
136
+
137
+ export interface StartAttestedPiTaskOptions {
138
+ name: string;
139
+ provider: string;
140
+ model: string;
141
+ prompt: string;
142
+ reportPath: string;
143
+ extraPiArgs?: string[] | undefined;
144
+ thinking?: string | undefined;
145
+ timeoutSeconds?: number | undefined;
146
+ }
99
147
 
100
148
  export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
101
149
  export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
102
150
  export const COMMAND_PREVIEW_CHARS = 90;
151
+ const parseJsonValue: (text: string) => unknown = globalThis.JSON.parse;
152
+
153
+ export function isJsonObject(value: unknown): value is JsonObject {
154
+ return typeof value === 'object' && value !== null;
155
+ }
156
+
157
+ export function parseJsonText(text: string): unknown {
158
+ return parseJsonValue(text);
159
+ }
103
160
 
104
161
  export function sanitizePathSegment(value: string): string {
105
- const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
106
- return sanitized || "session";
162
+ const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
163
+ return sanitized || 'session';
107
164
  }
108
165
 
109
166
  export function stripMatchingQuotes(value: string): string {
110
- const trimmed = value.trim();
111
- if (trimmed.length >= 2) {
112
- const first = trimmed[0];
113
- const last = trimmed[trimmed.length - 1];
114
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
115
- return trimmed.slice(1, -1);
116
- }
117
- }
118
- return trimmed;
167
+ const trimmed = value.trim();
168
+ if (trimmed.length >= 2) {
169
+ const first = trimmed[0];
170
+ const last = trimmed[trimmed.length - 1];
171
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
172
+ return trimmed.slice(1, -1);
173
+ }
174
+ }
175
+ return trimmed;
119
176
  }
120
177
 
121
178
  export function compactWhitespace(value: string): string {
122
- return value.replace(/\s+/g, " ").trim();
179
+ return value.replace(/\s+/g, ' ').trim();
123
180
  }
124
181
 
125
182
  export function truncateChars(value: string, maxChars: number): string {
126
- if (value.length <= maxChars) return value;
127
- return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
183
+ if (value.length <= maxChars) return value;
184
+ return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
128
185
  }
129
186
 
130
187
  export function normalizeTaskName(value: unknown): string | undefined {
131
- if (typeof value !== "string") return undefined;
132
- const normalized = compactWhitespace(stripMatchingQuotes(value));
133
- if (!normalized) return undefined;
134
- return truncateChars(normalized, 80);
188
+ if (typeof value !== 'string') return undefined;
189
+ const normalized = compactWhitespace(stripMatchingQuotes(value));
190
+ if (!normalized) return undefined;
191
+ return truncateChars(normalized, 80);
135
192
  }
136
193
 
137
194
  export function deriveTaskNameFromCommand(command: string): string {
138
- const normalized = compactWhitespace(stripMatchingQuotes(command));
139
- if (!normalized) return "Background task";
140
-
141
- const packageScript = normalized.match(/^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/);
142
- if (packageScript) {
143
- const runner = packageScript[1];
144
- const run = packageScript[2] ? " run" : "";
145
- const script = packageScript[3];
146
- return truncateChars(`${runner}${run} ${script}`, 48);
147
- }
148
-
149
- const words = normalized.split(/\s+/).slice(0, 5).join(" ");
150
- return truncateChars(words || normalized, 48);
195
+ const normalized = compactWhitespace(stripMatchingQuotes(command));
196
+ if (!normalized) return 'Background task';
197
+
198
+ const packageScript = /^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/.exec(normalized);
199
+ if (packageScript) {
200
+ const runner = packageScript[1] ?? 'npm';
201
+ const run = packageScript[2] !== undefined ? ' run' : '';
202
+ const script = packageScript[3] ?? '';
203
+ return truncateChars(`${runner}${run} ${script}`, 48);
204
+ }
205
+
206
+ const words = normalized.split(/\s+/).slice(0, 5).join(' ');
207
+ return truncateChars(words.length > 0 ? words : normalized, 48);
151
208
  }
152
209
 
153
- export function taskDisplayName(task: { name?: string | undefined; description?: string | undefined; command?: string | undefined; id?: string | undefined }): string {
154
- return normalizeTaskName(task.name)
155
- ?? normalizeTaskName(task.description)
156
- ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined)
157
- ?? task.id
158
- ?? "Background task";
210
+ export function taskDisplayName(task: {
211
+ name?: string | undefined;
212
+ description?: string | undefined;
213
+ command?: string | undefined;
214
+ id?: string | undefined;
215
+ }): string {
216
+ const commandName =
217
+ task.command && task.command.length > 0 ? deriveTaskNameFromCommand(task.command) : undefined;
218
+ return (
219
+ normalizeTaskName(task.name) ??
220
+ normalizeTaskName(task.description) ??
221
+ commandName ??
222
+ task.id ??
223
+ 'Background task'
224
+ );
159
225
  }
160
226
 
161
227
  function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
162
- const input = valueAndRest.trimStart();
163
- if (!input) return undefined;
164
- const quote = input[0];
165
- if (quote === '"' || quote === "'") {
166
- let escaped = false;
167
- let value = "";
168
- for (let i = 1; i < input.length; i++) {
169
- const char = input.charAt(i);
170
- if (escaped) {
171
- value += char;
172
- escaped = false;
173
- continue;
174
- }
175
- if (char === "\\") {
176
- escaped = true;
177
- continue;
178
- }
179
- if (char === quote) {
180
- return { value, rest: input.slice(i + 1).trimStart() };
181
- }
182
- value += char;
183
- }
184
- return undefined;
185
- }
186
- const match = input.match(/^(\S+)(?:\s+([\s\S]*))?$/);
187
- if (!match) return undefined;
188
- const parsedValue = match[1];
189
- if (parsedValue === undefined) return undefined;
190
- return { value: parsedValue, rest: match[2]?.trimStart() ?? "" };
191
- }
192
-
193
- export function parseBgCommandArgs(args: string): { name?: string; command: string; isAgent: boolean } {
194
- let input = args.trim();
195
- let name: string | undefined;
196
- let isAgent = false;
197
-
198
- while (input) {
199
- let consumed = false;
200
- for (const prefix of ["--name=", "-n="]) {
201
- if (input.startsWith(prefix)) {
202
- const parsed = parseNameValueAndRest(input.slice(prefix.length));
203
- if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
204
- name = normalizeTaskName(parsed.value);
205
- input = parsed.rest;
206
- consumed = true;
207
- break;
208
- }
209
- }
210
- if (consumed) continue;
211
-
212
- for (const prefix of ["--name", "-n"]) {
213
- if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
214
- const parsed = parseNameValueAndRest(input.slice(prefix.length));
215
- if (!parsed) throw new Error(`${prefix} requires a task name`);
216
- name = normalizeTaskName(parsed.value);
217
- input = parsed.rest;
218
- consumed = true;
219
- break;
220
- }
221
- }
222
- if (consumed) continue;
223
-
224
- for (const flag of ["--agent", "--llm-agent"]) {
225
- if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
226
- isAgent = true;
227
- input = input.slice(flag.length).trimStart();
228
- consumed = true;
229
- break;
230
- }
231
- }
232
- if (consumed) continue;
233
-
234
- for (const flag of ["--script", "--no-agent"]) {
235
- if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
236
- isAgent = false;
237
- input = input.slice(flag.length).trimStart();
238
- consumed = true;
239
- break;
240
- }
241
- }
242
- if (consumed) continue;
243
-
244
- if (input === "--") {
245
- input = "";
246
- break;
247
- }
248
- if (input.startsWith("-- ")) {
249
- input = input.slice(3).trimStart();
250
- break;
251
- }
252
- break;
253
- }
254
-
255
- return name ? { name, command: input, isAgent } : { command: input, isAgent };
228
+ const input = valueAndRest.trimStart();
229
+ if (!input) return undefined;
230
+ const quote = input[0];
231
+ if (quote === '"' || quote === "'") {
232
+ let escaped = false;
233
+ let value = '';
234
+ for (let i = 1; i < input.length; i++) {
235
+ const char = input.charAt(i);
236
+ if (escaped) {
237
+ value += char;
238
+ escaped = false;
239
+ continue;
240
+ }
241
+ if (char === '\\') {
242
+ escaped = true;
243
+ continue;
244
+ }
245
+ if (char === quote) {
246
+ return { value, rest: input.slice(i + 1).trimStart() };
247
+ }
248
+ value += char;
249
+ }
250
+ return undefined;
251
+ }
252
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(input);
253
+ if (!match) return undefined;
254
+ const parsedValue = match[1];
255
+ if (parsedValue === undefined) return undefined;
256
+ return { value: parsedValue, rest: match[2]?.trimStart() ?? '' };
257
+ }
258
+
259
+ export function parseBgCommandArgs(args: string): {
260
+ name?: string;
261
+ command: string;
262
+ isAgent: boolean;
263
+ } {
264
+ let input = args.trim();
265
+ let name: string | undefined;
266
+ let isAgent = false;
267
+
268
+ while (input) {
269
+ let consumed = false;
270
+ for (const prefix of ['--name=', '-n=']) {
271
+ if (input.startsWith(prefix)) {
272
+ const parsed = parseNameValueAndRest(input.slice(prefix.length));
273
+ if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
274
+ name = normalizeTaskName(parsed.value);
275
+ input = parsed.rest;
276
+ consumed = true;
277
+ break;
278
+ }
279
+ }
280
+ if (consumed) continue;
281
+
282
+ for (const prefix of ['--name', '-n']) {
283
+ if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
284
+ const parsed = parseNameValueAndRest(input.slice(prefix.length));
285
+ if (!parsed) throw new Error(`${prefix} requires a task name`);
286
+ name = normalizeTaskName(parsed.value);
287
+ input = parsed.rest;
288
+ consumed = true;
289
+ break;
290
+ }
291
+ }
292
+ if (consumed) continue;
293
+
294
+ for (const flag of ['--agent', '--llm-agent']) {
295
+ if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
296
+ isAgent = true;
297
+ input = input.slice(flag.length).trimStart();
298
+ consumed = true;
299
+ break;
300
+ }
301
+ }
302
+ if (consumed) continue;
303
+
304
+ for (const flag of ['--script', '--no-agent']) {
305
+ if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
306
+ isAgent = false;
307
+ input = input.slice(flag.length).trimStart();
308
+ consumed = true;
309
+ break;
310
+ }
311
+ }
312
+ if (consumed) continue;
313
+
314
+ if (input === '--') {
315
+ input = '';
316
+ break;
317
+ }
318
+ if (input.startsWith('-- ')) {
319
+ input = input.slice(3).trimStart();
320
+ break;
321
+ }
322
+ break;
323
+ }
324
+
325
+ return name ? { name, command: input, isAgent } : { command: input, isAgent };
256
326
  }
257
327
 
258
328
  export function formatDuration(ms: number): string {
259
- if (ms < 1000) return `${ms}ms`;
260
- const seconds = Math.floor(ms / 1000);
261
- if (seconds < 60) return `${seconds}s`;
262
- const minutes = Math.floor(seconds / 60);
263
- const remSeconds = seconds % 60;
264
- if (minutes < 60) return `${minutes}m${remSeconds ? `${remSeconds}s` : ""}`;
265
- const hours = Math.floor(minutes / 60);
266
- const remMinutes = minutes % 60;
267
- return `${hours}h${remMinutes ? `${remMinutes}m` : ""}`;
329
+ if (ms < 1000) return `${String(ms)}ms`;
330
+ const seconds = Math.floor(ms / 1000);
331
+ if (seconds < 60) return `${String(seconds)}s`;
332
+ const minutes = Math.floor(seconds / 60);
333
+ const remSeconds = seconds % 60;
334
+ if (minutes < 60) return `${String(minutes)}m${remSeconds > 0 ? `${String(remSeconds)}s` : ''}`;
335
+ const hours = Math.floor(minutes / 60);
336
+ const remMinutes = minutes % 60;
337
+ return `${String(hours)}h${remMinutes > 0 ? `${String(remMinutes)}m` : ''}`;
268
338
  }
269
339
 
270
340
  export function formatCompactNumber(count: number): string {
271
- const normalized = Math.max(0, Math.floor(count));
272
- if (normalized < 1000) return normalized.toString();
273
- if (normalized < 10000) return `${(normalized / 1000).toFixed(1)}k`;
274
- if (normalized < 1000000) return `${Math.round(normalized / 1000)}k`;
275
- if (normalized < 10000000) return `${(normalized / 1000000).toFixed(1)}M`;
276
- return `${Math.round(normalized / 1000000)}M`;
341
+ const normalized = Math.max(0, Math.floor(count));
342
+ if (normalized < 1000) return normalized.toString();
343
+ if (normalized < 10000) return `${(normalized / 1000).toFixed(1)}k`;
344
+ if (normalized < 1000000) return `${String(Math.round(normalized / 1000))}k`;
345
+ if (normalized < 10000000) return `${(normalized / 1000000).toFixed(1)}M`;
346
+ return `${String(Math.round(normalized / 1000000))}M`;
277
347
  }
278
348
 
279
349
  export function formatContextUsageSummary(usage?: TaskContextUsage): string | undefined {
280
- if (!usage || !usage.contextWindow) return undefined;
281
- const window = formatCompactNumber(usage.contextWindow);
282
- if (usage.percent === null || usage.tokens === null) return `ctx=?/${window}`;
283
- return `ctx=${usage.percent.toFixed(1)}%/${window}`;
350
+ if (usage?.contextWindow === undefined || usage.contextWindow <= 0) return undefined;
351
+ const window = formatCompactNumber(usage.contextWindow);
352
+ if (usage.percent === null || usage.tokens === null) return `ctx=?/${window}`;
353
+ return `ctx=${usage.percent.toFixed(1)}%/${window}`;
284
354
  }
285
355
 
286
356
  export function formatTokenUsageSummary(usage?: TaskTokenUsage): string | undefined {
287
- if (!usage || usage.totalTokens <= 0) return undefined;
288
- return `tokens=${formatCompactNumber(usage.totalTokens)}`;
357
+ if (!usage || usage.totalTokens <= 0) return undefined;
358
+ return `tokens=${formatCompactNumber(usage.totalTokens)}`;
289
359
  }
290
360
 
291
361
  export function formatToolUsageSummary(usage?: TaskToolUsage): string | undefined {
292
- if (!usage || (usage.total <= 0 && usage.failed <= 0)) return undefined;
293
- const failed = usage.failed > 0 ? ` failed=${usage.failed}` : "";
294
- return `tools=${usage.total}${failed}`;
362
+ if (!usage || (usage.total <= 0 && usage.failed <= 0)) return undefined;
363
+ const failed = usage.failed > 0 ? ` failed=${String(usage.failed)}` : '';
364
+ return `tools=${String(usage.total)}${failed}`;
295
365
  }
296
366
 
297
367
  export function formatModelSummary(model?: string): string | undefined {
298
- if (!model) return undefined;
299
- return `model=${model}`;
368
+ if (!model) return undefined;
369
+ return `model=${model}`;
370
+ }
371
+
372
+ /**
373
+ * Human-readable activity transcript for telemetry-wrapped Pi agents.
374
+ *
375
+ * The wrapper emits one `background-task-activity` control line per meaningful
376
+ * child-agent event (assistant text, reasoning, tool start, tool end) so the
377
+ * registry can render "what the agent is actually doing" into the task output
378
+ * file instead of leaking raw telemetry JSON. Both the parser and the formatter
379
+ * are pure so the visible transcript is fully unit-testable.
380
+ */
381
+ export const AGENT_ACTIVITY_TYPE = 'background-task-activity';
382
+ const AGENT_ACTIVITY_DETAIL_MAX = 80;
383
+
384
+ export type AgentActivity =
385
+ | { kind: 'assistant_text'; text: string }
386
+ | { kind: 'reasoning'; text: string }
387
+ | { kind: 'tool_start'; tool: string; argsSummary: string }
388
+ | { kind: 'tool_end'; tool: string; isError: boolean; error?: string };
389
+
390
+ interface AgentActivityPayload extends JsonObject {
391
+ readonly type?: unknown;
392
+ readonly kind?: unknown;
393
+ readonly text?: unknown;
394
+ readonly tool?: unknown;
395
+ readonly argsSummary?: unknown;
396
+ readonly isError?: unknown;
397
+ readonly error?: unknown;
398
+ }
399
+
400
+ function readActivityString(
401
+ record: AgentActivityPayload,
402
+ key: 'text' | 'tool' | 'argsSummary' | 'error',
403
+ ): string | undefined {
404
+ const value = record[key];
405
+ return typeof value === 'string' ? value : undefined;
406
+ }
407
+
408
+ /** Narrow a parsed `background-task-activity` control payload into a typed {@link AgentActivity}. */
409
+ export function parseAgentActivity(payload: unknown): AgentActivity | undefined {
410
+ if (!isJsonObject(payload)) return undefined;
411
+ const record: AgentActivityPayload = payload;
412
+ if (record.type !== AGENT_ACTIVITY_TYPE) return undefined;
413
+ const kind = record.kind;
414
+ if (kind === 'assistant_text' || kind === 'reasoning') {
415
+ const text = readActivityString(record, 'text');
416
+ if (text === undefined) return undefined;
417
+ return { kind, text };
418
+ }
419
+ if (kind === 'tool_start') {
420
+ const tool = readActivityString(record, 'tool');
421
+ if (!tool) return undefined;
422
+ return { kind, tool, argsSummary: readActivityString(record, 'argsSummary') ?? '' };
423
+ }
424
+ if (kind === 'tool_end') {
425
+ const tool = readActivityString(record, 'tool');
426
+ if (!tool) return undefined;
427
+ const activity: AgentActivity = { kind, tool, isError: record.isError === true };
428
+ const error = readActivityString(record, 'error');
429
+ if (error !== undefined && error.trim().length > 0) activity.error = error;
430
+ return activity;
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ /**
436
+ * Render an {@link AgentActivity} into a single transcript line, or `undefined`
437
+ * when the event carries nothing worth showing (blank text, a successful tool
438
+ * end). Successful tool ends are intentionally silent: the matching `→` start
439
+ * line already announced the call, and the next line implies completion.
440
+ */
441
+ export function formatAgentActivityLine(activity: AgentActivity): string | undefined {
442
+ if (activity.kind === 'assistant_text') {
443
+ const text = activity.text.replace(/\s+$/u, '');
444
+ return text.trim().length > 0 ? text : undefined;
445
+ }
446
+ if (activity.kind === 'reasoning') {
447
+ const text = activity.text.replace(/\s+$/u, '');
448
+ return text.trim().length > 0 ? `\u2026 ${text}` : undefined;
449
+ }
450
+ if (activity.kind === 'tool_start') {
451
+ const summary = compactWhitespace(activity.argsSummary);
452
+ const suffix =
453
+ summary.length > 0 ? ` ${truncateChars(summary, AGENT_ACTIVITY_DETAIL_MAX)}` : '';
454
+ return `\u2192 ${activity.tool}${suffix}`;
455
+ }
456
+ if (!activity.isError) return undefined;
457
+ const detail = activity.error
458
+ ? `: ${truncateChars(compactWhitespace(activity.error), AGENT_ACTIVITY_DETAIL_MAX)}`
459
+ : '';
460
+ return `\u2717 ${activity.tool} failed${detail}`;
300
461
  }
301
462
 
302
463
  export function shellQuote(value: string): string {
303
- return `'${value.replace(/'/g, `'"'"'`)}'`;
464
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
304
465
  }
305
466
 
306
467
  export function shellInvocation(
307
- command: string,
308
- platform: NodeJS.Platform = process.platform,
309
- env: NodeJS.ProcessEnv = process.env,
468
+ command: string,
469
+ platform: NodeJS.Platform = process.platform,
470
+ env: NodeJS.ProcessEnv = process.env,
310
471
  ): { shell: string; args: string[] } {
311
- if (platform === "win32") {
312
- return { shell: env["ComSpec"] || "cmd.exe", args: ["/d", "/s", "/c", command] };
313
- }
314
- return { shell: env["SHELL"] || "/bin/sh", args: ["-c", command] };
472
+ if (platform === 'win32') {
473
+ const comSpec = env['ComSpec'];
474
+ return {
475
+ shell: comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe',
476
+ args: ['/d', '/s', '/c', command],
477
+ };
478
+ }
479
+ const shell = env['SHELL'];
480
+ return { shell: shell && shell.length > 0 ? shell : '/bin/sh', args: ['-c', command] };
315
481
  }
316
482
 
317
483
  export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
318
- const raw = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
319
- return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
484
+ const raw = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
485
+ return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
320
486
  }
321
487
 
322
488
  export function snapshot(task: BgTask): BgTaskSnapshot {
323
- return {
324
- id: task.id,
325
- name: taskDisplayName(task),
326
- command: task.command,
327
- description: task.description,
328
- status: task.status,
329
- outputPath: task.outputPath,
330
- cwd: task.cwd,
331
- startTime: task.startTime,
332
- endTime: task.endTime,
333
- exitCode: task.exitCode,
334
- signal: task.signal,
335
- pid: task.pid,
336
- bytesWritten: task.bytesWritten,
337
- isAgent: task.isAgent,
338
- error: task.error,
339
- notified: task.notified,
340
- notifyOnCompletion: task.notifyOnCompletion,
341
- triggerOnCompletion: task.triggerOnCompletion,
342
- timeoutSeconds: task.timeoutSeconds,
343
- contextUsage: task.contextUsage,
344
- tokenUsage: task.tokenUsage,
345
- toolUsage: task.toolUsage,
346
- model: task.model,
347
- };
489
+ return {
490
+ id: task.id,
491
+ name: taskDisplayName(task),
492
+ command: task.command,
493
+ description: task.description,
494
+ status: task.status,
495
+ outputPath: task.outputPath,
496
+ cwd: task.cwd,
497
+ startTime: task.startTime,
498
+ endTime: task.endTime,
499
+ exitCode: task.exitCode,
500
+ signal: task.signal,
501
+ pid: task.pid,
502
+ bytesWritten: task.bytesWritten,
503
+ isAgent: task.isAgent,
504
+ error: task.error,
505
+ notified: task.notified,
506
+ notifyOnCompletion: task.notifyOnCompletion,
507
+ triggerOnCompletion: task.triggerOnCompletion,
508
+ timeoutSeconds: task.timeoutSeconds,
509
+ contextUsage: task.contextUsage,
510
+ tokenUsage: task.tokenUsage,
511
+ toolUsage: task.toolUsage,
512
+ model: task.model,
513
+ attestationPath: task.attestationPath,
514
+ };
348
515
  }
349
516
 
350
517
  export function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
351
- if (tasks.length === 0) return "No background tasks in this Pi extension runtime.";
352
- return tasks.map((task) => {
353
- const statusIcon = task.status === "running" ? "▶" : task.status === "completed" ? "✓" : task.status === "killed" ? "■" : "✗";
354
- const age = formatDuration((task.endTime ?? now) - task.startTime);
355
- const code = task.exitCode !== undefined ? ` exit=${task.exitCode}` : "";
356
- const pid = task.pid ? ` pid=${task.pid}` : "";
357
- const error = task.error ? ` error=${truncateChars(task.error, 80)}` : "";
358
- const telemetry = [
359
- formatContextUsageSummary(task.contextUsage),
360
- formatModelSummary(task.model),
361
- formatTokenUsageSummary(task.tokenUsage),
362
- formatToolUsageSummary(task.toolUsage),
363
- ].filter(Boolean).join(" ");
364
- const telemetryText = telemetry ? ` ${telemetry}` : "";
365
- return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid}${telemetryText} ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
366
- }).join("\n");
518
+ if (tasks.length === 0) return 'No background tasks in this Pi extension runtime.';
519
+ return tasks
520
+ .map((task) => {
521
+ const statusIcon =
522
+ task.status === 'running'
523
+ ? '▶'
524
+ : task.status === 'completed'
525
+ ? '✓'
526
+ : task.status === 'killed'
527
+ ? '■'
528
+ : '✗';
529
+ const age = formatDuration((task.endTime ?? now) - task.startTime);
530
+ const code = task.exitCode !== undefined ? ` exit=${String(task.exitCode)}` : '';
531
+ const pid = task.pid !== undefined ? ` pid=${String(task.pid)}` : '';
532
+ const error = task.error ? ` error=${truncateChars(task.error, 80)}` : '';
533
+ const telemetry = [
534
+ formatContextUsageSummary(task.contextUsage),
535
+ formatModelSummary(task.model),
536
+ formatTokenUsageSummary(task.tokenUsage),
537
+ formatToolUsageSummary(task.toolUsage),
538
+ ]
539
+ .filter(Boolean)
540
+ .join(' ');
541
+ const telemetryText = telemetry ? ` ${telemetry}` : '';
542
+ return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid}${telemetryText} — ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
543
+ })
544
+ .join('\n');
367
545
  }
368
546
 
369
547
  export async function boundedRead(
370
- filePath: string,
371
- maxBytes: number,
372
- tail: boolean,
548
+ filePath: string,
549
+ maxBytes: number,
550
+ tail: boolean,
373
551
  ): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
374
- const stats = statSync(filePath);
375
- const totalBytes = stats.size;
376
- const bytesToRead = Math.min(totalBytes, maxBytes);
377
- if (bytesToRead === 0) return { content: "", truncated: false, bytesRead: 0, totalBytes };
378
-
379
- const file = await open(filePath, "r");
380
- try {
381
- const buffer = Buffer.alloc(bytesToRead);
382
- const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
383
- const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
384
- return {
385
- content: buffer.subarray(0, bytesRead).toString("utf8"),
386
- truncated: totalBytes > bytesRead,
387
- bytesRead,
388
- totalBytes,
389
- };
390
- } finally {
391
- await file.close();
392
- }
552
+ const stats = statSync(filePath);
553
+ const totalBytes = stats.size;
554
+ const bytesToRead = Math.min(totalBytes, maxBytes);
555
+ if (bytesToRead === 0) return { content: '', truncated: false, bytesRead: 0, totalBytes };
556
+
557
+ const file = await open(filePath, 'r');
558
+ try {
559
+ const buffer = Buffer.alloc(bytesToRead);
560
+ const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
561
+ const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
562
+ return {
563
+ content: buffer.subarray(0, bytesRead).toString('utf8'),
564
+ truncated: totalBytes > bytesRead,
565
+ bytesRead,
566
+ totalBytes,
567
+ };
568
+ } finally {
569
+ await file.close();
570
+ }
393
571
  }
394
572
 
395
573
  export function escapeXml(value: string): string {
396
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
574
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
397
575
  }
398
576
 
399
- export const UPDATE_COMMAND = "/bg-update";
577
+ export const UPDATE_COMMAND = '/bg-update';
400
578
 
401
- type ParsedSemver = {
402
- major: number;
403
- minor: number;
404
- patch: number;
405
- prerelease: string[];
406
- };
579
+ interface ParsedSemver {
580
+ major: number;
581
+ minor: number;
582
+ patch: number;
583
+ prerelease: string[];
584
+ }
407
585
 
408
586
  const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
409
587
 
410
588
  export function parseSemver(value: string): ParsedSemver | undefined {
411
- if (typeof value !== "string") return undefined;
412
- const match = value.trim().match(SEMVER_PATTERN);
413
- if (!match) return undefined;
414
- const major = Number(match[1]);
415
- const minor = Number(match[2]);
416
- const patch = Number(match[3]);
417
- if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) return undefined;
418
- const prerelease = match[4] ? match[4].split(".") : [];
419
- return { major, minor, patch, prerelease };
589
+ if (typeof value !== 'string') return undefined;
590
+ const match = SEMVER_PATTERN.exec(value.trim());
591
+ if (!match) return undefined;
592
+ const majorRaw = match[1];
593
+ const minorRaw = match[2];
594
+ const patchRaw = match[3];
595
+ if (majorRaw === undefined || minorRaw === undefined || patchRaw === undefined) return undefined;
596
+ const major = Number(majorRaw);
597
+ const minor = Number(minorRaw);
598
+ const patch = Number(patchRaw);
599
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch))
600
+ return undefined;
601
+ const prerelease = match[4] !== undefined ? match[4].split('.') : [];
602
+ return { major, minor, patch, prerelease };
420
603
  }
421
604
 
422
605
  function comparePrerelease(a: string[], b: string[]): number {
423
- if (a.length === 0 && b.length === 0) return 0;
424
- // A version without prerelease identifiers outranks the same core with prerelease identifiers.
425
- if (a.length === 0) return 1;
426
- if (b.length === 0) return -1;
427
- const shared = Math.min(a.length, b.length);
428
- for (let i = 0; i < shared; i++) {
429
- const idA = a[i];
430
- const idB = b[i];
431
- if (idA === undefined || idB === undefined) break;
432
- if (idA === idB) continue;
433
- const numericA = /^\d+$/.test(idA);
434
- const numericB = /^\d+$/.test(idB);
435
- if (numericA && numericB) {
436
- const diff = Number(idA) - Number(idB);
437
- if (diff !== 0) return diff < 0 ? -1 : 1;
438
- continue;
439
- }
440
- // Numeric identifiers always have lower precedence than non-numeric identifiers.
441
- if (numericA) return -1;
442
- if (numericB) return 1;
443
- return idA < idB ? -1 : 1;
444
- }
445
- if (a.length === b.length) return 0;
446
- return a.length < b.length ? -1 : 1;
606
+ if (a.length === 0 && b.length === 0) return 0;
607
+ // A version without prerelease identifiers outranks the same core with prerelease identifiers.
608
+ if (a.length === 0) return 1;
609
+ if (b.length === 0) return -1;
610
+ const shared = Math.min(a.length, b.length);
611
+ for (let i = 0; i < shared; i++) {
612
+ const idA = a[i];
613
+ const idB = b[i];
614
+ if (idA === undefined || idB === undefined) break;
615
+ if (idA === idB) continue;
616
+ const numericA = /^\d+$/.test(idA);
617
+ const numericB = /^\d+$/.test(idB);
618
+ if (numericA && numericB) {
619
+ const diff = Number(idA) - Number(idB);
620
+ if (diff !== 0) return diff < 0 ? -1 : 1;
621
+ continue;
622
+ }
623
+ // Numeric identifiers always have lower precedence than non-numeric identifiers.
624
+ if (numericA) return -1;
625
+ if (numericB) return 1;
626
+ return idA < idB ? -1 : 1;
627
+ }
628
+ if (a.length === b.length) return 0;
629
+ return a.length < b.length ? -1 : 1;
447
630
  }
448
631
 
449
632
  /** Compare two semver strings. Returns -1/0/1, or undefined when either side is not valid semver. */
450
633
  export function compareSemver(a: string, b: string): number | undefined {
451
- const left = parseSemver(a);
452
- const right = parseSemver(b);
453
- if (!left || !right) return undefined;
454
- if (left.major !== right.major) return left.major < right.major ? -1 : 1;
455
- if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
456
- if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
457
- return comparePrerelease(left.prerelease, right.prerelease);
634
+ const left = parseSemver(a);
635
+ const right = parseSemver(b);
636
+ if (!left || !right) return undefined;
637
+ if (left.major !== right.major) return left.major < right.major ? -1 : 1;
638
+ if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
639
+ if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
640
+ return comparePrerelease(left.prerelease, right.prerelease);
458
641
  }
459
642
 
460
643
  export function isNewerVersion(latest: string, current: string): boolean {
461
- return compareSemver(latest, current) === 1;
644
+ return compareSemver(latest, current) === 1;
462
645
  }
463
646
 
464
647
  /** Footer segment shown only when a newer published version exists; undefined otherwise. */
465
- export function formatUpdateSegment(latest: string | undefined, current: string): string | undefined {
466
- if (!latest) return undefined;
467
- if (!isNewerVersion(latest, current)) return undefined;
468
- return `\u2b06 v${latest} ${UPDATE_COMMAND}`;
648
+ export function formatUpdateSegment(
649
+ latest: string | undefined,
650
+ current: string,
651
+ ): string | undefined {
652
+ if (!latest) return undefined;
653
+ if (!isNewerVersion(latest, current)) return undefined;
654
+ return `\u2b06 v${latest} ${UPDATE_COMMAND}`;
469
655
  }