pi-background-tasks 0.2.0 → 0.4.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 +30 -12
- package/TESTING.md +21 -7
- package/TEST_PLAN.md +36 -28
- package/package.json +6 -3
- package/src/core/common.ts +461 -16
- package/src/core/registry.ts +958 -0
- package/src/core/update-check.ts +99 -0
- package/src/extension.ts +231 -909
- package/src/ui/background-tasks-manager.ts +71 -14
package/src/extension.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
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
|
+
formatUpdateSegment,
|
|
11
|
+
isNewerVersion,
|
|
12
|
+
normalizeMaxBytes,
|
|
13
|
+
normalizeTaskName,
|
|
14
|
+
parseBgCommandArgs,
|
|
15
|
+
taskDisplayName,
|
|
16
|
+
truncateChars,
|
|
17
|
+
type BgKillDetails,
|
|
18
|
+
type BgLogsDetails,
|
|
19
|
+
type BgRunDetails,
|
|
20
|
+
type BgStatusDetails,
|
|
21
|
+
type BgTask,
|
|
22
|
+
type BgTaskSnapshot,
|
|
23
|
+
type StartTaskOptions,
|
|
24
|
+
} from "./core/common.js";
|
|
25
|
+
import { fetchLatestVersion, readPackageInfo, type FetchLatestVersionOptions } from "./core/update-check.js";
|
|
26
|
+
import { BackgroundTaskRegistry } from "./core/registry.js";
|
|
27
|
+
import { BackgroundTasksManager, type BackgroundTaskForUi, type TaskManagerResult } from "./ui/background-tasks-manager.js";
|
|
11
28
|
|
|
12
29
|
/**
|
|
13
30
|
* Project-local Pi background task manager.
|
|
@@ -19,389 +36,80 @@ import { Type } from "typebox";
|
|
|
19
36
|
* extension runtime and are killed on session shutdown/reload.
|
|
20
37
|
*/
|
|
21
38
|
|
|
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
39
|
const STATUS_INTERVAL_MS = 1000;
|
|
99
40
|
const COMMAND_PREVIEW_CHARS = 90;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
const
|
|
41
|
+
const GIT_INSTALL_TARGET = "git:github.com/ismailsaleekh/pi-background-tasks";
|
|
42
|
+
|
|
43
|
+
const packageInfo = readPackageInfo(new URL("../package.json", import.meta.url), (error) => {
|
|
44
|
+
console.error(`[background-tasks] failed to read package version: ${error.message}`);
|
|
45
|
+
});
|
|
46
|
+
const PACKAGE_NAME = packageInfo.name ?? "pi-background-tasks";
|
|
47
|
+
const PACKAGE_VERSION = packageInfo.version;
|
|
103
48
|
const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
|
|
104
49
|
const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
|
|
105
|
-
const LIGHT_BLUE_BORDER = "\x1b[38;2;83;160;215m";
|
|
106
50
|
const ANSI_RESET = "\x1b[0m";
|
|
107
51
|
|
|
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
52
|
function lightBlue(value: string): string {
|
|
231
53
|
return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
|
|
232
54
|
}
|
|
233
55
|
|
|
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
56
|
function textContent(text: string) {
|
|
298
57
|
return [{ type: "text" as const, text }];
|
|
299
58
|
}
|
|
300
59
|
|
|
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);
|
|
60
|
+
type TextToolResult = { content?: readonly { type: string; text?: string }[] };
|
|
61
|
+
|
|
62
|
+
const BgRunParams = Type.Object({
|
|
63
|
+
name: Type.String({ description: "Short human-readable task name shown in the bg footer dock. Required; use 2-6 words, not the raw command." }),
|
|
64
|
+
command: Type.String({ description: "Shell command to start in the background" }),
|
|
65
|
+
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." }),
|
|
66
|
+
description: Type.Optional(Type.String({ description: "Optional longer human-readable context for the task" })),
|
|
67
|
+
timeoutSeconds: Type.Optional(Type.Number({ description: "Optional timeout; task is failed and killed when exceeded" })),
|
|
68
|
+
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Whether to show a completion notification. Default: true." })),
|
|
69
|
+
triggerOnCompletion: Type.Optional(Type.Boolean({ description: "Whether completion should trigger a follow-up agent turn. Default: true for bg_run." })),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const BgStatusParams = Type.Object({
|
|
73
|
+
taskId: Type.Optional(Type.String({ description: "Optional task ID or unambiguous prefix. If omitted, all running/recent tasks are returned." })),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const BgLogsParams = Type.Object({
|
|
77
|
+
taskId: Type.String({ description: "Task ID or unambiguous prefix" }),
|
|
78
|
+
maxBytes: Type.Optional(Type.Number({ description: `Maximum bytes to return, capped at ${formatSize(MAX_LOG_BYTES)}. Default: ${formatSize(DEFAULT_LOG_BYTES)}.` })),
|
|
79
|
+
tail: Type.Optional(Type.Boolean({ description: "Read the tail of the log when true, head when false. Default: true." })),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const BgKillParams = Type.Object({
|
|
83
|
+
taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
type BgRunParamsValue = Static<typeof BgRunParams>;
|
|
87
|
+
type BgStatusParamsValue = Static<typeof BgStatusParams>;
|
|
88
|
+
type BgLogsParamsValue = Static<typeof BgLogsParams>;
|
|
89
|
+
type BgKillParamsValue = Static<typeof BgKillParams>;
|
|
90
|
+
|
|
91
|
+
function renderPlainResult(result: TextToolResult, _options: ToolRenderResultOptions, _theme: Theme) {
|
|
92
|
+
const text = result.content?.map((part) => part.type === "text" ? (part.text ?? "") : "").join("\n") ?? "";
|
|
93
|
+
return new Text(text, 0, 0);
|
|
369
94
|
}
|
|
370
95
|
|
|
371
96
|
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
372
|
-
const
|
|
373
|
-
let runtimeDirAbs: string | undefined;
|
|
374
|
-
let runtimeDirDisplay: string | undefined;
|
|
97
|
+
const seenTaskIds = new Set<string>();
|
|
375
98
|
let currentCtx: ExtensionContext | undefined;
|
|
376
99
|
let dockOpen = false;
|
|
377
|
-
let shuttingDown = false;
|
|
378
100
|
let statusInterval: NodeJS.Timeout | undefined;
|
|
379
|
-
|
|
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
|
-
}
|
|
101
|
+
let latestKnownVersion: string | undefined;
|
|
102
|
+
let updateCheckStarted = false;
|
|
388
103
|
|
|
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
|
-
}
|
|
104
|
+
const registry = new BackgroundTaskRegistry({
|
|
105
|
+
onChange: () => updateUi(),
|
|
106
|
+
sendCompletionNotification: (message, options) => {
|
|
107
|
+
pi.sendMessage(message, options);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
402
110
|
|
|
403
111
|
function unseenFinishedTasks(): BgTask[] {
|
|
404
|
-
return
|
|
112
|
+
return registry.allTasks().filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
|
|
405
113
|
}
|
|
406
114
|
|
|
407
115
|
function clearFinishedNotices(ctx = currentCtx): number {
|
|
@@ -411,19 +119,32 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
411
119
|
return unseen.length;
|
|
412
120
|
}
|
|
413
121
|
|
|
122
|
+
function notifyClearFinishedNotices(ctx: ExtensionContext): void {
|
|
123
|
+
currentCtx = ctx;
|
|
124
|
+
const cleared = clearFinishedNotices(ctx);
|
|
125
|
+
if (!ctx.hasUI) return;
|
|
126
|
+
ctx.ui.notify(
|
|
127
|
+
cleared > 0
|
|
128
|
+
? `Cleared ${cleared} finished background task notice${cleared === 1 ? "" : "s"}.`
|
|
129
|
+
: "No finished background task notices to clear.",
|
|
130
|
+
cleared > 0 ? "info" : "warning",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
414
134
|
function updateUi(ctx = currentCtx): void {
|
|
415
|
-
if (
|
|
135
|
+
if (registry.isShuttingDown() || !ctx) return;
|
|
416
136
|
try {
|
|
417
137
|
if (!ctx.hasUI) return;
|
|
418
|
-
const allTasks =
|
|
138
|
+
const allTasks = registry.allTasks();
|
|
419
139
|
const running = allTasks.filter((task) => task.status === "running");
|
|
420
140
|
const unseenFailed = allTasks.filter((task) => task.status === "failed" && !seenTaskIds.has(task.id));
|
|
421
141
|
const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
|
|
422
142
|
const unseenDone = allTasks.filter((task) => task.status === "completed" && !seenTaskIds.has(task.id));
|
|
423
143
|
const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
|
|
144
|
+
const updateSegment = formatUpdateSegment(latestKnownVersion, PACKAGE_VERSION ?? "");
|
|
424
145
|
ctx.ui.setWidget("background-tasks", undefined);
|
|
425
146
|
if (running.length === 0 && unseenFinishedCount === 0) {
|
|
426
|
-
ctx.ui.setStatus("background-tasks", undefined);
|
|
147
|
+
ctx.ui.setStatus("background-tasks", updateSegment ? lightBlue(` bg ${updateSegment} `) : undefined);
|
|
427
148
|
return;
|
|
428
149
|
}
|
|
429
150
|
|
|
@@ -432,8 +153,10 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
432
153
|
if (unseenFailed.length > 0) parts.push(`${unseenFailed.length} failed`);
|
|
433
154
|
if (unseenStopped.length > 0) parts.push(`${unseenStopped.length} stopped`);
|
|
434
155
|
if (unseenDone.length > 0) parts.push(`${unseenDone.length} done`);
|
|
435
|
-
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " ·
|
|
436
|
-
const
|
|
156
|
+
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · /bg-clear" : ""}`;
|
|
157
|
+
const segments = [...parts, entryHint];
|
|
158
|
+
if (updateSegment) segments.push(updateSegment);
|
|
159
|
+
const label = ` bg ${segments.join(" · ")} `;
|
|
437
160
|
ctx.ui.setStatus("background-tasks", lightBlue(label));
|
|
438
161
|
} catch (error) {
|
|
439
162
|
console.error(`[background-tasks] UI update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -441,434 +164,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
441
164
|
}
|
|
442
165
|
}
|
|
443
166
|
|
|
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
|
-
|
|
167
|
+
async function startTask(ctx: ExtensionContext, command: string, options: StartTaskOptions = {}): Promise<BgTask> {
|
|
717
168
|
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
|
-
};
|
|
169
|
+
return registry.startTask(ctx, command, options);
|
|
872
170
|
}
|
|
873
171
|
|
|
874
172
|
async function openTaskManager(ctx: ExtensionCommandContext | ExtensionContext, initialTaskId?: string): Promise<void> {
|
|
@@ -881,55 +179,47 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
881
179
|
updateUi(ctx);
|
|
882
180
|
try {
|
|
883
181
|
await ctx.ui.custom<TaskManagerResult>(
|
|
884
|
-
(tui, theme, _keybindings, done) =>
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
await stopTask(resolveTask(task.id), "user");
|
|
182
|
+
(tui, theme, _keybindings, done) => {
|
|
183
|
+
const managerOptions = {
|
|
184
|
+
getTasks: () => registry.allTasks(),
|
|
185
|
+
stopTask: async (task: BackgroundTaskForUi) => {
|
|
186
|
+
await registry.stopTask(registry.resolveTask(task.id), "user");
|
|
890
187
|
updateUi(ctx);
|
|
891
188
|
},
|
|
892
189
|
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
|
-
);
|
|
190
|
+
const result = await registry.stopAllRunning("user");
|
|
906
191
|
updateUi(ctx);
|
|
907
|
-
return
|
|
192
|
+
return result;
|
|
908
193
|
},
|
|
909
|
-
rerunTask: async (task) => {
|
|
910
|
-
const
|
|
194
|
+
rerunTask: async (task: BackgroundTaskForUi) => {
|
|
195
|
+
const rerunOptions: StartTaskOptions = {
|
|
911
196
|
name: taskDisplayName(task),
|
|
912
|
-
|
|
913
|
-
timeoutSeconds: task.timeoutSeconds,
|
|
197
|
+
isAgent: task.isAgent,
|
|
914
198
|
notifyOnCompletion: true,
|
|
915
199
|
triggerOnCompletion: false,
|
|
916
|
-
}
|
|
200
|
+
};
|
|
201
|
+
if (task.description !== undefined) rerunOptions.description = task.description;
|
|
202
|
+
if (task.timeoutSeconds !== undefined) rerunOptions.timeoutSeconds = task.timeoutSeconds;
|
|
203
|
+
const rerun = await startTask(ctx, task.command, rerunOptions);
|
|
917
204
|
updateUi(ctx);
|
|
918
205
|
return rerun;
|
|
919
206
|
},
|
|
920
|
-
showOutputPath: (task) => {
|
|
207
|
+
showOutputPath: (task: BackgroundTaskForUi) => {
|
|
921
208
|
ctx.ui.notify(`Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`, "info");
|
|
922
209
|
},
|
|
923
|
-
markSeen: (taskId) => {
|
|
210
|
+
markSeen: (taskId: string) => {
|
|
924
211
|
seenTaskIds.add(taskId);
|
|
925
212
|
updateUi(ctx);
|
|
926
213
|
},
|
|
927
|
-
markFinishedSeen: (taskIds) => {
|
|
214
|
+
markFinishedSeen: (taskIds: string[]) => {
|
|
928
215
|
for (const taskId of taskIds) seenTaskIds.add(taskId);
|
|
929
216
|
updateUi(ctx);
|
|
930
217
|
},
|
|
931
|
-
isSeen: (taskId) => seenTaskIds.has(taskId),
|
|
932
|
-
}
|
|
218
|
+
isSeen: (taskId: string) => seenTaskIds.has(taskId),
|
|
219
|
+
};
|
|
220
|
+
if (initialTaskId) return new BackgroundTasksManager(tui, theme, done, { ...managerOptions, initialTaskId });
|
|
221
|
+
return new BackgroundTasksManager(tui, theme, done, managerOptions);
|
|
222
|
+
},
|
|
933
223
|
{
|
|
934
224
|
overlay: true,
|
|
935
225
|
overlayOptions: { anchor: "bottom-center", width: "96%", minWidth: 64, maxHeight: "60%", margin: { bottom: 1, left: 1, right: 1 } },
|
|
@@ -944,38 +234,60 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
944
234
|
pi.registerMessageRenderer<BgTaskSnapshot>("background-task-notification", (message, _options, theme) => {
|
|
945
235
|
const task = message.details;
|
|
946
236
|
const status = task?.status ?? "completed";
|
|
947
|
-
const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
|
|
237
|
+
const color: ThemeColor = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
|
|
948
238
|
const id = task?.id ?? "background task";
|
|
949
239
|
const name = task ? taskDisplayName(task) : "Background task";
|
|
950
240
|
const output = task?.outputPath ? `\n${theme.fg("dim", `Output: ${task.outputPath}`)}` : "";
|
|
951
241
|
const error = task?.error ? `\n${theme.fg("error", task.error)}` : "";
|
|
952
|
-
return new Text(`${theme.fg(color
|
|
242
|
+
return new Text(`${theme.fg(color, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
|
|
953
243
|
});
|
|
954
244
|
|
|
245
|
+
async function scheduleUpdateCheck(ctx: ExtensionContext): Promise<void> {
|
|
246
|
+
if (updateCheckStarted) return;
|
|
247
|
+
updateCheckStarted = true;
|
|
248
|
+
const env = process.env;
|
|
249
|
+
if (env["PI_BG_DISABLE_UPDATE_CHECK"] === "1") return;
|
|
250
|
+
if (env["PI_OFFLINE"] === "1") return;
|
|
251
|
+
if (!PACKAGE_VERSION) return;
|
|
252
|
+
const options: FetchLatestVersionOptions = {
|
|
253
|
+
packageName: PACKAGE_NAME,
|
|
254
|
+
onError: (error) => console.error(`[background-tasks] update check skipped: ${error.message}`),
|
|
255
|
+
};
|
|
256
|
+
const registryUrl = env["PI_BG_REGISTRY_URL"];
|
|
257
|
+
if (registryUrl) options.registryUrl = registryUrl;
|
|
258
|
+
const latest = await fetchLatestVersion(options);
|
|
259
|
+
if (latest && isNewerVersion(latest, PACKAGE_VERSION)) {
|
|
260
|
+
latestKnownVersion = latest;
|
|
261
|
+
updateUi(ctx);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
955
265
|
pi.on("session_start", async (_event, ctx) => {
|
|
956
|
-
|
|
266
|
+
registry.setShuttingDown(false);
|
|
957
267
|
currentCtx = ctx;
|
|
958
|
-
await ensureRuntimeDir(ctx);
|
|
268
|
+
await registry.ensureRuntimeDir(ctx);
|
|
959
269
|
updateUi(ctx);
|
|
960
270
|
if (statusInterval) clearInterval(statusInterval);
|
|
961
271
|
statusInterval = setInterval(() => updateUi(), STATUS_INTERVAL_MS);
|
|
272
|
+
// One-shot, non-blocking: never awaited on the session-start path or the status tick.
|
|
273
|
+
void scheduleUpdateCheck(ctx);
|
|
962
274
|
});
|
|
963
275
|
|
|
964
276
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
965
|
-
|
|
277
|
+
registry.setShuttingDown(true);
|
|
966
278
|
currentCtx = undefined;
|
|
967
279
|
if (statusInterval) {
|
|
968
280
|
clearInterval(statusInterval);
|
|
969
281
|
statusInterval = undefined;
|
|
970
282
|
}
|
|
971
|
-
const running =
|
|
283
|
+
const running = registry.allTasks().filter((task) => task.status === "running");
|
|
972
284
|
if (running.length === 0) return;
|
|
973
285
|
|
|
974
286
|
const failures: string[] = [];
|
|
975
287
|
await Promise.all(
|
|
976
288
|
running.map(async (task) => {
|
|
977
289
|
try {
|
|
978
|
-
await stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
|
|
290
|
+
await registry.stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
|
|
979
291
|
} catch (error) {
|
|
980
292
|
const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
981
293
|
failures.push(message);
|
|
@@ -989,11 +301,13 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
989
301
|
});
|
|
990
302
|
|
|
991
303
|
pi.registerCommand("bg", {
|
|
992
|
-
description: "Start a shell command as a tracked background task: /bg [--name \"Task name\"] <command>",
|
|
304
|
+
description: "Start a shell command as a tracked background task: /bg [--agent] [--name \"Task name\"] <command>",
|
|
993
305
|
handler: async (args, ctx) => {
|
|
994
306
|
try {
|
|
995
307
|
const parsed = parseBgCommandArgs(args);
|
|
996
|
-
const
|
|
308
|
+
const taskOptions: StartTaskOptions = { isAgent: parsed.isAgent, notifyOnCompletion: true, triggerOnCompletion: false };
|
|
309
|
+
if (parsed.name !== undefined) taskOptions.name = parsed.name;
|
|
310
|
+
const task = await startTask(ctx, parsed.command, taskOptions);
|
|
997
311
|
ctx.ui.notify(`Started ${taskDisplayName(task)} (${task.id})\nOutput: ${task.outputPath}\nCommand: ${task.command}`, "info");
|
|
998
312
|
} catch (error) {
|
|
999
313
|
ctx.ui.notify(`Background task failed to start: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
@@ -1017,34 +331,52 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1017
331
|
},
|
|
1018
332
|
});
|
|
1019
333
|
|
|
1020
|
-
pi.
|
|
334
|
+
pi.registerCommand("bg-clear", {
|
|
335
|
+
description: "Clear finished background task footer notices",
|
|
336
|
+
handler: async (_args, ctx) => {
|
|
337
|
+
notifyClearFinishedNotices(ctx);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
pi.registerCommand("bg-update", {
|
|
342
|
+
description: "Show how to update pi-background-tasks to the latest published version",
|
|
343
|
+
handler: async (_args, ctx) => {
|
|
344
|
+
const current = PACKAGE_VERSION ?? "unknown";
|
|
345
|
+
const latest = latestKnownVersion;
|
|
346
|
+
const pinnedNpm = latest ? `${PACKAGE_NAME}@${latest}` : `${PACKAGE_NAME}@<version>`;
|
|
347
|
+
const pinnedGit = latest ? `${GIT_INSTALL_TARGET}@v${latest}` : `${GIT_INSTALL_TARGET}@<tag>`;
|
|
348
|
+
const lines = [
|
|
349
|
+
latest
|
|
350
|
+
? `pi-background-tasks ${current} is installed; ${latest} is the latest published version.`
|
|
351
|
+
: `pi-background-tasks ${current} is installed.`,
|
|
352
|
+
"Update from npm:",
|
|
353
|
+
` pi install npm:${PACKAGE_NAME}@latest`,
|
|
354
|
+
` pi install npm:${pinnedNpm}`,
|
|
355
|
+
"Or update from git tags:",
|
|
356
|
+
` pi install ${pinnedGit}`,
|
|
357
|
+
"This command only prints update instructions; it does not install or self-update.",
|
|
358
|
+
];
|
|
359
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
pi.registerShortcut("shift+down" satisfies KeyId, {
|
|
1021
364
|
description: "Open focused background task footer dock",
|
|
1022
365
|
handler: async (ctx) => {
|
|
1023
366
|
await openTaskManager(ctx);
|
|
1024
367
|
},
|
|
1025
368
|
});
|
|
1026
369
|
|
|
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
|
-
},
|
|
370
|
+
pi.registerShortcut("ctrl+alt+c" satisfies KeyId, {
|
|
371
|
+
description: "Clear finished background task footer notices (terminal-dependent fallback for /bg-clear)",
|
|
372
|
+
handler: (ctx) => notifyClearFinishedNotices(ctx),
|
|
1041
373
|
});
|
|
1042
374
|
|
|
1043
375
|
pi.registerCommand("jobs", {
|
|
1044
376
|
description: "List running and recent background tasks",
|
|
1045
377
|
handler: async (_args, ctx) => {
|
|
1046
378
|
currentCtx = ctx;
|
|
1047
|
-
ctx.ui.notify(
|
|
379
|
+
ctx.ui.notify(formatSnapshotList(registry.allTasks().map((task) => registry.snapshot(task))), "info");
|
|
1048
380
|
updateUi(ctx);
|
|
1049
381
|
},
|
|
1050
382
|
});
|
|
@@ -1052,7 +384,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1052
384
|
pi.registerCommand("logs", {
|
|
1053
385
|
description: "Show bounded output from a background task: /logs <id> [maxBytes]",
|
|
1054
386
|
getArgumentCompletions: (prefix) => {
|
|
1055
|
-
const matches =
|
|
387
|
+
const matches = registry.allTasks()
|
|
1056
388
|
.filter((task) => task.id.startsWith(prefix.trim()))
|
|
1057
389
|
.slice(0, 20)
|
|
1058
390
|
.map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: `${task.status} — ${truncateChars(task.command, 60)}` }));
|
|
@@ -1062,9 +394,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1062
394
|
try {
|
|
1063
395
|
currentCtx = ctx;
|
|
1064
396
|
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);
|
|
397
|
+
const task = registry.resolveTask(id || "");
|
|
398
|
+
const maxBytes = normalizeMaxBytes(Number(bytes), DEFAULT_LOG_BYTES);
|
|
399
|
+
const logs = await registry.getTaskLogs(task, maxBytes, true);
|
|
1068
400
|
ctx.ui.notify(logs.text, "info");
|
|
1069
401
|
} catch (error) {
|
|
1070
402
|
ctx.ui.notify(`Background logs error: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
@@ -1075,7 +407,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1075
407
|
pi.registerCommand("kill", {
|
|
1076
408
|
description: "Stop a running background task: /kill <id>",
|
|
1077
409
|
getArgumentCompletions: (prefix) => {
|
|
1078
|
-
const matches =
|
|
410
|
+
const matches = registry.allTasks()
|
|
1079
411
|
.filter((task) => task.status === "running" && task.id.startsWith(prefix.trim()))
|
|
1080
412
|
.slice(0, 20)
|
|
1081
413
|
.map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: truncateChars(task.command, 70) }));
|
|
@@ -1084,8 +416,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1084
416
|
handler: async (args, ctx) => {
|
|
1085
417
|
try {
|
|
1086
418
|
currentCtx = ctx;
|
|
1087
|
-
const task = resolveTask(args.trim());
|
|
1088
|
-
await stopTask(task, "user");
|
|
419
|
+
const task = registry.resolveTask(args.trim());
|
|
420
|
+
await registry.stopTask(task, "user");
|
|
1089
421
|
ctx.ui.notify(`Killed ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`, "info");
|
|
1090
422
|
updateUi(ctx);
|
|
1091
423
|
} catch (error) {
|
|
@@ -1094,15 +426,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1094
426
|
},
|
|
1095
427
|
});
|
|
1096
428
|
|
|
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
429
|
pi.registerTool<typeof BgRunParams, BgRunDetails>({
|
|
1107
430
|
name: "bg_run",
|
|
1108
431
|
label: "Background Run",
|
|
@@ -1110,37 +433,50 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1110
433
|
promptSnippet: "Start named long-running shell commands in the background and return a task ID plus output file path",
|
|
1111
434
|
promptGuidelines: [
|
|
1112
435
|
"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.",
|
|
436
|
+
"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
437
|
"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
438
|
"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
439
|
"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
440
|
],
|
|
1117
441
|
parameters: BgRunParams,
|
|
1118
|
-
prepareArguments(args) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
if (
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
442
|
+
prepareArguments(args): BgRunParamsValue {
|
|
443
|
+
if (!args || typeof args !== "object") throw new Error("bg_run arguments must be an object");
|
|
444
|
+
const input = args as Record<string, unknown>;
|
|
445
|
+
if (typeof input["command"] !== "string") throw new Error("bg_run requires command string");
|
|
446
|
+
if (typeof input["isAgent"] !== "boolean") {
|
|
447
|
+
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.");
|
|
448
|
+
}
|
|
449
|
+
const prepared: BgRunParamsValue = {
|
|
450
|
+
command: input["command"],
|
|
451
|
+
name: normalizeTaskName(input["name"]) ?? normalizeTaskName(input["description"]) ?? deriveTaskNameFromCommand(input["command"]),
|
|
452
|
+
isAgent: input["isAgent"],
|
|
1127
453
|
};
|
|
454
|
+
if (typeof input["description"] === "string") prepared.description = input["description"];
|
|
455
|
+
if (typeof input["timeoutSeconds"] === "number") prepared.timeoutSeconds = input["timeoutSeconds"];
|
|
456
|
+
if (typeof input["notifyOnCompletion"] === "boolean") prepared.notifyOnCompletion = input["notifyOnCompletion"];
|
|
457
|
+
if (typeof input["triggerOnCompletion"] === "boolean") prepared.triggerOnCompletion = input["triggerOnCompletion"];
|
|
458
|
+
return prepared;
|
|
1128
459
|
},
|
|
1129
460
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1130
|
-
|
|
461
|
+
if (typeof params.isAgent !== "boolean") {
|
|
462
|
+
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.");
|
|
463
|
+
}
|
|
464
|
+
const taskOptions: StartTaskOptions = {
|
|
1131
465
|
name: params.name,
|
|
1132
|
-
|
|
1133
|
-
timeoutSeconds: params.timeoutSeconds,
|
|
466
|
+
isAgent: params.isAgent,
|
|
1134
467
|
notifyOnCompletion: params.notifyOnCompletion ?? true,
|
|
1135
468
|
triggerOnCompletion: params.triggerOnCompletion ?? true,
|
|
1136
|
-
}
|
|
469
|
+
};
|
|
470
|
+
if (params.description !== undefined) taskOptions.description = params.description;
|
|
471
|
+
if (params.timeoutSeconds !== undefined) taskOptions.timeoutSeconds = params.timeoutSeconds;
|
|
472
|
+
const task = await startTask(ctx, params.command, taskOptions);
|
|
1137
473
|
return {
|
|
1138
474
|
content: textContent(`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${task.pid ?? "unknown"}\nOutput: ${task.outputPath}`),
|
|
1139
|
-
details: { task: snapshot(task) },
|
|
475
|
+
details: { task: registry.snapshot(task) },
|
|
1140
476
|
};
|
|
1141
477
|
},
|
|
1142
478
|
renderCall(args, theme) {
|
|
1143
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args),
|
|
479
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args), COMMAND_PREVIEW_CHARS))}`, 0, 0);
|
|
1144
480
|
},
|
|
1145
481
|
renderResult(result, _options, theme) {
|
|
1146
482
|
const task = result.details?.task;
|
|
@@ -1149,10 +485,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1149
485
|
},
|
|
1150
486
|
});
|
|
1151
487
|
|
|
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
488
|
pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
|
|
1157
489
|
name: "bg_status",
|
|
1158
490
|
label: "Background Status",
|
|
@@ -1161,8 +493,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1161
493
|
promptGuidelines: ["Use bg_status before bg_logs when you need to know whether a background task is still running or has finished."],
|
|
1162
494
|
parameters: BgStatusParams,
|
|
1163
495
|
async execute(_toolCallId, params) {
|
|
1164
|
-
const selected = params.taskId ? [resolveTask(params.taskId)] :
|
|
1165
|
-
const snapshots = selected.map(snapshot);
|
|
496
|
+
const selected = params.taskId ? [registry.resolveTask(params.taskId)] : registry.allTasks();
|
|
497
|
+
const snapshots = selected.map((task) => registry.snapshot(task));
|
|
1166
498
|
return {
|
|
1167
499
|
content: textContent(formatSnapshotList(snapshots)),
|
|
1168
500
|
details: { tasks: snapshots },
|
|
@@ -1174,12 +506,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1174
506
|
renderResult: renderPlainResult,
|
|
1175
507
|
});
|
|
1176
508
|
|
|
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
509
|
pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
|
|
1184
510
|
name: "bg_logs",
|
|
1185
511
|
label: "Background Logs",
|
|
@@ -1188,8 +514,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1188
514
|
promptGuidelines: ["Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context."],
|
|
1189
515
|
parameters: BgLogsParams,
|
|
1190
516
|
async execute(_toolCallId, params) {
|
|
1191
|
-
const task = resolveTask(params.taskId);
|
|
1192
|
-
const logs = await getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
|
|
517
|
+
const task = registry.resolveTask(params.taskId);
|
|
518
|
+
const logs = await registry.getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
|
|
1193
519
|
return {
|
|
1194
520
|
content: textContent(logs.text),
|
|
1195
521
|
details: logs.details,
|
|
@@ -1212,10 +538,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1212
538
|
},
|
|
1213
539
|
});
|
|
1214
540
|
|
|
1215
|
-
const BgKillParams = Type.Object({
|
|
1216
|
-
taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1219
541
|
pi.registerTool<typeof BgKillParams, BgKillDetails>({
|
|
1220
542
|
name: "bg_kill",
|
|
1221
543
|
label: "Background Kill",
|
|
@@ -1224,12 +546,12 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1224
546
|
promptGuidelines: ["Use bg_kill when the user asks to stop a background task or when a bg_run command is no longer needed."],
|
|
1225
547
|
parameters: BgKillParams,
|
|
1226
548
|
async execute(_toolCallId, params) {
|
|
1227
|
-
const task = resolveTask(params.taskId);
|
|
1228
|
-
await stopTask(task, "user");
|
|
549
|
+
const task = registry.resolveTask(params.taskId);
|
|
550
|
+
await registry.stopTask(task, "user");
|
|
1229
551
|
const message = `Killed background task ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`;
|
|
1230
552
|
return {
|
|
1231
553
|
content: textContent(message),
|
|
1232
|
-
details: { task: snapshot(task), message },
|
|
554
|
+
details: { task: registry.snapshot(task), message },
|
|
1233
555
|
};
|
|
1234
556
|
},
|
|
1235
557
|
renderCall(args, theme) {
|