pi-background-tasks 0.1.0 → 0.3.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 +10 -8
- package/README.md +68 -9
- package/TESTING.md +116 -0
- package/TEST_PLAN.md +98 -0
- package/extensions/background-tasks.ts +1 -1305
- package/package.json +28 -2
- package/src/core/common.ts +397 -0
- package/src/core/registry.ts +958 -0
- package/src/extension.ts +507 -0
- package/src/testing/normalize.ts +3 -0
- package/src/ui/background-tasks-manager.ts +622 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-background-tasks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Claude-Code-like background shell task manager for Pi: bg_run tools, /bg commands,
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Claude-Code-like named background shell task manager for Pi: bg_run tools, /bg commands, Shift+Down footer dock, bounded logs, kill/timeout safety, and completion wakeups.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
7
7
|
"author": "Ismail <ismailsalikhodjaev@gmail.com>",
|
|
@@ -27,11 +27,25 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"extensions/",
|
|
30
|
+
"src/",
|
|
30
31
|
"README.md",
|
|
32
|
+
"TESTING.md",
|
|
33
|
+
"TEST_PLAN.md",
|
|
31
34
|
"PUBLISHING.md",
|
|
32
35
|
"LICENSE"
|
|
33
36
|
],
|
|
34
37
|
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test:type-safety": "tsx --test tests/package/type-safety.test.ts",
|
|
40
|
+
"test:unit": "tsx --test tests/unit/**/*.test.ts",
|
|
41
|
+
"test:sdk": "tsx --test tests/sdk/**/*.test.ts",
|
|
42
|
+
"test:rpc": "tsx --test tests/rpc/**/*.test.ts",
|
|
43
|
+
"test:component": "tsx --test tests/component/**/*.test.ts",
|
|
44
|
+
"test:package": "tsx --test tests/package/**/*.test.ts",
|
|
45
|
+
"test": "npm run typecheck && npm run test:type-safety && npm run test:unit && npm run test:sdk && npm run test:rpc && npm run test:component && npm run test:package",
|
|
46
|
+
"test:pty": "tsx --test tests/pty/**/*.test.ts",
|
|
47
|
+
"test:agent-loop": "tsx --test tests/scripted-provider/**/*.test.ts",
|
|
48
|
+
"test:full": "npm run test && npm run test:pty && npm run test:agent-loop",
|
|
35
49
|
"smoke": "pi --no-extensions -e ./extensions/background-tasks.ts --offline --no-tools --no-session -p \"/jobs\"",
|
|
36
50
|
"pack:dry-run": "npm pack --dry-run"
|
|
37
51
|
},
|
|
@@ -44,5 +58,17 @@
|
|
|
44
58
|
"@earendil-works/pi-coding-agent": "*",
|
|
45
59
|
"@earendil-works/pi-tui": "*",
|
|
46
60
|
"typebox": "*"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@earendil-works/pi-ai": "^0.75.5",
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.75.5",
|
|
66
|
+
"@types/node": "^24.0.0",
|
|
67
|
+
"tsx": "^4.19.0",
|
|
68
|
+
"typebox": "^1.1.38",
|
|
69
|
+
"typescript": "^5.9.0"
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=22.19.0"
|
|
47
73
|
}
|
|
48
74
|
}
|
|
@@ -0,0 +1,397 @@
|
|
|
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
|
+
};
|
|
99
|
+
|
|
100
|
+
export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
101
|
+
export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
102
|
+
export const COMMAND_PREVIEW_CHARS = 90;
|
|
103
|
+
|
|
104
|
+
export function sanitizePathSegment(value: string): string {
|
|
105
|
+
const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
106
|
+
return sanitized || "session";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
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;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function compactWhitespace(value: string): string {
|
|
122
|
+
return value.replace(/\s+/g, " ").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
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))}…`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
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);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
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);
|
|
151
|
+
}
|
|
152
|
+
|
|
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";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
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 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
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` : ""}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
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`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
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}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function formatTokenUsageSummary(usage?: TaskTokenUsage): string | undefined {
|
|
287
|
+
if (!usage || usage.totalTokens <= 0) return undefined;
|
|
288
|
+
return `tokens=${formatCompactNumber(usage.totalTokens)}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
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}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function formatModelSummary(model?: string): string | undefined {
|
|
298
|
+
if (!model) return undefined;
|
|
299
|
+
return `model=${model}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function shellQuote(value: string): string {
|
|
303
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function shellInvocation(
|
|
307
|
+
command: string,
|
|
308
|
+
platform: NodeJS.Platform = process.platform,
|
|
309
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
310
|
+
): { 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] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
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));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
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
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
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");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function boundedRead(
|
|
370
|
+
filePath: string,
|
|
371
|
+
maxBytes: number,
|
|
372
|
+
tail: boolean,
|
|
373
|
+
): 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
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function escapeXml(value: string): string {
|
|
396
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
397
|
+
}
|