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.
- package/PUBLISHING.md +15 -15
- package/README.md +83 -9
- package/TESTING.md +31 -13
- package/TEST_PLAN.md +19 -9
- package/extensions/background-tasks.ts +1 -1
- package/package.json +16 -10
- package/src/core/attested-pi-run.ts +619 -0
- package/src/core/common.ts +567 -381
- package/src/core/extension-api.ts +548 -0
- package/src/core/fusion/artifacts.ts +443 -0
- package/src/core/fusion/config.ts +371 -0
- package/src/core/fusion/context.ts +179 -0
- package/src/core/fusion/evaluation.ts +362 -0
- package/src/core/fusion/orchestrator.ts +593 -0
- package/src/core/fusion/pi-child.ts +816 -0
- package/src/core/fusion/prompts.ts +155 -0
- package/src/core/fusion/types.ts +288 -0
- package/src/core/registry.ts +1392 -694
- package/src/core/update-check.ts +69 -63
- package/src/extension.ts +863 -524
- package/src/fusion-extension.ts +616 -0
- package/src/testing/normalize.ts +22 -3
- package/src/ui/background-tasks-manager.ts +711 -559
- package/src/ui/fusion-model-selector.ts +322 -0
package/src/core/common.ts
CHANGED
|
@@ -1,469 +1,655 @@
|
|
|
1
|
-
import { statSync } from
|
|
2
|
-
import { open } from
|
|
3
|
-
import { DEFAULT_MAX_BYTES } from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
179
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
123
180
|
}
|
|
124
181
|
|
|
125
182
|
export function truncateChars(value: string, maxChars: number): string {
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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: {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function parseBgCommandArgs(args: string): {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
464
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
304
465
|
}
|
|
305
466
|
|
|
306
467
|
export function shellInvocation(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
468
|
+
command: string,
|
|
469
|
+
platform: NodeJS.Platform = process.platform,
|
|
470
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
310
471
|
): { shell: string; args: string[] } {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
548
|
+
filePath: string,
|
|
549
|
+
maxBytes: number,
|
|
550
|
+
tail: boolean,
|
|
373
551
|
): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
574
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
397
575
|
}
|
|
398
576
|
|
|
399
|
-
export const UPDATE_COMMAND =
|
|
577
|
+
export const UPDATE_COMMAND = '/bg-update';
|
|
400
578
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
}
|