pi-background-tasks 0.2.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 +6 -6
- package/README.md +16 -12
- package/TESTING.md +20 -7
- package/TEST_PLAN.md +35 -28
- package/package.json +6 -3
- package/src/core/common.ts +389 -16
- package/src/core/registry.ts +958 -0
- package/src/extension.ts +170 -907
- package/src/ui/background-tasks-manager.ts +71 -14
package/src/extension.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme, ThemeColor, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatSize } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text, type KeyId } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type, type Static } from "typebox";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_LOG_BYTES,
|
|
7
|
+
MAX_LOG_BYTES,
|
|
8
|
+
deriveTaskNameFromCommand,
|
|
9
|
+
formatSnapshotList,
|
|
10
|
+
normalizeMaxBytes,
|
|
11
|
+
normalizeTaskName,
|
|
12
|
+
parseBgCommandArgs,
|
|
13
|
+
taskDisplayName,
|
|
14
|
+
truncateChars,
|
|
15
|
+
type BgKillDetails,
|
|
16
|
+
type BgLogsDetails,
|
|
17
|
+
type BgRunDetails,
|
|
18
|
+
type BgStatusDetails,
|
|
19
|
+
type BgTask,
|
|
20
|
+
type BgTaskSnapshot,
|
|
21
|
+
type StartTaskOptions,
|
|
22
|
+
} from "./core/common.js";
|
|
23
|
+
import { BackgroundTaskRegistry } from "./core/registry.js";
|
|
24
|
+
import { BackgroundTasksManager, type BackgroundTaskForUi, type TaskManagerResult } from "./ui/background-tasks-manager.js";
|
|
11
25
|
|
|
12
26
|
/**
|
|
13
27
|
* Project-local Pi background task manager.
|
|
@@ -19,389 +33,71 @@ import { Type } from "typebox";
|
|
|
19
33
|
* extension runtime and are killed on session shutdown/reload.
|
|
20
34
|
*/
|
|
21
35
|
|
|
22
|
-
type TaskStatus = "running" | "completed" | "failed" | "killed";
|
|
23
|
-
type KillKind = "user" | "timeout" | "output_cap" | "shutdown";
|
|
24
|
-
|
|
25
|
-
type TaskContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
|
|
26
|
-
|
|
27
|
-
type BgTaskSnapshot = {
|
|
28
|
-
id: string;
|
|
29
|
-
name: string;
|
|
30
|
-
command: string;
|
|
31
|
-
description?: string;
|
|
32
|
-
status: TaskStatus;
|
|
33
|
-
outputPath: string;
|
|
34
|
-
cwd: string;
|
|
35
|
-
startTime: number;
|
|
36
|
-
endTime?: number;
|
|
37
|
-
exitCode?: number | null;
|
|
38
|
-
signal?: string | null;
|
|
39
|
-
pid?: number;
|
|
40
|
-
bytesWritten: number;
|
|
41
|
-
error?: string;
|
|
42
|
-
notified: boolean;
|
|
43
|
-
notifyOnCompletion: boolean;
|
|
44
|
-
triggerOnCompletion: boolean;
|
|
45
|
-
timeoutSeconds?: number;
|
|
46
|
-
contextUsage?: TaskContextUsage;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
type BgTask = BgTaskSnapshot & {
|
|
50
|
-
outputAbsPath: string;
|
|
51
|
-
metadataAbsPath: string;
|
|
52
|
-
child?: ChildProcess;
|
|
53
|
-
stream?: WriteStream;
|
|
54
|
-
timeoutHandle?: NodeJS.Timeout;
|
|
55
|
-
killKind?: KillKind;
|
|
56
|
-
killSignalSent?: boolean;
|
|
57
|
-
capExceeded?: boolean;
|
|
58
|
-
finalized?: boolean;
|
|
59
|
-
contextUsageBuffer?: string;
|
|
60
|
-
waiters: Array<() => void>;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
type BgRunDetails = {
|
|
64
|
-
task: BgTaskSnapshot;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
type BgStatusDetails = {
|
|
68
|
-
tasks: BgTaskSnapshot[];
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
type BgLogsDetails = {
|
|
72
|
-
task: BgTaskSnapshot;
|
|
73
|
-
path: string;
|
|
74
|
-
bytesRead: number;
|
|
75
|
-
truncated: boolean;
|
|
76
|
-
tail: boolean;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
type BgKillDetails = {
|
|
80
|
-
task: BgTaskSnapshot;
|
|
81
|
-
message: string;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
type StartTaskOptions = {
|
|
85
|
-
name?: string;
|
|
86
|
-
description?: string;
|
|
87
|
-
timeoutSeconds?: number;
|
|
88
|
-
notifyOnCompletion?: boolean;
|
|
89
|
-
triggerOnCompletion?: boolean;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
93
|
-
const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
94
|
-
const MAX_OUTPUT_BYTES = Number(process.env.PI_BG_MAX_OUTPUT_BYTES ?? 20 * 1024 * 1024);
|
|
95
|
-
const KILL_GRACE_MS = 3000;
|
|
96
|
-
const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
|
|
97
|
-
const MAX_RECENT_TASKS = 100;
|
|
98
36
|
const STATUS_INTERVAL_MS = 1000;
|
|
99
37
|
const COMMAND_PREVIEW_CHARS = 90;
|
|
100
|
-
const DETAIL_TAIL_BYTES = 8 * 1024;
|
|
101
|
-
const LIST_VISIBLE_ROWS = 14;
|
|
102
|
-
const DETAIL_VISIBLE_OUTPUT_LINES = 12;
|
|
103
38
|
const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
|
|
104
39
|
const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
|
|
105
|
-
const LIGHT_BLUE_BORDER = "\x1b[38;2;83;160;215m";
|
|
106
40
|
const ANSI_RESET = "\x1b[0m";
|
|
107
41
|
|
|
108
|
-
function sanitizePathSegment(value: string): string {
|
|
109
|
-
const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
110
|
-
return sanitized || "session";
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
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;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function compactWhitespace(value: string): string {
|
|
126
|
-
return value.replace(/\s+/g, " ").trim();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function truncateChars(value: string, maxChars: number): string {
|
|
130
|
-
if (value.length <= maxChars) return value;
|
|
131
|
-
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
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);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
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);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function taskDisplayName(task: { name?: string; description?: string; command?: string; id?: string }): string {
|
|
158
|
-
return normalizeTaskName(task.name) ?? normalizeTaskName(task.description) ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined) ?? task.id ?? "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[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
|
-
return { value: match[1]!, rest: match[2]?.trimStart() ?? "" };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function parseBgCommandArgs(args: string): { name?: string; command: string } {
|
|
192
|
-
const input = args.trim();
|
|
193
|
-
for (const prefix of ["--name=", "-n="]) {
|
|
194
|
-
if (input.startsWith(prefix)) {
|
|
195
|
-
const parsed = parseNameValueAndRest(input.slice(prefix.length));
|
|
196
|
-
if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
|
|
197
|
-
return { name: normalizeTaskName(parsed.value), command: parsed.rest };
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
for (const prefix of ["--name", "-n"]) {
|
|
201
|
-
if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
|
|
202
|
-
const parsed = parseNameValueAndRest(input.slice(prefix.length));
|
|
203
|
-
if (!parsed) throw new Error(`${prefix} requires a task name`);
|
|
204
|
-
return { name: normalizeTaskName(parsed.value), command: parsed.rest };
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return { command: input };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function formatDuration(ms: number): string {
|
|
211
|
-
if (ms < 1000) return `${ms}ms`;
|
|
212
|
-
const seconds = Math.floor(ms / 1000);
|
|
213
|
-
if (seconds < 60) return `${seconds}s`;
|
|
214
|
-
const minutes = Math.floor(seconds / 60);
|
|
215
|
-
const remSeconds = seconds % 60;
|
|
216
|
-
if (minutes < 60) return `${minutes}m${remSeconds ? `${remSeconds}s` : ""}`;
|
|
217
|
-
const hours = Math.floor(minutes / 60);
|
|
218
|
-
const remMinutes = minutes % 60;
|
|
219
|
-
return `${hours}h${remMinutes ? `${remMinutes}m` : ""}`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function formatTime(timestamp: number): string {
|
|
223
|
-
return new Date(timestamp).toLocaleTimeString();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function padAnsi(value: string, width: number): string {
|
|
227
|
-
return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
42
|
function lightBlue(value: string): string {
|
|
231
43
|
return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
|
|
232
44
|
}
|
|
233
45
|
|
|
234
|
-
function blueBorder(value: string): string {
|
|
235
|
-
return `${LIGHT_BLUE_BORDER}${value}${ANSI_RESET}`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function statusLabel(status: TaskStatus): string {
|
|
239
|
-
if (status === "completed") return "done";
|
|
240
|
-
if (status === "failed") return "error";
|
|
241
|
-
if (status === "killed") return "stopped";
|
|
242
|
-
return "running";
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function statusColor(theme: Theme, status: TaskStatus, text = statusLabel(status)): string {
|
|
246
|
-
if (status === "completed") return theme.fg("success", text);
|
|
247
|
-
if (status === "failed") return theme.fg("error", text);
|
|
248
|
-
if (status === "killed") return theme.fg("warning", text);
|
|
249
|
-
return theme.fg("accent", text);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function sortTasksForUi(tasks: BgTask[]): BgTask[] {
|
|
253
|
-
const rank = (task: BgTask) => (task.status === "running" ? 0 : task.status === "failed" ? 1 : task.status === "killed" ? 2 : 3);
|
|
254
|
-
return [...tasks].sort((a, b) => {
|
|
255
|
-
const rankDiff = rank(a) - rank(b);
|
|
256
|
-
if (rankDiff !== 0) return rankDiff;
|
|
257
|
-
return (b.endTime ?? b.startTime) - (a.endTime ?? a.startTime);
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function shellInvocation(command: string): { shell: string; args: string[] } {
|
|
262
|
-
if (process.platform === "win32") {
|
|
263
|
-
return { shell: process.env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", command] };
|
|
264
|
-
}
|
|
265
|
-
return { shell: process.env.SHELL || "/bin/sh", args: ["-c", command] };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
|
|
269
|
-
const raw = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
270
|
-
return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function snapshot(task: BgTask): BgTaskSnapshot {
|
|
274
|
-
return {
|
|
275
|
-
id: task.id,
|
|
276
|
-
name: taskDisplayName(task),
|
|
277
|
-
command: task.command,
|
|
278
|
-
description: task.description,
|
|
279
|
-
status: task.status,
|
|
280
|
-
outputPath: task.outputPath,
|
|
281
|
-
cwd: task.cwd,
|
|
282
|
-
startTime: task.startTime,
|
|
283
|
-
endTime: task.endTime,
|
|
284
|
-
exitCode: task.exitCode,
|
|
285
|
-
signal: task.signal,
|
|
286
|
-
pid: task.pid,
|
|
287
|
-
bytesWritten: task.bytesWritten,
|
|
288
|
-
error: task.error,
|
|
289
|
-
notified: task.notified,
|
|
290
|
-
notifyOnCompletion: task.notifyOnCompletion,
|
|
291
|
-
triggerOnCompletion: task.triggerOnCompletion,
|
|
292
|
-
timeoutSeconds: task.timeoutSeconds,
|
|
293
|
-
contextUsage: task.contextUsage,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
46
|
function textContent(text: string) {
|
|
298
47
|
return [{ type: "text" as const, text }];
|
|
299
48
|
}
|
|
300
49
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const error = task.error ? ` error=${truncateChars(task.error, 80)}` : "";
|
|
336
|
-
const label = taskDisplayName(task);
|
|
337
|
-
return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid} — ${truncateChars(label, COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
|
|
338
|
-
})
|
|
339
|
-
.join("\n");
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async function boundedRead(filePath: string, maxBytes: number, tail: boolean): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
|
|
343
|
-
const stat = statSync(filePath);
|
|
344
|
-
const totalBytes = stat.size;
|
|
345
|
-
const bytesToRead = Math.min(totalBytes, maxBytes);
|
|
346
|
-
if (bytesToRead === 0) {
|
|
347
|
-
return { content: "", truncated: false, bytesRead: 0, totalBytes };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const file = await open(filePath, "r");
|
|
351
|
-
try {
|
|
352
|
-
const buffer = Buffer.alloc(bytesToRead);
|
|
353
|
-
const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
|
|
354
|
-
const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
|
|
355
|
-
return {
|
|
356
|
-
content: buffer.subarray(0, bytesRead).toString("utf8"),
|
|
357
|
-
truncated: totalBytes > bytesRead,
|
|
358
|
-
bytesRead,
|
|
359
|
-
totalBytes,
|
|
360
|
-
};
|
|
361
|
-
} finally {
|
|
362
|
-
await file.close();
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function renderPlainResult(result: { content?: Array<{ type: string; text?: string }> }, _options: ToolRenderResultOptions, _theme: any) {
|
|
367
|
-
const first = result.content?.find((part) => part.type === "text");
|
|
368
|
-
return new Text(first?.text ?? "", 0, 0);
|
|
50
|
+
type TextToolResult = { content?: readonly { type: string; text?: string }[] };
|
|
51
|
+
|
|
52
|
+
const BgRunParams = Type.Object({
|
|
53
|
+
name: Type.String({ description: "Short human-readable task name shown in the bg footer dock. Required; use 2-6 words, not the raw command." }),
|
|
54
|
+
command: Type.String({ description: "Shell command to start in the background" }),
|
|
55
|
+
isAgent: Type.Boolean({ description: "Required. Set true only when this background task launches an LLM/agent process, such as a child `pi -p ...` or `pi --mode json ...`, so Pi-agent telemetry can be collected. Set false for scripts, tests, servers, sleeps, and ordinary shell commands." }),
|
|
56
|
+
description: Type.Optional(Type.String({ description: "Optional longer human-readable context for the task" })),
|
|
57
|
+
timeoutSeconds: Type.Optional(Type.Number({ description: "Optional timeout; task is failed and killed when exceeded" })),
|
|
58
|
+
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Whether to show a completion notification. Default: true." })),
|
|
59
|
+
triggerOnCompletion: Type.Optional(Type.Boolean({ description: "Whether completion should trigger a follow-up agent turn. Default: true for bg_run." })),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const BgStatusParams = Type.Object({
|
|
63
|
+
taskId: Type.Optional(Type.String({ description: "Optional task ID or unambiguous prefix. If omitted, all running/recent tasks are returned." })),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const BgLogsParams = Type.Object({
|
|
67
|
+
taskId: Type.String({ description: "Task ID or unambiguous prefix" }),
|
|
68
|
+
maxBytes: Type.Optional(Type.Number({ description: `Maximum bytes to return, capped at ${formatSize(MAX_LOG_BYTES)}. Default: ${formatSize(DEFAULT_LOG_BYTES)}.` })),
|
|
69
|
+
tail: Type.Optional(Type.Boolean({ description: "Read the tail of the log when true, head when false. Default: true." })),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const BgKillParams = Type.Object({
|
|
73
|
+
taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
type BgRunParamsValue = Static<typeof BgRunParams>;
|
|
77
|
+
type BgStatusParamsValue = Static<typeof BgStatusParams>;
|
|
78
|
+
type BgLogsParamsValue = Static<typeof BgLogsParams>;
|
|
79
|
+
type BgKillParamsValue = Static<typeof BgKillParams>;
|
|
80
|
+
|
|
81
|
+
function renderPlainResult(result: TextToolResult, _options: ToolRenderResultOptions, _theme: Theme) {
|
|
82
|
+
const text = result.content?.map((part) => part.type === "text" ? (part.text ?? "") : "").join("\n") ?? "";
|
|
83
|
+
return new Text(text, 0, 0);
|
|
369
84
|
}
|
|
370
85
|
|
|
371
86
|
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
372
|
-
const
|
|
373
|
-
let runtimeDirAbs: string | undefined;
|
|
374
|
-
let runtimeDirDisplay: string | undefined;
|
|
87
|
+
const seenTaskIds = new Set<string>();
|
|
375
88
|
let currentCtx: ExtensionContext | undefined;
|
|
376
89
|
let dockOpen = false;
|
|
377
|
-
let shuttingDown = false;
|
|
378
90
|
let statusInterval: NodeJS.Timeout | undefined;
|
|
379
|
-
const seenTaskIds = new Set<string>();
|
|
380
|
-
|
|
381
|
-
function makeTaskId(): string {
|
|
382
|
-
for (let attempt = 0; attempt < 20; attempt++) {
|
|
383
|
-
const id = `b${randomBytes(4).toString("hex")}`;
|
|
384
|
-
if (!tasks.has(id)) return id;
|
|
385
|
-
}
|
|
386
|
-
throw new Error("Could not generate a unique background task ID after 20 attempts");
|
|
387
|
-
}
|
|
388
91
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
await mkdir(runtimeDirAbs, { recursive: true });
|
|
396
|
-
return { abs: runtimeDirAbs, display: runtimeDirDisplay };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function writeMetadata(task: BgTask): Promise<void> {
|
|
400
|
-
await writeFile(task.metadataAbsPath, `${JSON.stringify(snapshot(task), null, 2)}\n`, "utf8");
|
|
401
|
-
}
|
|
92
|
+
const registry = new BackgroundTaskRegistry({
|
|
93
|
+
onChange: () => updateUi(),
|
|
94
|
+
sendCompletionNotification: (message, options) => {
|
|
95
|
+
pi.sendMessage(message, options);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
402
98
|
|
|
403
99
|
function unseenFinishedTasks(): BgTask[] {
|
|
404
|
-
return
|
|
100
|
+
return registry.allTasks().filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
|
|
405
101
|
}
|
|
406
102
|
|
|
407
103
|
function clearFinishedNotices(ctx = currentCtx): number {
|
|
@@ -411,11 +107,23 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
411
107
|
return unseen.length;
|
|
412
108
|
}
|
|
413
109
|
|
|
110
|
+
function notifyClearFinishedNotices(ctx: ExtensionContext): void {
|
|
111
|
+
currentCtx = ctx;
|
|
112
|
+
const cleared = clearFinishedNotices(ctx);
|
|
113
|
+
if (!ctx.hasUI) return;
|
|
114
|
+
ctx.ui.notify(
|
|
115
|
+
cleared > 0
|
|
116
|
+
? `Cleared ${cleared} finished background task notice${cleared === 1 ? "" : "s"}.`
|
|
117
|
+
: "No finished background task notices to clear.",
|
|
118
|
+
cleared > 0 ? "info" : "warning",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
414
122
|
function updateUi(ctx = currentCtx): void {
|
|
415
|
-
if (
|
|
123
|
+
if (registry.isShuttingDown() || !ctx) return;
|
|
416
124
|
try {
|
|
417
125
|
if (!ctx.hasUI) return;
|
|
418
|
-
const allTasks =
|
|
126
|
+
const allTasks = registry.allTasks();
|
|
419
127
|
const running = allTasks.filter((task) => task.status === "running");
|
|
420
128
|
const unseenFailed = allTasks.filter((task) => task.status === "failed" && !seenTaskIds.has(task.id));
|
|
421
129
|
const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
|
|
@@ -432,7 +140,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
432
140
|
if (unseenFailed.length > 0) parts.push(`${unseenFailed.length} failed`);
|
|
433
141
|
if (unseenStopped.length > 0) parts.push(`${unseenStopped.length} stopped`);
|
|
434
142
|
if (unseenDone.length > 0) parts.push(`${unseenDone.length} done`);
|
|
435
|
-
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " ·
|
|
143
|
+
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · /bg-clear" : ""}`;
|
|
436
144
|
const label = ` bg ${parts.join(" · ")} · ${entryHint} `;
|
|
437
145
|
ctx.ui.setStatus("background-tasks", lightBlue(label));
|
|
438
146
|
} catch (error) {
|
|
@@ -441,434 +149,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
441
149
|
}
|
|
442
150
|
}
|
|
443
151
|
|
|
444
|
-
function
|
|
445
|
-
if (!value || typeof value !== "object") return undefined;
|
|
446
|
-
const input = value as { tokens?: unknown; contextWindow?: unknown; percent?: unknown };
|
|
447
|
-
const contextWindow = typeof input.contextWindow === "number" && Number.isFinite(input.contextWindow) && input.contextWindow > 0
|
|
448
|
-
? Math.floor(input.contextWindow)
|
|
449
|
-
: undefined;
|
|
450
|
-
if (!contextWindow) return undefined;
|
|
451
|
-
const tokens = input.tokens === null
|
|
452
|
-
? null
|
|
453
|
-
: typeof input.tokens === "number" && Number.isFinite(input.tokens) && input.tokens >= 0
|
|
454
|
-
? Math.floor(input.tokens)
|
|
455
|
-
: null;
|
|
456
|
-
const percent = input.percent === null
|
|
457
|
-
? null
|
|
458
|
-
: typeof input.percent === "number" && Number.isFinite(input.percent) && input.percent >= 0
|
|
459
|
-
? input.percent
|
|
460
|
-
: tokens === null
|
|
461
|
-
? null
|
|
462
|
-
: (tokens / contextWindow) * 100;
|
|
463
|
-
return { tokens, contextWindow, percent };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function parseContextUsageXml(xml: string): TaskContextUsage | undefined {
|
|
467
|
-
const readNumber = (tag: string): number | null | undefined => {
|
|
468
|
-
const match = xml.match(new RegExp(`<${tag}>(.*?)</${tag}>`, "i"));
|
|
469
|
-
if (!match) return undefined;
|
|
470
|
-
const raw = match[1]?.trim();
|
|
471
|
-
if (raw === "null" || raw === "?") return null;
|
|
472
|
-
const parsed = Number(raw);
|
|
473
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
474
|
-
};
|
|
475
|
-
const tokens = readNumber("tokens");
|
|
476
|
-
const contextWindow = readNumber("context-window") ?? readNumber("contextWindow");
|
|
477
|
-
const percent = readNumber("percent");
|
|
478
|
-
return normalizeContextUsage({ tokens, contextWindow, percent });
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function ingestContextUsageTelemetry(task: BgTask, text: string): void {
|
|
482
|
-
if (!text) return;
|
|
483
|
-
task.contextUsageBuffer = `${task.contextUsageBuffer ?? ""}${text}`.slice(-16 * 1024);
|
|
484
|
-
let latest: TaskContextUsage | undefined;
|
|
485
|
-
for (const line of task.contextUsageBuffer.split(/\r?\n/)) {
|
|
486
|
-
if (!line.includes("background-task-context-usage")) continue;
|
|
487
|
-
const trimmed = line.trim();
|
|
488
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
489
|
-
try {
|
|
490
|
-
const parsed = JSON.parse(trimmed);
|
|
491
|
-
if (parsed?.type === "background-task-context-usage") latest = normalizeContextUsage(parsed) ?? latest;
|
|
492
|
-
} catch {
|
|
493
|
-
// Ignore malformed optional telemetry; task output remains authoritative for debugging.
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const xmlMatches = task.contextUsageBuffer.matchAll(/<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi);
|
|
498
|
-
for (const match of xmlMatches) latest = parseContextUsageXml(match[0]) ?? latest;
|
|
499
|
-
if (latest) {
|
|
500
|
-
task.contextUsage = latest;
|
|
501
|
-
updateUi();
|
|
502
|
-
void writeMetadata(task).catch((error) => {
|
|
503
|
-
console.error(`[background-tasks] failed to write context usage metadata for ${task.id}:`, error);
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function appendToOutput(task: BgTask, data: Buffer | string): void {
|
|
509
|
-
if (!task.stream || task.stream.destroyed) return;
|
|
510
|
-
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
|
|
511
|
-
if (buffer.length === 0) return;
|
|
512
|
-
ingestContextUsageTelemetry(task, buffer.toString("utf8"));
|
|
513
|
-
|
|
514
|
-
const nextBytes = task.bytesWritten + buffer.length;
|
|
515
|
-
if (nextBytes <= MAX_OUTPUT_BYTES) {
|
|
516
|
-
task.stream.write(buffer);
|
|
517
|
-
task.bytesWritten = nextBytes;
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const remaining = Math.max(0, MAX_OUTPUT_BYTES - task.bytesWritten);
|
|
522
|
-
if (remaining > 0) {
|
|
523
|
-
task.stream.write(buffer.subarray(0, remaining));
|
|
524
|
-
task.bytesWritten += remaining;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (!task.capExceeded) {
|
|
528
|
-
task.capExceeded = true;
|
|
529
|
-
task.error = `Output exceeded cap of ${formatSize(MAX_OUTPUT_BYTES)}; terminating task`;
|
|
530
|
-
const notice = `\n\n[background task error: ${task.error}]\n`;
|
|
531
|
-
task.stream.write(notice);
|
|
532
|
-
task.bytesWritten += Buffer.byteLength(notice, "utf8");
|
|
533
|
-
task.killKind = "output_cap";
|
|
534
|
-
try {
|
|
535
|
-
requestKill(task, "SIGTERM");
|
|
536
|
-
} catch (error) {
|
|
537
|
-
task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
538
|
-
void finalizeTask(task, "failed", null, undefined, task.error);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function requestKill(task: BgTask, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
544
|
-
if (task.status !== "running") {
|
|
545
|
-
throw new Error(`Task ${task.id} is ${task.status}, not running`);
|
|
546
|
-
}
|
|
547
|
-
if (!task.child) {
|
|
548
|
-
throw new Error(`Task ${task.id} has no child process handle`);
|
|
549
|
-
}
|
|
550
|
-
if (!task.pid) {
|
|
551
|
-
throw new Error(`Task ${task.id} has no process id`);
|
|
552
|
-
}
|
|
553
|
-
if (task.killSignalSent && signal === "SIGTERM") return;
|
|
554
|
-
|
|
555
|
-
const errors: string[] = [];
|
|
556
|
-
let killed = false;
|
|
557
|
-
|
|
558
|
-
if (process.platform !== "win32") {
|
|
559
|
-
try {
|
|
560
|
-
process.kill(-task.pid, signal);
|
|
561
|
-
killed = true;
|
|
562
|
-
} catch (error) {
|
|
563
|
-
errors.push(`process group kill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (!killed) {
|
|
568
|
-
try {
|
|
569
|
-
task.child.kill(signal);
|
|
570
|
-
killed = true;
|
|
571
|
-
} catch (error) {
|
|
572
|
-
errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
if (!killed) {
|
|
577
|
-
throw new Error(`Could not kill task ${task.id}: ${errors.join("; ")}`);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
task.killSignalSent = true;
|
|
581
|
-
setTimeout(() => {
|
|
582
|
-
if (task.status !== "running") return;
|
|
583
|
-
try {
|
|
584
|
-
requestKill(task, "SIGKILL");
|
|
585
|
-
} catch (error) {
|
|
586
|
-
task.error = `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
587
|
-
void writeMetadata(task).catch((metadataError) => {
|
|
588
|
-
console.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
}, KILL_GRACE_MS).unref?.();
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function waitForEnd(task: BgTask, timeoutMs: number): Promise<boolean> {
|
|
595
|
-
if (task.status !== "running") return Promise.resolve(true);
|
|
596
|
-
return new Promise((resolve) => {
|
|
597
|
-
const timeout = setTimeout(() => {
|
|
598
|
-
const idx = task.waiters.indexOf(done);
|
|
599
|
-
if (idx >= 0) task.waiters.splice(idx, 1);
|
|
600
|
-
resolve(false);
|
|
601
|
-
}, timeoutMs);
|
|
602
|
-
const done = () => {
|
|
603
|
-
clearTimeout(timeout);
|
|
604
|
-
resolve(true);
|
|
605
|
-
};
|
|
606
|
-
task.waiters.push(done);
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
async function stopTask(task: BgTask, kind: KillKind, reason?: string): Promise<BgTask> {
|
|
611
|
-
if (task.status !== "running") {
|
|
612
|
-
throw new Error(`Task ${task.id} is ${task.status}, not running`);
|
|
613
|
-
}
|
|
614
|
-
task.killKind = kind;
|
|
615
|
-
if (reason) task.error = reason;
|
|
616
|
-
requestKill(task, "SIGTERM");
|
|
617
|
-
const stopped = await waitForEnd(task, STOP_WAIT_MS);
|
|
618
|
-
if (!stopped) {
|
|
619
|
-
throw new Error(`Task ${task.id} did not exit within ${formatDuration(STOP_WAIT_MS)} after SIGTERM/SIGKILL`);
|
|
620
|
-
}
|
|
621
|
-
return task;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
async function notifyCompletion(task: BgTask): Promise<void> {
|
|
625
|
-
if (!task.notifyOnCompletion || task.notified || shuttingDown) return;
|
|
626
|
-
task.notified = true;
|
|
627
|
-
const exit = task.exitCode === undefined ? "" : `\n <exit-code>${task.exitCode}</exit-code>`;
|
|
628
|
-
const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : "";
|
|
629
|
-
const taskName = taskDisplayName(task);
|
|
630
|
-
const content = [
|
|
631
|
-
"<background-task-notification>",
|
|
632
|
-
` <task-id>${task.id}</task-id>`,
|
|
633
|
-
` <task-name>${escapeXml(taskName)}</task-name>`,
|
|
634
|
-
` <status>${task.status}</status>`,
|
|
635
|
-
exit,
|
|
636
|
-
error,
|
|
637
|
-
` <output-file>${escapeXml(task.outputPath)}</output-file>`,
|
|
638
|
-
` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
|
|
639
|
-
"</background-task-notification>",
|
|
640
|
-
]
|
|
641
|
-
.filter(Boolean)
|
|
642
|
-
.join("\n");
|
|
643
|
-
|
|
644
|
-
try {
|
|
645
|
-
pi.sendMessage(
|
|
646
|
-
{
|
|
647
|
-
customType: "background-task-notification",
|
|
648
|
-
content,
|
|
649
|
-
display: true,
|
|
650
|
-
details: snapshot(task),
|
|
651
|
-
},
|
|
652
|
-
{ deliverAs: "followUp", triggerTurn: task.triggerOnCompletion },
|
|
653
|
-
);
|
|
654
|
-
} catch (error) {
|
|
655
|
-
task.notified = false;
|
|
656
|
-
throw new Error(`Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function escapeXml(value: string): string {
|
|
661
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
async function finalizeTask(task: BgTask, status: TaskStatus, exitCode: number | null, signal?: string | null, error?: string): Promise<void> {
|
|
665
|
-
if (task.finalized) return;
|
|
666
|
-
task.finalized = true;
|
|
667
|
-
if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
|
|
668
|
-
task.status = status;
|
|
669
|
-
task.exitCode = exitCode;
|
|
670
|
-
task.signal = signal ?? null;
|
|
671
|
-
task.endTime = Date.now();
|
|
672
|
-
if (error) task.error = error;
|
|
673
|
-
if (task.stream && !task.stream.destroyed) task.stream.end();
|
|
674
|
-
|
|
675
|
-
for (const waiter of task.waiters.splice(0)) waiter();
|
|
676
|
-
|
|
677
|
-
try {
|
|
678
|
-
await writeMetadata(task);
|
|
679
|
-
} catch (metadataError) {
|
|
680
|
-
console.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
updateUi();
|
|
684
|
-
try {
|
|
685
|
-
await notifyCompletion(task);
|
|
686
|
-
} catch (notificationError) {
|
|
687
|
-
console.error(`[background-tasks] notification failed for ${task.id}:`, notificationError);
|
|
688
|
-
}
|
|
689
|
-
try {
|
|
690
|
-
await writeMetadata(task);
|
|
691
|
-
} catch (metadataError) {
|
|
692
|
-
console.error(`[background-tasks] failed to update notification metadata for ${task.id}:`, metadataError);
|
|
693
|
-
}
|
|
694
|
-
pruneOldTasks();
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function pruneOldTasks(): void {
|
|
698
|
-
if (tasks.size <= MAX_RECENT_TASKS) return;
|
|
699
|
-
const removable = [...tasks.values()]
|
|
700
|
-
.filter((task) => task.status !== "running")
|
|
701
|
-
.sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
|
|
702
|
-
while (tasks.size > MAX_RECENT_TASKS && removable.length > 0) {
|
|
703
|
-
const task = removable.shift();
|
|
704
|
-
if (task) tasks.delete(task.id);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
async function startTask(
|
|
709
|
-
ctx: ExtensionContext,
|
|
710
|
-
command: string,
|
|
711
|
-
options: StartTaskOptions = {},
|
|
712
|
-
): Promise<BgTask> {
|
|
713
|
-
const normalizedCommand = stripMatchingQuotes(command);
|
|
714
|
-
if (!normalizedCommand) throw new Error("Background command is empty");
|
|
715
|
-
if (shuttingDown) throw new Error("Cannot start a background task while Pi is shutting down");
|
|
716
|
-
|
|
152
|
+
async function startTask(ctx: ExtensionContext, command: string, options: StartTaskOptions = {}): Promise<BgTask> {
|
|
717
153
|
currentCtx = ctx;
|
|
718
|
-
|
|
719
|
-
const id = makeTaskId();
|
|
720
|
-
const outputAbsPath = join(dir.abs, `${id}.output`);
|
|
721
|
-
const metadataAbsPath = join(dir.abs, `${id}.json`);
|
|
722
|
-
const outputPath = join(dir.display, `${id}.output`);
|
|
723
|
-
const timeoutSeconds =
|
|
724
|
-
typeof options.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds) && options.timeoutSeconds > 0
|
|
725
|
-
? Math.floor(options.timeoutSeconds)
|
|
726
|
-
: undefined;
|
|
727
|
-
const taskName = normalizeTaskName(options.name) ?? normalizeTaskName(options.description) ?? deriveTaskNameFromCommand(normalizedCommand);
|
|
728
|
-
|
|
729
|
-
const task: BgTask = {
|
|
730
|
-
id,
|
|
731
|
-
name: taskName,
|
|
732
|
-
command: normalizedCommand,
|
|
733
|
-
description: options.description?.trim() || undefined,
|
|
734
|
-
status: "running",
|
|
735
|
-
outputPath,
|
|
736
|
-
outputAbsPath,
|
|
737
|
-
metadataAbsPath,
|
|
738
|
-
cwd: ctx.cwd,
|
|
739
|
-
startTime: Date.now(),
|
|
740
|
-
exitCode: undefined,
|
|
741
|
-
pid: undefined,
|
|
742
|
-
bytesWritten: 0,
|
|
743
|
-
notified: false,
|
|
744
|
-
notifyOnCompletion: options.notifyOnCompletion ?? true,
|
|
745
|
-
triggerOnCompletion: options.triggerOnCompletion ?? false,
|
|
746
|
-
timeoutSeconds,
|
|
747
|
-
waiters: [],
|
|
748
|
-
};
|
|
749
|
-
tasks.set(id, task);
|
|
750
|
-
|
|
751
|
-
const stream = createWriteStream(outputAbsPath, { flags: "a", encoding: "utf8" });
|
|
752
|
-
task.stream = stream;
|
|
753
|
-
stream.on("error", (error) => {
|
|
754
|
-
task.error = `Output file write failed: ${error.message}`;
|
|
755
|
-
if (task.status === "running") {
|
|
756
|
-
task.killKind = "output_cap";
|
|
757
|
-
try {
|
|
758
|
-
requestKill(task, "SIGTERM");
|
|
759
|
-
} catch (killError) {
|
|
760
|
-
void finalizeTask(
|
|
761
|
-
task,
|
|
762
|
-
"failed",
|
|
763
|
-
null,
|
|
764
|
-
undefined,
|
|
765
|
-
`${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
|
|
766
|
-
);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
try {
|
|
772
|
-
const invocation = shellInvocation(normalizedCommand);
|
|
773
|
-
const child = spawn(invocation.shell, invocation.args, {
|
|
774
|
-
cwd: ctx.cwd,
|
|
775
|
-
detached: process.platform !== "win32",
|
|
776
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
777
|
-
env: process.env,
|
|
778
|
-
windowsHide: true,
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
task.child = child;
|
|
782
|
-
task.pid = child.pid;
|
|
783
|
-
|
|
784
|
-
child.stdout?.on("data", (data) => appendToOutput(task, data));
|
|
785
|
-
child.stderr?.on("data", (data) => appendToOutput(task, data));
|
|
786
|
-
|
|
787
|
-
child.on("error", (error) => {
|
|
788
|
-
appendToOutput(task, `\n[background task spawn error: ${error.message}]\n`);
|
|
789
|
-
void finalizeTask(task, "failed", null, undefined, error.message);
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
child.on("close", (code, signalName) => {
|
|
793
|
-
let status: TaskStatus;
|
|
794
|
-
let error: string | undefined;
|
|
795
|
-
if (task.killKind === "user" || task.killKind === "shutdown") {
|
|
796
|
-
status = "killed";
|
|
797
|
-
} else if (task.killKind === "timeout") {
|
|
798
|
-
status = "failed";
|
|
799
|
-
error = task.error || `Timed out after ${task.timeoutSeconds}s`;
|
|
800
|
-
} else if (task.killKind === "output_cap") {
|
|
801
|
-
status = "failed";
|
|
802
|
-
error = task.error || `Output exceeded cap of ${formatSize(MAX_OUTPUT_BYTES)}`;
|
|
803
|
-
} else if ((code ?? 0) === 0) {
|
|
804
|
-
status = "completed";
|
|
805
|
-
} else {
|
|
806
|
-
status = "failed";
|
|
807
|
-
error = `Exited with code ${code ?? "null"}${signalName ? ` (${signalName})` : ""}`;
|
|
808
|
-
}
|
|
809
|
-
void finalizeTask(task, status, code, signalName, error);
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
if (timeoutSeconds) {
|
|
813
|
-
task.timeoutHandle = setTimeout(() => {
|
|
814
|
-
if (task.status !== "running") return;
|
|
815
|
-
task.killKind = "timeout";
|
|
816
|
-
task.error = `Timed out after ${timeoutSeconds}s`;
|
|
817
|
-
appendToOutput(task, `\n[background task timeout: ${task.error}]\n`);
|
|
818
|
-
try {
|
|
819
|
-
requestKill(task, "SIGTERM");
|
|
820
|
-
} catch (error) {
|
|
821
|
-
void finalizeTask(task, "failed", null, undefined, `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
822
|
-
}
|
|
823
|
-
}, timeoutSeconds * 1000);
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
await writeMetadata(task);
|
|
827
|
-
updateUi(ctx);
|
|
828
|
-
return task;
|
|
829
|
-
} catch (error) {
|
|
830
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
831
|
-
appendToOutput(task, `\n[background task spawn exception: ${message}]\n`);
|
|
832
|
-
await finalizeTask(task, "failed", null, undefined, message);
|
|
833
|
-
throw new Error(`Failed to start background task: ${message}`);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function resolveTask(idOrPrefix: string): BgTask {
|
|
838
|
-
const id = idOrPrefix.trim();
|
|
839
|
-
if (!id) throw new Error("Task ID is required");
|
|
840
|
-
const exact = tasks.get(id);
|
|
841
|
-
if (exact) return exact;
|
|
842
|
-
const matches = [...tasks.values()].filter((task) => task.id.startsWith(id));
|
|
843
|
-
if (matches.length === 1) return matches[0];
|
|
844
|
-
if (matches.length > 1) throw new Error(`Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(", ")}`);
|
|
845
|
-
throw new Error(`Unknown background task ID: ${id}`);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
async function getTaskLogs(task: BgTask, maxBytes: number, tail: boolean): Promise<{ text: string; details: BgLogsDetails }> {
|
|
849
|
-
if (!existsSync(task.outputAbsPath)) {
|
|
850
|
-
throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
|
|
851
|
-
}
|
|
852
|
-
const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
|
|
853
|
-
const direction = tail ? "tail" : "head";
|
|
854
|
-
let text = read.content || "(no output yet)";
|
|
855
|
-
if (read.truncated) {
|
|
856
|
-
const omitted = read.totalBytes - read.bytesRead;
|
|
857
|
-
const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
|
|
858
|
-
text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
|
|
859
|
-
} else {
|
|
860
|
-
text += `\n\n[Full output: ${task.outputPath}]`;
|
|
861
|
-
}
|
|
862
|
-
return {
|
|
863
|
-
text,
|
|
864
|
-
details: {
|
|
865
|
-
task: snapshot(task),
|
|
866
|
-
path: task.outputPath,
|
|
867
|
-
bytesRead: read.bytesRead,
|
|
868
|
-
truncated: read.truncated,
|
|
869
|
-
tail,
|
|
870
|
-
},
|
|
871
|
-
};
|
|
154
|
+
return registry.startTask(ctx, command, options);
|
|
872
155
|
}
|
|
873
156
|
|
|
874
157
|
async function openTaskManager(ctx: ExtensionCommandContext | ExtensionContext, initialTaskId?: string): Promise<void> {
|
|
@@ -881,55 +164,47 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
881
164
|
updateUi(ctx);
|
|
882
165
|
try {
|
|
883
166
|
await ctx.ui.custom<TaskManagerResult>(
|
|
884
|
-
(tui, theme, _keybindings, done) =>
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
await stopTask(resolveTask(task.id), "user");
|
|
167
|
+
(tui, theme, _keybindings, done) => {
|
|
168
|
+
const managerOptions = {
|
|
169
|
+
getTasks: () => registry.allTasks(),
|
|
170
|
+
stopTask: async (task: BackgroundTaskForUi) => {
|
|
171
|
+
await registry.stopTask(registry.resolveTask(task.id), "user");
|
|
890
172
|
updateUi(ctx);
|
|
891
173
|
},
|
|
892
174
|
stopAllRunning: async () => {
|
|
893
|
-
const
|
|
894
|
-
const failures: string[] = [];
|
|
895
|
-
let stopped = 0;
|
|
896
|
-
await Promise.all(
|
|
897
|
-
running.map(async (task) => {
|
|
898
|
-
try {
|
|
899
|
-
await stopTask(task, "user");
|
|
900
|
-
stopped++;
|
|
901
|
-
} catch (error) {
|
|
902
|
-
failures.push(`${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`);
|
|
903
|
-
}
|
|
904
|
-
}),
|
|
905
|
-
);
|
|
175
|
+
const result = await registry.stopAllRunning("user");
|
|
906
176
|
updateUi(ctx);
|
|
907
|
-
return
|
|
177
|
+
return result;
|
|
908
178
|
},
|
|
909
|
-
rerunTask: async (task) => {
|
|
910
|
-
const
|
|
179
|
+
rerunTask: async (task: BackgroundTaskForUi) => {
|
|
180
|
+
const rerunOptions: StartTaskOptions = {
|
|
911
181
|
name: taskDisplayName(task),
|
|
912
|
-
|
|
913
|
-
timeoutSeconds: task.timeoutSeconds,
|
|
182
|
+
isAgent: task.isAgent,
|
|
914
183
|
notifyOnCompletion: true,
|
|
915
184
|
triggerOnCompletion: false,
|
|
916
|
-
}
|
|
185
|
+
};
|
|
186
|
+
if (task.description !== undefined) rerunOptions.description = task.description;
|
|
187
|
+
if (task.timeoutSeconds !== undefined) rerunOptions.timeoutSeconds = task.timeoutSeconds;
|
|
188
|
+
const rerun = await startTask(ctx, task.command, rerunOptions);
|
|
917
189
|
updateUi(ctx);
|
|
918
190
|
return rerun;
|
|
919
191
|
},
|
|
920
|
-
showOutputPath: (task) => {
|
|
192
|
+
showOutputPath: (task: BackgroundTaskForUi) => {
|
|
921
193
|
ctx.ui.notify(`Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`, "info");
|
|
922
194
|
},
|
|
923
|
-
markSeen: (taskId) => {
|
|
195
|
+
markSeen: (taskId: string) => {
|
|
924
196
|
seenTaskIds.add(taskId);
|
|
925
197
|
updateUi(ctx);
|
|
926
198
|
},
|
|
927
|
-
markFinishedSeen: (taskIds) => {
|
|
199
|
+
markFinishedSeen: (taskIds: string[]) => {
|
|
928
200
|
for (const taskId of taskIds) seenTaskIds.add(taskId);
|
|
929
201
|
updateUi(ctx);
|
|
930
202
|
},
|
|
931
|
-
isSeen: (taskId) => seenTaskIds.has(taskId),
|
|
932
|
-
}
|
|
203
|
+
isSeen: (taskId: string) => seenTaskIds.has(taskId),
|
|
204
|
+
};
|
|
205
|
+
if (initialTaskId) return new BackgroundTasksManager(tui, theme, done, { ...managerOptions, initialTaskId });
|
|
206
|
+
return new BackgroundTasksManager(tui, theme, done, managerOptions);
|
|
207
|
+
},
|
|
933
208
|
{
|
|
934
209
|
overlay: true,
|
|
935
210
|
overlayOptions: { anchor: "bottom-center", width: "96%", minWidth: 64, maxHeight: "60%", margin: { bottom: 1, left: 1, right: 1 } },
|
|
@@ -944,38 +219,38 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
944
219
|
pi.registerMessageRenderer<BgTaskSnapshot>("background-task-notification", (message, _options, theme) => {
|
|
945
220
|
const task = message.details;
|
|
946
221
|
const status = task?.status ?? "completed";
|
|
947
|
-
const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
|
|
222
|
+
const color: ThemeColor = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
|
|
948
223
|
const id = task?.id ?? "background task";
|
|
949
224
|
const name = task ? taskDisplayName(task) : "Background task";
|
|
950
225
|
const output = task?.outputPath ? `\n${theme.fg("dim", `Output: ${task.outputPath}`)}` : "";
|
|
951
226
|
const error = task?.error ? `\n${theme.fg("error", task.error)}` : "";
|
|
952
|
-
return new Text(`${theme.fg(color
|
|
227
|
+
return new Text(`${theme.fg(color, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
|
|
953
228
|
});
|
|
954
229
|
|
|
955
230
|
pi.on("session_start", async (_event, ctx) => {
|
|
956
|
-
|
|
231
|
+
registry.setShuttingDown(false);
|
|
957
232
|
currentCtx = ctx;
|
|
958
|
-
await ensureRuntimeDir(ctx);
|
|
233
|
+
await registry.ensureRuntimeDir(ctx);
|
|
959
234
|
updateUi(ctx);
|
|
960
235
|
if (statusInterval) clearInterval(statusInterval);
|
|
961
236
|
statusInterval = setInterval(() => updateUi(), STATUS_INTERVAL_MS);
|
|
962
237
|
});
|
|
963
238
|
|
|
964
239
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
965
|
-
|
|
240
|
+
registry.setShuttingDown(true);
|
|
966
241
|
currentCtx = undefined;
|
|
967
242
|
if (statusInterval) {
|
|
968
243
|
clearInterval(statusInterval);
|
|
969
244
|
statusInterval = undefined;
|
|
970
245
|
}
|
|
971
|
-
const running =
|
|
246
|
+
const running = registry.allTasks().filter((task) => task.status === "running");
|
|
972
247
|
if (running.length === 0) return;
|
|
973
248
|
|
|
974
249
|
const failures: string[] = [];
|
|
975
250
|
await Promise.all(
|
|
976
251
|
running.map(async (task) => {
|
|
977
252
|
try {
|
|
978
|
-
await stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
|
|
253
|
+
await registry.stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
|
|
979
254
|
} catch (error) {
|
|
980
255
|
const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
981
256
|
failures.push(message);
|
|
@@ -989,11 +264,13 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
989
264
|
});
|
|
990
265
|
|
|
991
266
|
pi.registerCommand("bg", {
|
|
992
|
-
description: "Start a shell command as a tracked background task: /bg [--name \"Task name\"] <command>",
|
|
267
|
+
description: "Start a shell command as a tracked background task: /bg [--agent] [--name \"Task name\"] <command>",
|
|
993
268
|
handler: async (args, ctx) => {
|
|
994
269
|
try {
|
|
995
270
|
const parsed = parseBgCommandArgs(args);
|
|
996
|
-
const
|
|
271
|
+
const taskOptions: StartTaskOptions = { isAgent: parsed.isAgent, notifyOnCompletion: true, triggerOnCompletion: false };
|
|
272
|
+
if (parsed.name !== undefined) taskOptions.name = parsed.name;
|
|
273
|
+
const task = await startTask(ctx, parsed.command, taskOptions);
|
|
997
274
|
ctx.ui.notify(`Started ${taskDisplayName(task)} (${task.id})\nOutput: ${task.outputPath}\nCommand: ${task.command}`, "info");
|
|
998
275
|
} catch (error) {
|
|
999
276
|
ctx.ui.notify(`Background task failed to start: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
@@ -1017,34 +294,30 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1017
294
|
},
|
|
1018
295
|
});
|
|
1019
296
|
|
|
1020
|
-
pi.
|
|
297
|
+
pi.registerCommand("bg-clear", {
|
|
298
|
+
description: "Clear finished background task footer notices",
|
|
299
|
+
handler: async (_args, ctx) => {
|
|
300
|
+
notifyClearFinishedNotices(ctx);
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
pi.registerShortcut("shift+down" satisfies KeyId, {
|
|
1021
305
|
description: "Open focused background task footer dock",
|
|
1022
306
|
handler: async (ctx) => {
|
|
1023
307
|
await openTaskManager(ctx);
|
|
1024
308
|
},
|
|
1025
309
|
});
|
|
1026
310
|
|
|
1027
|
-
pi.registerShortcut("
|
|
1028
|
-
description: "Clear finished background task footer notices",
|
|
1029
|
-
handler: (ctx) =>
|
|
1030
|
-
currentCtx = ctx;
|
|
1031
|
-
const cleared = clearFinishedNotices(ctx);
|
|
1032
|
-
if (ctx.hasUI) {
|
|
1033
|
-
ctx.ui.notify(
|
|
1034
|
-
cleared > 0
|
|
1035
|
-
? `Cleared ${cleared} finished background task notice${cleared === 1 ? "" : "s"}.`
|
|
1036
|
-
: "No finished background task notices to clear.",
|
|
1037
|
-
cleared > 0 ? "info" : "warning",
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1040
|
-
},
|
|
311
|
+
pi.registerShortcut("ctrl+alt+c" satisfies KeyId, {
|
|
312
|
+
description: "Clear finished background task footer notices (terminal-dependent fallback for /bg-clear)",
|
|
313
|
+
handler: (ctx) => notifyClearFinishedNotices(ctx),
|
|
1041
314
|
});
|
|
1042
315
|
|
|
1043
316
|
pi.registerCommand("jobs", {
|
|
1044
317
|
description: "List running and recent background tasks",
|
|
1045
318
|
handler: async (_args, ctx) => {
|
|
1046
319
|
currentCtx = ctx;
|
|
1047
|
-
ctx.ui.notify(
|
|
320
|
+
ctx.ui.notify(formatSnapshotList(registry.allTasks().map((task) => registry.snapshot(task))), "info");
|
|
1048
321
|
updateUi(ctx);
|
|
1049
322
|
},
|
|
1050
323
|
});
|
|
@@ -1052,7 +325,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1052
325
|
pi.registerCommand("logs", {
|
|
1053
326
|
description: "Show bounded output from a background task: /logs <id> [maxBytes]",
|
|
1054
327
|
getArgumentCompletions: (prefix) => {
|
|
1055
|
-
const matches =
|
|
328
|
+
const matches = registry.allTasks()
|
|
1056
329
|
.filter((task) => task.id.startsWith(prefix.trim()))
|
|
1057
330
|
.slice(0, 20)
|
|
1058
331
|
.map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: `${task.status} — ${truncateChars(task.command, 60)}` }));
|
|
@@ -1062,9 +335,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1062
335
|
try {
|
|
1063
336
|
currentCtx = ctx;
|
|
1064
337
|
const [id, bytes] = args.trim().split(/\s+/, 2);
|
|
1065
|
-
const task = resolveTask(id || "");
|
|
1066
|
-
const maxBytes = normalizeMaxBytes(Number(bytes));
|
|
1067
|
-
const logs = await getTaskLogs(task, maxBytes, true);
|
|
338
|
+
const task = registry.resolveTask(id || "");
|
|
339
|
+
const maxBytes = normalizeMaxBytes(Number(bytes), DEFAULT_LOG_BYTES);
|
|
340
|
+
const logs = await registry.getTaskLogs(task, maxBytes, true);
|
|
1068
341
|
ctx.ui.notify(logs.text, "info");
|
|
1069
342
|
} catch (error) {
|
|
1070
343
|
ctx.ui.notify(`Background logs error: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
@@ -1075,7 +348,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1075
348
|
pi.registerCommand("kill", {
|
|
1076
349
|
description: "Stop a running background task: /kill <id>",
|
|
1077
350
|
getArgumentCompletions: (prefix) => {
|
|
1078
|
-
const matches =
|
|
351
|
+
const matches = registry.allTasks()
|
|
1079
352
|
.filter((task) => task.status === "running" && task.id.startsWith(prefix.trim()))
|
|
1080
353
|
.slice(0, 20)
|
|
1081
354
|
.map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: truncateChars(task.command, 70) }));
|
|
@@ -1084,8 +357,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1084
357
|
handler: async (args, ctx) => {
|
|
1085
358
|
try {
|
|
1086
359
|
currentCtx = ctx;
|
|
1087
|
-
const task = resolveTask(args.trim());
|
|
1088
|
-
await stopTask(task, "user");
|
|
360
|
+
const task = registry.resolveTask(args.trim());
|
|
361
|
+
await registry.stopTask(task, "user");
|
|
1089
362
|
ctx.ui.notify(`Killed ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`, "info");
|
|
1090
363
|
updateUi(ctx);
|
|
1091
364
|
} catch (error) {
|
|
@@ -1094,15 +367,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1094
367
|
},
|
|
1095
368
|
});
|
|
1096
369
|
|
|
1097
|
-
const BgRunParams = Type.Object({
|
|
1098
|
-
name: Type.String({ description: "Short human-readable task name shown in the bg footer dock. Required; use 2-6 words, not the raw command." }),
|
|
1099
|
-
command: Type.String({ description: "Shell command to start in the background" }),
|
|
1100
|
-
description: Type.Optional(Type.String({ description: "Optional longer human-readable context for the task" })),
|
|
1101
|
-
timeoutSeconds: Type.Optional(Type.Number({ description: "Optional timeout; task is failed and killed when exceeded" })),
|
|
1102
|
-
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Whether to show a completion notification. Default: true." })),
|
|
1103
|
-
triggerOnCompletion: Type.Optional(Type.Boolean({ description: "Whether completion should trigger a follow-up agent turn. Default: true for bg_run." })),
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
370
|
pi.registerTool<typeof BgRunParams, BgRunDetails>({
|
|
1107
371
|
name: "bg_run",
|
|
1108
372
|
label: "Background Run",
|
|
@@ -1110,37 +374,50 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1110
374
|
promptSnippet: "Start named long-running shell commands in the background and return a task ID plus output file path",
|
|
1111
375
|
promptGuidelines: [
|
|
1112
376
|
"Use bg_run instead of bash for commands expected to run for a long time, such as test suites, dev servers, watchers, builds, or sleeps.",
|
|
377
|
+
"Always set isAgent: true only when the background task launches an LLM/agent process; set isAgent: false for scripts, tests, dev servers, sleeps, and ordinary shell commands.",
|
|
1113
378
|
"When using bg_run, always set name to a concise 2-6 word human-readable label for the footer task dock; do not use the raw command as the name unless it is already short and meaningful.",
|
|
1114
379
|
"After bg_run, use bg_status and bg_logs to inspect progress; do not assume the background task completed until status says completed, failed, or killed.",
|
|
1115
380
|
"When a <background-task-notification> appears, react to it: inspect bg_status/bg_logs as needed, then report completion, failure, or next steps to the user.",
|
|
1116
381
|
],
|
|
1117
382
|
parameters: BgRunParams,
|
|
1118
|
-
prepareArguments(args) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
if (
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
383
|
+
prepareArguments(args): BgRunParamsValue {
|
|
384
|
+
if (!args || typeof args !== "object") throw new Error("bg_run arguments must be an object");
|
|
385
|
+
const input = args as Record<string, unknown>;
|
|
386
|
+
if (typeof input["command"] !== "string") throw new Error("bg_run requires command string");
|
|
387
|
+
if (typeof input["isAgent"] !== "boolean") {
|
|
388
|
+
throw new Error("bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.");
|
|
389
|
+
}
|
|
390
|
+
const prepared: BgRunParamsValue = {
|
|
391
|
+
command: input["command"],
|
|
392
|
+
name: normalizeTaskName(input["name"]) ?? normalizeTaskName(input["description"]) ?? deriveTaskNameFromCommand(input["command"]),
|
|
393
|
+
isAgent: input["isAgent"],
|
|
1127
394
|
};
|
|
395
|
+
if (typeof input["description"] === "string") prepared.description = input["description"];
|
|
396
|
+
if (typeof input["timeoutSeconds"] === "number") prepared.timeoutSeconds = input["timeoutSeconds"];
|
|
397
|
+
if (typeof input["notifyOnCompletion"] === "boolean") prepared.notifyOnCompletion = input["notifyOnCompletion"];
|
|
398
|
+
if (typeof input["triggerOnCompletion"] === "boolean") prepared.triggerOnCompletion = input["triggerOnCompletion"];
|
|
399
|
+
return prepared;
|
|
1128
400
|
},
|
|
1129
401
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1130
|
-
|
|
402
|
+
if (typeof params.isAgent !== "boolean") {
|
|
403
|
+
throw new Error("bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.");
|
|
404
|
+
}
|
|
405
|
+
const taskOptions: StartTaskOptions = {
|
|
1131
406
|
name: params.name,
|
|
1132
|
-
|
|
1133
|
-
timeoutSeconds: params.timeoutSeconds,
|
|
407
|
+
isAgent: params.isAgent,
|
|
1134
408
|
notifyOnCompletion: params.notifyOnCompletion ?? true,
|
|
1135
409
|
triggerOnCompletion: params.triggerOnCompletion ?? true,
|
|
1136
|
-
}
|
|
410
|
+
};
|
|
411
|
+
if (params.description !== undefined) taskOptions.description = params.description;
|
|
412
|
+
if (params.timeoutSeconds !== undefined) taskOptions.timeoutSeconds = params.timeoutSeconds;
|
|
413
|
+
const task = await startTask(ctx, params.command, taskOptions);
|
|
1137
414
|
return {
|
|
1138
415
|
content: textContent(`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${task.pid ?? "unknown"}\nOutput: ${task.outputPath}`),
|
|
1139
|
-
details: { task: snapshot(task) },
|
|
416
|
+
details: { task: registry.snapshot(task) },
|
|
1140
417
|
};
|
|
1141
418
|
},
|
|
1142
419
|
renderCall(args, theme) {
|
|
1143
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args),
|
|
420
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args), COMMAND_PREVIEW_CHARS))}`, 0, 0);
|
|
1144
421
|
},
|
|
1145
422
|
renderResult(result, _options, theme) {
|
|
1146
423
|
const task = result.details?.task;
|
|
@@ -1149,10 +426,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1149
426
|
},
|
|
1150
427
|
});
|
|
1151
428
|
|
|
1152
|
-
const BgStatusParams = Type.Object({
|
|
1153
|
-
taskId: Type.Optional(Type.String({ description: "Optional task ID or unambiguous prefix. If omitted, all running/recent tasks are returned." })),
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
429
|
pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
|
|
1157
430
|
name: "bg_status",
|
|
1158
431
|
label: "Background Status",
|
|
@@ -1161,8 +434,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1161
434
|
promptGuidelines: ["Use bg_status before bg_logs when you need to know whether a background task is still running or has finished."],
|
|
1162
435
|
parameters: BgStatusParams,
|
|
1163
436
|
async execute(_toolCallId, params) {
|
|
1164
|
-
const selected = params.taskId ? [resolveTask(params.taskId)] :
|
|
1165
|
-
const snapshots = selected.map(snapshot);
|
|
437
|
+
const selected = params.taskId ? [registry.resolveTask(params.taskId)] : registry.allTasks();
|
|
438
|
+
const snapshots = selected.map((task) => registry.snapshot(task));
|
|
1166
439
|
return {
|
|
1167
440
|
content: textContent(formatSnapshotList(snapshots)),
|
|
1168
441
|
details: { tasks: snapshots },
|
|
@@ -1174,12 +447,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1174
447
|
renderResult: renderPlainResult,
|
|
1175
448
|
});
|
|
1176
449
|
|
|
1177
|
-
const BgLogsParams = Type.Object({
|
|
1178
|
-
taskId: Type.String({ description: "Task ID or unambiguous prefix" }),
|
|
1179
|
-
maxBytes: Type.Optional(Type.Number({ description: `Maximum bytes to return, capped at ${formatSize(MAX_LOG_BYTES)}. Default: ${formatSize(DEFAULT_LOG_BYTES)}.` })),
|
|
1180
|
-
tail: Type.Optional(Type.Boolean({ description: "Read the tail of the log when true, head when false. Default: true." })),
|
|
1181
|
-
});
|
|
1182
|
-
|
|
1183
450
|
pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
|
|
1184
451
|
name: "bg_logs",
|
|
1185
452
|
label: "Background Logs",
|
|
@@ -1188,8 +455,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1188
455
|
promptGuidelines: ["Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context."],
|
|
1189
456
|
parameters: BgLogsParams,
|
|
1190
457
|
async execute(_toolCallId, params) {
|
|
1191
|
-
const task = resolveTask(params.taskId);
|
|
1192
|
-
const logs = await getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
|
|
458
|
+
const task = registry.resolveTask(params.taskId);
|
|
459
|
+
const logs = await registry.getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
|
|
1193
460
|
return {
|
|
1194
461
|
content: textContent(logs.text),
|
|
1195
462
|
details: logs.details,
|
|
@@ -1212,10 +479,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1212
479
|
},
|
|
1213
480
|
});
|
|
1214
481
|
|
|
1215
|
-
const BgKillParams = Type.Object({
|
|
1216
|
-
taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1219
482
|
pi.registerTool<typeof BgKillParams, BgKillDetails>({
|
|
1220
483
|
name: "bg_kill",
|
|
1221
484
|
label: "Background Kill",
|
|
@@ -1224,12 +487,12 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1224
487
|
promptGuidelines: ["Use bg_kill when the user asks to stop a background task or when a bg_run command is no longer needed."],
|
|
1225
488
|
parameters: BgKillParams,
|
|
1226
489
|
async execute(_toolCallId, params) {
|
|
1227
|
-
const task = resolveTask(params.taskId);
|
|
1228
|
-
await stopTask(task, "user");
|
|
490
|
+
const task = registry.resolveTask(params.taskId);
|
|
491
|
+
await registry.stopTask(task, "user");
|
|
1229
492
|
const message = `Killed background task ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`;
|
|
1230
493
|
return {
|
|
1231
494
|
content: textContent(message),
|
|
1232
|
-
details: { task: snapshot(task), message },
|
|
495
|
+
details: { task: registry.snapshot(task), message },
|
|
1233
496
|
};
|
|
1234
497
|
},
|
|
1235
498
|
renderCall(args, theme) {
|