pi-background-tasks 0.6.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,306 +1,372 @@
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
- /** True when this task launched a telemetry-wrapped Pi agent; its stdout carries control lines, not raw output. */
68
- telemetryWrapped?: boolean | undefined;
69
- /** Partial trailing stdout line held between chunks while reconstructing wrapped-agent control lines. */
70
- agentStdoutBuffer?: string | undefined;
71
- waiters: Array<() => void>;
72
- };
73
-
74
- export type BgRunDetails = {
75
- task: BgTaskSnapshot;
76
- };
77
-
78
- export type BgStatusDetails = {
79
- tasks: BgTaskSnapshot[];
80
- };
81
-
82
- export type BgLogsDetails = {
83
- task: BgTaskSnapshot;
84
- path: string;
85
- bytesRead: number;
86
- truncated: boolean;
87
- tail: boolean;
88
- };
89
-
90
- export type BgKillDetails = {
91
- task: BgTaskSnapshot;
92
- message: string;
93
- };
94
-
95
- export type StartTaskOptions = {
96
- name?: string | undefined;
97
- description?: string | undefined;
98
- isAgent?: boolean | undefined;
99
- timeoutSeconds?: number | undefined;
100
- notifyOnCompletion?: boolean | undefined;
101
- triggerOnCompletion?: boolean | undefined;
102
- };
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
+ }
103
147
 
104
148
  export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
105
149
  export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
106
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
+ }
107
160
 
108
161
  export function sanitizePathSegment(value: string): string {
109
- const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
110
- return sanitized || "session";
162
+ const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
163
+ return sanitized || 'session';
111
164
  }
112
165
 
113
166
  export function stripMatchingQuotes(value: string): string {
114
- const trimmed = value.trim();
115
- if (trimmed.length >= 2) {
116
- const first = trimmed[0];
117
- const last = trimmed[trimmed.length - 1];
118
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
119
- return trimmed.slice(1, -1);
120
- }
121
- }
122
- 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;
123
176
  }
124
177
 
125
178
  export function compactWhitespace(value: string): string {
126
- return value.replace(/\s+/g, " ").trim();
179
+ return value.replace(/\s+/g, ' ').trim();
127
180
  }
128
181
 
129
182
  export function truncateChars(value: string, maxChars: number): string {
130
- if (value.length <= maxChars) return value;
131
- 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))}…`;
132
185
  }
133
186
 
134
187
  export function normalizeTaskName(value: unknown): string | undefined {
135
- if (typeof value !== "string") return undefined;
136
- const normalized = compactWhitespace(stripMatchingQuotes(value));
137
- if (!normalized) return undefined;
138
- 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);
139
192
  }
140
193
 
141
194
  export function deriveTaskNameFromCommand(command: string): string {
142
- const normalized = compactWhitespace(stripMatchingQuotes(command));
143
- if (!normalized) return "Background task";
144
-
145
- const packageScript = normalized.match(/^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/);
146
- if (packageScript) {
147
- const runner = packageScript[1];
148
- const run = packageScript[2] ? " run" : "";
149
- const script = packageScript[3];
150
- return truncateChars(`${runner}${run} ${script}`, 48);
151
- }
152
-
153
- const words = normalized.split(/\s+/).slice(0, 5).join(" ");
154
- 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);
155
208
  }
156
209
 
157
- export function taskDisplayName(task: { name?: string | undefined; description?: string | undefined; command?: string | undefined; id?: string | undefined }): string {
158
- return normalizeTaskName(task.name)
159
- ?? normalizeTaskName(task.description)
160
- ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined)
161
- ?? task.id
162
- ?? "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
+ );
163
225
  }
164
226
 
165
227
  function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
166
- const input = valueAndRest.trimStart();
167
- if (!input) return undefined;
168
- const quote = input[0];
169
- if (quote === '"' || quote === "'") {
170
- let escaped = false;
171
- let value = "";
172
- for (let i = 1; i < input.length; i++) {
173
- const char = input.charAt(i);
174
- if (escaped) {
175
- value += char;
176
- escaped = false;
177
- continue;
178
- }
179
- if (char === "\\") {
180
- escaped = true;
181
- continue;
182
- }
183
- if (char === quote) {
184
- return { value, rest: input.slice(i + 1).trimStart() };
185
- }
186
- value += char;
187
- }
188
- return undefined;
189
- }
190
- const match = input.match(/^(\S+)(?:\s+([\s\S]*))?$/);
191
- if (!match) return undefined;
192
- const parsedValue = match[1];
193
- if (parsedValue === undefined) return undefined;
194
- return { value: parsedValue, rest: match[2]?.trimStart() ?? "" };
195
- }
196
-
197
- export function parseBgCommandArgs(args: string): { name?: string; command: string; isAgent: boolean } {
198
- let input = args.trim();
199
- let name: string | undefined;
200
- let isAgent = false;
201
-
202
- while (input) {
203
- let consumed = false;
204
- for (const prefix of ["--name=", "-n="]) {
205
- if (input.startsWith(prefix)) {
206
- const parsed = parseNameValueAndRest(input.slice(prefix.length));
207
- if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
208
- name = normalizeTaskName(parsed.value);
209
- input = parsed.rest;
210
- consumed = true;
211
- break;
212
- }
213
- }
214
- if (consumed) continue;
215
-
216
- for (const prefix of ["--name", "-n"]) {
217
- if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
218
- const parsed = parseNameValueAndRest(input.slice(prefix.length));
219
- if (!parsed) throw new Error(`${prefix} requires a task name`);
220
- name = normalizeTaskName(parsed.value);
221
- input = parsed.rest;
222
- consumed = true;
223
- break;
224
- }
225
- }
226
- if (consumed) continue;
227
-
228
- for (const flag of ["--agent", "--llm-agent"]) {
229
- if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
230
- isAgent = true;
231
- input = input.slice(flag.length).trimStart();
232
- consumed = true;
233
- break;
234
- }
235
- }
236
- if (consumed) continue;
237
-
238
- for (const flag of ["--script", "--no-agent"]) {
239
- if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
240
- isAgent = false;
241
- input = input.slice(flag.length).trimStart();
242
- consumed = true;
243
- break;
244
- }
245
- }
246
- if (consumed) continue;
247
-
248
- if (input === "--") {
249
- input = "";
250
- break;
251
- }
252
- if (input.startsWith("-- ")) {
253
- input = input.slice(3).trimStart();
254
- break;
255
- }
256
- break;
257
- }
258
-
259
- 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 };
260
326
  }
261
327
 
262
328
  export function formatDuration(ms: number): string {
263
- if (ms < 1000) return `${ms}ms`;
264
- const seconds = Math.floor(ms / 1000);
265
- if (seconds < 60) return `${seconds}s`;
266
- const minutes = Math.floor(seconds / 60);
267
- const remSeconds = seconds % 60;
268
- if (minutes < 60) return `${minutes}m${remSeconds ? `${remSeconds}s` : ""}`;
269
- const hours = Math.floor(minutes / 60);
270
- const remMinutes = minutes % 60;
271
- 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` : ''}`;
272
338
  }
273
339
 
274
340
  export function formatCompactNumber(count: number): string {
275
- const normalized = Math.max(0, Math.floor(count));
276
- if (normalized < 1000) return normalized.toString();
277
- if (normalized < 10000) return `${(normalized / 1000).toFixed(1)}k`;
278
- if (normalized < 1000000) return `${Math.round(normalized / 1000)}k`;
279
- if (normalized < 10000000) return `${(normalized / 1000000).toFixed(1)}M`;
280
- 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`;
281
347
  }
282
348
 
283
349
  export function formatContextUsageSummary(usage?: TaskContextUsage): string | undefined {
284
- if (!usage || !usage.contextWindow) return undefined;
285
- const window = formatCompactNumber(usage.contextWindow);
286
- if (usage.percent === null || usage.tokens === null) return `ctx=?/${window}`;
287
- 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}`;
288
354
  }
289
355
 
290
356
  export function formatTokenUsageSummary(usage?: TaskTokenUsage): string | undefined {
291
- if (!usage || usage.totalTokens <= 0) return undefined;
292
- return `tokens=${formatCompactNumber(usage.totalTokens)}`;
357
+ if (!usage || usage.totalTokens <= 0) return undefined;
358
+ return `tokens=${formatCompactNumber(usage.totalTokens)}`;
293
359
  }
294
360
 
295
361
  export function formatToolUsageSummary(usage?: TaskToolUsage): string | undefined {
296
- if (!usage || (usage.total <= 0 && usage.failed <= 0)) return undefined;
297
- const failed = usage.failed > 0 ? ` failed=${usage.failed}` : "";
298
- 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}`;
299
365
  }
300
366
 
301
367
  export function formatModelSummary(model?: string): string | undefined {
302
- if (!model) return undefined;
303
- return `model=${model}`;
368
+ if (!model) return undefined;
369
+ return `model=${model}`;
304
370
  }
305
371
 
306
372
  /**
@@ -312,45 +378,58 @@ export function formatModelSummary(model?: string): string | undefined {
312
378
  * file instead of leaking raw telemetry JSON. Both the parser and the formatter
313
379
  * are pure so the visible transcript is fully unit-testable.
314
380
  */
315
- export const AGENT_ACTIVITY_TYPE = "background-task-activity";
381
+ export const AGENT_ACTIVITY_TYPE = 'background-task-activity';
316
382
  const AGENT_ACTIVITY_DETAIL_MAX = 80;
317
383
 
318
384
  export type AgentActivity =
319
- | { kind: "assistant_text"; text: string }
320
- | { kind: "reasoning"; text: string }
321
- | { kind: "tool_start"; tool: string; argsSummary: string }
322
- | { kind: "tool_end"; tool: string; isError: boolean; error?: string };
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
+ }
323
399
 
324
- function readActivityString(record: Record<string, unknown>, key: string): string | undefined {
325
- const value = record[key];
326
- return typeof value === "string" ? value : undefined;
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;
327
406
  }
328
407
 
329
408
  /** Narrow a parsed `background-task-activity` control payload into a typed {@link AgentActivity}. */
330
409
  export function parseAgentActivity(payload: unknown): AgentActivity | undefined {
331
- if (typeof payload !== "object" || payload === null) return undefined;
332
- const record = payload as Record<string, unknown>;
333
- if (record["type"] !== AGENT_ACTIVITY_TYPE) return undefined;
334
- const kind = record["kind"];
335
- if (kind === "assistant_text" || kind === "reasoning") {
336
- const text = readActivityString(record, "text");
337
- if (text === undefined) return undefined;
338
- return { kind, text };
339
- }
340
- if (kind === "tool_start") {
341
- const tool = readActivityString(record, "tool");
342
- if (!tool) return undefined;
343
- return { kind, tool, argsSummary: readActivityString(record, "argsSummary") ?? "" };
344
- }
345
- if (kind === "tool_end") {
346
- const tool = readActivityString(record, "tool");
347
- if (!tool) return undefined;
348
- const activity: AgentActivity = { kind, tool, isError: record["isError"] === true };
349
- const error = readActivityString(record, "error");
350
- if (error !== undefined && error.trim().length > 0) activity.error = error;
351
- return activity;
352
- }
353
- return 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;
354
433
  }
355
434
 
356
435
  /**
@@ -360,189 +439,217 @@ export function parseAgentActivity(payload: unknown): AgentActivity | undefined
360
439
  * line already announced the call, and the next line implies completion.
361
440
  */
362
441
  export function formatAgentActivityLine(activity: AgentActivity): string | undefined {
363
- if (activity.kind === "assistant_text") {
364
- const text = activity.text.replace(/\s+$/u, "");
365
- return text.trim().length > 0 ? text : undefined;
366
- }
367
- if (activity.kind === "reasoning") {
368
- const text = activity.text.replace(/\s+$/u, "");
369
- return text.trim().length > 0 ? `\u2026 ${text}` : undefined;
370
- }
371
- if (activity.kind === "tool_start") {
372
- const summary = compactWhitespace(activity.argsSummary);
373
- const suffix = summary.length > 0 ? ` ${truncateChars(summary, AGENT_ACTIVITY_DETAIL_MAX)}` : "";
374
- return `\u2192 ${activity.tool}${suffix}`;
375
- }
376
- if (!activity.isError) return undefined;
377
- const detail = activity.error ? `: ${truncateChars(compactWhitespace(activity.error), AGENT_ACTIVITY_DETAIL_MAX)}` : "";
378
- return `\u2717 ${activity.tool} failed${detail}`;
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}`;
379
461
  }
380
462
 
381
463
  export function shellQuote(value: string): string {
382
- return `'${value.replace(/'/g, `'"'"'`)}'`;
464
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
383
465
  }
384
466
 
385
467
  export function shellInvocation(
386
- command: string,
387
- platform: NodeJS.Platform = process.platform,
388
- env: NodeJS.ProcessEnv = process.env,
468
+ command: string,
469
+ platform: NodeJS.Platform = process.platform,
470
+ env: NodeJS.ProcessEnv = process.env,
389
471
  ): { shell: string; args: string[] } {
390
- if (platform === "win32") {
391
- return { shell: env["ComSpec"] || "cmd.exe", args: ["/d", "/s", "/c", command] };
392
- }
393
- 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] };
394
481
  }
395
482
 
396
483
  export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
397
- const raw = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
398
- 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));
399
486
  }
400
487
 
401
488
  export function snapshot(task: BgTask): BgTaskSnapshot {
402
- return {
403
- id: task.id,
404
- name: taskDisplayName(task),
405
- command: task.command,
406
- description: task.description,
407
- status: task.status,
408
- outputPath: task.outputPath,
409
- cwd: task.cwd,
410
- startTime: task.startTime,
411
- endTime: task.endTime,
412
- exitCode: task.exitCode,
413
- signal: task.signal,
414
- pid: task.pid,
415
- bytesWritten: task.bytesWritten,
416
- isAgent: task.isAgent,
417
- error: task.error,
418
- notified: task.notified,
419
- notifyOnCompletion: task.notifyOnCompletion,
420
- triggerOnCompletion: task.triggerOnCompletion,
421
- timeoutSeconds: task.timeoutSeconds,
422
- contextUsage: task.contextUsage,
423
- tokenUsage: task.tokenUsage,
424
- toolUsage: task.toolUsage,
425
- model: task.model,
426
- };
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
+ };
427
515
  }
428
516
 
429
517
  export function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
430
- if (tasks.length === 0) return "No background tasks in this Pi extension runtime.";
431
- return tasks.map((task) => {
432
- const statusIcon = task.status === "running" ? "▶" : task.status === "completed" ? "✓" : task.status === "killed" ? "■" : "✗";
433
- const age = formatDuration((task.endTime ?? now) - task.startTime);
434
- const code = task.exitCode !== undefined ? ` exit=${task.exitCode}` : "";
435
- const pid = task.pid ? ` pid=${task.pid}` : "";
436
- const error = task.error ? ` error=${truncateChars(task.error, 80)}` : "";
437
- const telemetry = [
438
- formatContextUsageSummary(task.contextUsage),
439
- formatModelSummary(task.model),
440
- formatTokenUsageSummary(task.tokenUsage),
441
- formatToolUsageSummary(task.toolUsage),
442
- ].filter(Boolean).join(" ");
443
- const telemetryText = telemetry ? ` ${telemetry}` : "";
444
- return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid}${telemetryText} ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
445
- }).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');
446
545
  }
447
546
 
448
547
  export async function boundedRead(
449
- filePath: string,
450
- maxBytes: number,
451
- tail: boolean,
548
+ filePath: string,
549
+ maxBytes: number,
550
+ tail: boolean,
452
551
  ): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
453
- const stats = statSync(filePath);
454
- const totalBytes = stats.size;
455
- const bytesToRead = Math.min(totalBytes, maxBytes);
456
- if (bytesToRead === 0) return { content: "", truncated: false, bytesRead: 0, totalBytes };
457
-
458
- const file = await open(filePath, "r");
459
- try {
460
- const buffer = Buffer.alloc(bytesToRead);
461
- const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
462
- const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
463
- return {
464
- content: buffer.subarray(0, bytesRead).toString("utf8"),
465
- truncated: totalBytes > bytesRead,
466
- bytesRead,
467
- totalBytes,
468
- };
469
- } finally {
470
- await file.close();
471
- }
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
+ }
472
571
  }
473
572
 
474
573
  export function escapeXml(value: string): string {
475
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
574
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
476
575
  }
477
576
 
478
- export const UPDATE_COMMAND = "/bg-update";
577
+ export const UPDATE_COMMAND = '/bg-update';
479
578
 
480
- type ParsedSemver = {
481
- major: number;
482
- minor: number;
483
- patch: number;
484
- prerelease: string[];
485
- };
579
+ interface ParsedSemver {
580
+ major: number;
581
+ minor: number;
582
+ patch: number;
583
+ prerelease: string[];
584
+ }
486
585
 
487
586
  const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
488
587
 
489
588
  export function parseSemver(value: string): ParsedSemver | undefined {
490
- if (typeof value !== "string") return undefined;
491
- const match = value.trim().match(SEMVER_PATTERN);
492
- if (!match) return undefined;
493
- const major = Number(match[1]);
494
- const minor = Number(match[2]);
495
- const patch = Number(match[3]);
496
- if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) return undefined;
497
- const prerelease = match[4] ? match[4].split(".") : [];
498
- 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 };
499
603
  }
500
604
 
501
605
  function comparePrerelease(a: string[], b: string[]): number {
502
- if (a.length === 0 && b.length === 0) return 0;
503
- // A version without prerelease identifiers outranks the same core with prerelease identifiers.
504
- if (a.length === 0) return 1;
505
- if (b.length === 0) return -1;
506
- const shared = Math.min(a.length, b.length);
507
- for (let i = 0; i < shared; i++) {
508
- const idA = a[i];
509
- const idB = b[i];
510
- if (idA === undefined || idB === undefined) break;
511
- if (idA === idB) continue;
512
- const numericA = /^\d+$/.test(idA);
513
- const numericB = /^\d+$/.test(idB);
514
- if (numericA && numericB) {
515
- const diff = Number(idA) - Number(idB);
516
- if (diff !== 0) return diff < 0 ? -1 : 1;
517
- continue;
518
- }
519
- // Numeric identifiers always have lower precedence than non-numeric identifiers.
520
- if (numericA) return -1;
521
- if (numericB) return 1;
522
- return idA < idB ? -1 : 1;
523
- }
524
- if (a.length === b.length) return 0;
525
- 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;
526
630
  }
527
631
 
528
632
  /** Compare two semver strings. Returns -1/0/1, or undefined when either side is not valid semver. */
529
633
  export function compareSemver(a: string, b: string): number | undefined {
530
- const left = parseSemver(a);
531
- const right = parseSemver(b);
532
- if (!left || !right) return undefined;
533
- if (left.major !== right.major) return left.major < right.major ? -1 : 1;
534
- if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
535
- if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
536
- 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);
537
641
  }
538
642
 
539
643
  export function isNewerVersion(latest: string, current: string): boolean {
540
- return compareSemver(latest, current) === 1;
644
+ return compareSemver(latest, current) === 1;
541
645
  }
542
646
 
543
647
  /** Footer segment shown only when a newer published version exists; undefined otherwise. */
544
- export function formatUpdateSegment(latest: string | undefined, current: string): string | undefined {
545
- if (!latest) return undefined;
546
- if (!isNewerVersion(latest, current)) return undefined;
547
- 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}`;
548
655
  }