pi-background-tasks 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISHING.md +10 -8
- package/README.md +64 -9
- package/TESTING.md +103 -0
- package/TEST_PLAN.md +91 -0
- package/extensions/background-tasks.ts +1 -1305
- package/package.json +25 -2
- package/src/core/common.ts +24 -0
- package/src/extension.ts +1244 -0
- package/src/testing/normalize.ts +3 -0
- package/src/ui/background-tasks-manager.ts +565 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { createWriteStream, existsSync, statSync, type WriteStream } from "node:fs";
|
|
4
|
+
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { DEFAULT_MAX_BYTES, formatSize } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { BackgroundTasksManager, type StopAllResult, type TaskManagerResult } from "./ui/background-tasks-manager.js";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Project-local Pi background task manager.
|
|
14
|
+
*
|
|
15
|
+
* Scope:
|
|
16
|
+
* - Explicit background shell jobs only: /bg and bg_run spawn commands directly.
|
|
17
|
+
* - No Ctrl+B support for backgrounding an already-running built-in bash tool.
|
|
18
|
+
* - No detached/restart reattachment: live child processes belong to this Pi
|
|
19
|
+
* extension runtime and are killed on session shutdown/reload.
|
|
20
|
+
*/
|
|
21
|
+
|
|
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
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
99
|
+
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
|
+
const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
|
|
104
|
+
const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
|
|
105
|
+
const LIGHT_BLUE_BORDER = "\x1b[38;2;83;160;215m";
|
|
106
|
+
const ANSI_RESET = "\x1b[0m";
|
|
107
|
+
|
|
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
|
+
function lightBlue(value: string): string {
|
|
231
|
+
return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
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
|
+
function textContent(text: string) {
|
|
298
|
+
return [{ type: "text" as const, text }];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function taskAge(task: BgTask, now = Date.now()): string {
|
|
302
|
+
return formatDuration((task.endTime ?? now) - task.startTime);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatTaskLine(task: BgTask, now = Date.now()): string {
|
|
306
|
+
const statusIcon =
|
|
307
|
+
task.status === "running" ? "▶" : task.status === "completed" ? "✓" : task.status === "killed" ? "■" : "✗";
|
|
308
|
+
const code = task.exitCode !== undefined ? ` exit=${task.exitCode}` : "";
|
|
309
|
+
const pid = task.pid ? ` pid=${task.pid}` : "";
|
|
310
|
+
const error = task.error ? ` error=${truncateChars(task.error, 80)}` : "";
|
|
311
|
+
const label = taskDisplayName(task);
|
|
312
|
+
return `${statusIcon} ${task.id} ${task.status} ${taskAge(task, now)}${code}${pid} — ${truncateChars(label, COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function formatTaskList(tasks: BgTask[], now = Date.now()): string {
|
|
316
|
+
if (tasks.length === 0) return "No background tasks in this Pi extension runtime.";
|
|
317
|
+
const running = tasks.filter((task) => task.status === "running");
|
|
318
|
+
const finished = tasks
|
|
319
|
+
.filter((task) => task.status !== "running")
|
|
320
|
+
.sort((a, b) => (b.endTime ?? b.startTime) - (a.endTime ?? a.startTime))
|
|
321
|
+
.slice(0, 20);
|
|
322
|
+
const ordered = [...running.sort((a, b) => a.startTime - b.startTime), ...finished];
|
|
323
|
+
return ordered.map((task) => formatTaskLine(task, now)).join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
|
|
327
|
+
if (tasks.length === 0) return "No background tasks in this Pi extension runtime.";
|
|
328
|
+
return tasks
|
|
329
|
+
.map((task) => {
|
|
330
|
+
const statusIcon =
|
|
331
|
+
task.status === "running" ? "▶" : task.status === "completed" ? "✓" : task.status === "killed" ? "■" : "✗";
|
|
332
|
+
const age = formatDuration((task.endTime ?? now) - task.startTime);
|
|
333
|
+
const code = task.exitCode !== undefined ? ` exit=${task.exitCode}` : "";
|
|
334
|
+
const pid = task.pid ? ` pid=${task.pid}` : "";
|
|
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);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
372
|
+
const tasks = new Map<string, BgTask>();
|
|
373
|
+
let runtimeDirAbs: string | undefined;
|
|
374
|
+
let runtimeDirDisplay: string | undefined;
|
|
375
|
+
let currentCtx: ExtensionContext | undefined;
|
|
376
|
+
let dockOpen = false;
|
|
377
|
+
let shuttingDown = false;
|
|
378
|
+
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
|
+
|
|
389
|
+
async function ensureRuntimeDir(ctx: ExtensionContext): Promise<{ abs: string; display: string }> {
|
|
390
|
+
if (runtimeDirAbs && runtimeDirDisplay) return { abs: runtimeDirAbs, display: runtimeDirDisplay };
|
|
391
|
+
const sessionId = sanitizePathSegment(ctx.sessionManager.getSessionId?.() || "session");
|
|
392
|
+
const runId = `${sessionId}-${process.pid}`;
|
|
393
|
+
runtimeDirAbs = join(ctx.cwd, ".pi", "tasks", runId);
|
|
394
|
+
runtimeDirDisplay = join(".pi", "tasks", runId);
|
|
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
|
+
}
|
|
402
|
+
|
|
403
|
+
function unseenFinishedTasks(): BgTask[] {
|
|
404
|
+
return [...tasks.values()].filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function clearFinishedNotices(ctx = currentCtx): number {
|
|
408
|
+
const unseen = unseenFinishedTasks();
|
|
409
|
+
for (const task of unseen) seenTaskIds.add(task.id);
|
|
410
|
+
updateUi(ctx);
|
|
411
|
+
return unseen.length;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function updateUi(ctx = currentCtx): void {
|
|
415
|
+
if (shuttingDown || !ctx) return;
|
|
416
|
+
try {
|
|
417
|
+
if (!ctx.hasUI) return;
|
|
418
|
+
const allTasks = [...tasks.values()];
|
|
419
|
+
const running = allTasks.filter((task) => task.status === "running");
|
|
420
|
+
const unseenFailed = allTasks.filter((task) => task.status === "failed" && !seenTaskIds.has(task.id));
|
|
421
|
+
const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
|
|
422
|
+
const unseenDone = allTasks.filter((task) => task.status === "completed" && !seenTaskIds.has(task.id));
|
|
423
|
+
const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
|
|
424
|
+
ctx.ui.setWidget("background-tasks", undefined);
|
|
425
|
+
if (running.length === 0 && unseenFinishedCount === 0) {
|
|
426
|
+
ctx.ui.setStatus("background-tasks", undefined);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const parts: string[] = [];
|
|
431
|
+
if (running.length > 0) parts.push(`${running.length} running`);
|
|
432
|
+
if (unseenFailed.length > 0) parts.push(`${unseenFailed.length} failed`);
|
|
433
|
+
if (unseenStopped.length > 0) parts.push(`${unseenStopped.length} stopped`);
|
|
434
|
+
if (unseenDone.length > 0) parts.push(`${unseenDone.length} done`);
|
|
435
|
+
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · C clear" : ""}`;
|
|
436
|
+
const label = ` bg ${parts.join(" · ")} · ${entryHint} `;
|
|
437
|
+
ctx.ui.setStatus("background-tasks", lightBlue(label));
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error(`[background-tasks] UI update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
440
|
+
currentCtx = undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function normalizeContextUsage(value: unknown): TaskContextUsage | undefined {
|
|
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
|
+
|
|
717
|
+
currentCtx = ctx;
|
|
718
|
+
const dir = await ensureRuntimeDir(ctx);
|
|
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
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function openTaskManager(ctx: ExtensionCommandContext | ExtensionContext, initialTaskId?: string): Promise<void> {
|
|
875
|
+
currentCtx = ctx;
|
|
876
|
+
if (!ctx.hasUI) {
|
|
877
|
+
ctx.ui.notify("Background task manager requires an interactive Pi UI. Use /jobs, /logs, or the bg_status/bg_logs tools in non-interactive mode.", "error");
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
dockOpen = true;
|
|
881
|
+
updateUi(ctx);
|
|
882
|
+
try {
|
|
883
|
+
await ctx.ui.custom<TaskManagerResult>(
|
|
884
|
+
(tui, theme, _keybindings, done) =>
|
|
885
|
+
new BackgroundTasksManager(tui, theme, done, {
|
|
886
|
+
initialTaskId,
|
|
887
|
+
getTasks: () => [...tasks.values()],
|
|
888
|
+
stopTask: async (task) => {
|
|
889
|
+
await stopTask(resolveTask(task.id), "user");
|
|
890
|
+
updateUi(ctx);
|
|
891
|
+
},
|
|
892
|
+
stopAllRunning: async () => {
|
|
893
|
+
const running = [...tasks.values()].filter((task) => task.status === "running");
|
|
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
|
+
);
|
|
906
|
+
updateUi(ctx);
|
|
907
|
+
return { stopped, failures };
|
|
908
|
+
},
|
|
909
|
+
rerunTask: async (task) => {
|
|
910
|
+
const rerun = await startTask(ctx, task.command, {
|
|
911
|
+
name: taskDisplayName(task),
|
|
912
|
+
description: task.description,
|
|
913
|
+
timeoutSeconds: task.timeoutSeconds,
|
|
914
|
+
notifyOnCompletion: true,
|
|
915
|
+
triggerOnCompletion: false,
|
|
916
|
+
});
|
|
917
|
+
updateUi(ctx);
|
|
918
|
+
return rerun;
|
|
919
|
+
},
|
|
920
|
+
showOutputPath: (task) => {
|
|
921
|
+
ctx.ui.notify(`Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`, "info");
|
|
922
|
+
},
|
|
923
|
+
markSeen: (taskId) => {
|
|
924
|
+
seenTaskIds.add(taskId);
|
|
925
|
+
updateUi(ctx);
|
|
926
|
+
},
|
|
927
|
+
markFinishedSeen: (taskIds) => {
|
|
928
|
+
for (const taskId of taskIds) seenTaskIds.add(taskId);
|
|
929
|
+
updateUi(ctx);
|
|
930
|
+
},
|
|
931
|
+
isSeen: (taskId) => seenTaskIds.has(taskId),
|
|
932
|
+
}),
|
|
933
|
+
{
|
|
934
|
+
overlay: true,
|
|
935
|
+
overlayOptions: { anchor: "bottom-center", width: "96%", minWidth: 64, maxHeight: "60%", margin: { bottom: 1, left: 1, right: 1 } },
|
|
936
|
+
},
|
|
937
|
+
);
|
|
938
|
+
} finally {
|
|
939
|
+
dockOpen = false;
|
|
940
|
+
updateUi(ctx);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
pi.registerMessageRenderer<BgTaskSnapshot>("background-task-notification", (message, _options, theme) => {
|
|
945
|
+
const task = message.details;
|
|
946
|
+
const status = task?.status ?? "completed";
|
|
947
|
+
const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
|
|
948
|
+
const id = task?.id ?? "background task";
|
|
949
|
+
const name = task ? taskDisplayName(task) : "Background task";
|
|
950
|
+
const output = task?.outputPath ? `\n${theme.fg("dim", `Output: ${task.outputPath}`)}` : "";
|
|
951
|
+
const error = task?.error ? `\n${theme.fg("error", task.error)}` : "";
|
|
952
|
+
return new Text(`${theme.fg(color as any, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
956
|
+
shuttingDown = false;
|
|
957
|
+
currentCtx = ctx;
|
|
958
|
+
await ensureRuntimeDir(ctx);
|
|
959
|
+
updateUi(ctx);
|
|
960
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
961
|
+
statusInterval = setInterval(() => updateUi(), STATUS_INTERVAL_MS);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
965
|
+
shuttingDown = true;
|
|
966
|
+
currentCtx = undefined;
|
|
967
|
+
if (statusInterval) {
|
|
968
|
+
clearInterval(statusInterval);
|
|
969
|
+
statusInterval = undefined;
|
|
970
|
+
}
|
|
971
|
+
const running = [...tasks.values()].filter((task) => task.status === "running");
|
|
972
|
+
if (running.length === 0) return;
|
|
973
|
+
|
|
974
|
+
const failures: string[] = [];
|
|
975
|
+
await Promise.all(
|
|
976
|
+
running.map(async (task) => {
|
|
977
|
+
try {
|
|
978
|
+
await stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
|
|
979
|
+
} catch (error) {
|
|
980
|
+
const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
981
|
+
failures.push(message);
|
|
982
|
+
console.error(`[background-tasks] shutdown cleanup failed for ${message}`);
|
|
983
|
+
}
|
|
984
|
+
}),
|
|
985
|
+
);
|
|
986
|
+
if (failures.length > 0 && ctx.hasUI) {
|
|
987
|
+
ctx.ui.notify(`Background task cleanup failed:\n${failures.join("\n")}`, "error");
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
pi.registerCommand("bg", {
|
|
992
|
+
description: "Start a shell command as a tracked background task: /bg [--name \"Task name\"] <command>",
|
|
993
|
+
handler: async (args, ctx) => {
|
|
994
|
+
try {
|
|
995
|
+
const parsed = parseBgCommandArgs(args);
|
|
996
|
+
const task = await startTask(ctx, parsed.command, { name: parsed.name, notifyOnCompletion: true, triggerOnCompletion: false });
|
|
997
|
+
ctx.ui.notify(`Started ${taskDisplayName(task)} (${task.id})\nOutput: ${task.outputPath}\nCommand: ${task.command}`, "info");
|
|
998
|
+
} catch (error) {
|
|
999
|
+
ctx.ui.notify(`Background task failed to start: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
pi.registerCommand("tasks", {
|
|
1005
|
+
description: "Open the Claude-like background task manager UI",
|
|
1006
|
+
handler: async (args, ctx) => {
|
|
1007
|
+
const taskId = args.trim() || undefined;
|
|
1008
|
+
await openTaskManager(ctx, taskId);
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
pi.registerCommand("bg-tasks", {
|
|
1013
|
+
description: "Open the background task manager UI",
|
|
1014
|
+
handler: async (args, ctx) => {
|
|
1015
|
+
const taskId = args.trim() || undefined;
|
|
1016
|
+
await openTaskManager(ctx, taskId);
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
pi.registerShortcut("shift+down" as any, {
|
|
1021
|
+
description: "Open focused background task footer dock",
|
|
1022
|
+
handler: async (ctx) => {
|
|
1023
|
+
await openTaskManager(ctx);
|
|
1024
|
+
},
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
pi.registerShortcut("shift+c" as any, {
|
|
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
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
pi.registerCommand("jobs", {
|
|
1044
|
+
description: "List running and recent background tasks",
|
|
1045
|
+
handler: async (_args, ctx) => {
|
|
1046
|
+
currentCtx = ctx;
|
|
1047
|
+
ctx.ui.notify(formatTaskList([...tasks.values()]), "info");
|
|
1048
|
+
updateUi(ctx);
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
pi.registerCommand("logs", {
|
|
1053
|
+
description: "Show bounded output from a background task: /logs <id> [maxBytes]",
|
|
1054
|
+
getArgumentCompletions: (prefix) => {
|
|
1055
|
+
const matches = [...tasks.values()]
|
|
1056
|
+
.filter((task) => task.id.startsWith(prefix.trim()))
|
|
1057
|
+
.slice(0, 20)
|
|
1058
|
+
.map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: `${task.status} — ${truncateChars(task.command, 60)}` }));
|
|
1059
|
+
return matches.length > 0 ? matches : null;
|
|
1060
|
+
},
|
|
1061
|
+
handler: async (args, ctx) => {
|
|
1062
|
+
try {
|
|
1063
|
+
currentCtx = ctx;
|
|
1064
|
+
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);
|
|
1068
|
+
ctx.ui.notify(logs.text, "info");
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
ctx.ui.notify(`Background logs error: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
pi.registerCommand("kill", {
|
|
1076
|
+
description: "Stop a running background task: /kill <id>",
|
|
1077
|
+
getArgumentCompletions: (prefix) => {
|
|
1078
|
+
const matches = [...tasks.values()]
|
|
1079
|
+
.filter((task) => task.status === "running" && task.id.startsWith(prefix.trim()))
|
|
1080
|
+
.slice(0, 20)
|
|
1081
|
+
.map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: truncateChars(task.command, 70) }));
|
|
1082
|
+
return matches.length > 0 ? matches : null;
|
|
1083
|
+
},
|
|
1084
|
+
handler: async (args, ctx) => {
|
|
1085
|
+
try {
|
|
1086
|
+
currentCtx = ctx;
|
|
1087
|
+
const task = resolveTask(args.trim());
|
|
1088
|
+
await stopTask(task, "user");
|
|
1089
|
+
ctx.ui.notify(`Killed ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`, "info");
|
|
1090
|
+
updateUi(ctx);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
ctx.ui.notify(`Background kill error: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1093
|
+
}
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
|
|
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
|
+
pi.registerTool<typeof BgRunParams, BgRunDetails>({
|
|
1107
|
+
name: "bg_run",
|
|
1108
|
+
label: "Background Run",
|
|
1109
|
+
description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
|
|
1110
|
+
promptSnippet: "Start named long-running shell commands in the background and return a task ID plus output file path",
|
|
1111
|
+
promptGuidelines: [
|
|
1112
|
+
"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.",
|
|
1113
|
+
"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
|
+
"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
|
+
"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
|
+
],
|
|
1117
|
+
parameters: BgRunParams,
|
|
1118
|
+
prepareArguments(args) {
|
|
1119
|
+
const fallback = { name: "Background task", command: "" };
|
|
1120
|
+
if (!args || typeof args !== "object") return fallback;
|
|
1121
|
+
const input = args as { name?: unknown; command?: unknown; description?: unknown };
|
|
1122
|
+
if (normalizeTaskName(input.name) && typeof input.command === "string") return input as any;
|
|
1123
|
+
if (typeof input.command !== "string") return fallback;
|
|
1124
|
+
return {
|
|
1125
|
+
...input,
|
|
1126
|
+
name: normalizeTaskName(input.description) ?? deriveTaskNameFromCommand(input.command),
|
|
1127
|
+
};
|
|
1128
|
+
},
|
|
1129
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1130
|
+
const task = await startTask(ctx, params.command, {
|
|
1131
|
+
name: params.name,
|
|
1132
|
+
description: params.description,
|
|
1133
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
1134
|
+
notifyOnCompletion: params.notifyOnCompletion ?? true,
|
|
1135
|
+
triggerOnCompletion: params.triggerOnCompletion ?? true,
|
|
1136
|
+
});
|
|
1137
|
+
return {
|
|
1138
|
+
content: textContent(`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${task.pid ?? "unknown"}\nOutput: ${task.outputPath}`),
|
|
1139
|
+
details: { task: snapshot(task) },
|
|
1140
|
+
};
|
|
1141
|
+
},
|
|
1142
|
+
renderCall(args, theme) {
|
|
1143
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args), 90))}`, 0, 0);
|
|
1144
|
+
},
|
|
1145
|
+
renderResult(result, _options, theme) {
|
|
1146
|
+
const task = result.details?.task;
|
|
1147
|
+
if (!task) return renderPlainResult(result, _options, theme);
|
|
1148
|
+
return new Text(`${theme.fg("success", "✓ started")} ${theme.fg("accent", taskDisplayName(task))} ${theme.fg("dim", `(${task.id})`)}\n${theme.fg("dim", `Output: ${task.outputPath}`)}`, 0, 0);
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
|
|
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
|
+
pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
|
|
1157
|
+
name: "bg_status",
|
|
1158
|
+
label: "Background Status",
|
|
1159
|
+
description: "Inspect one background task or list all running/recent background tasks.",
|
|
1160
|
+
promptSnippet: "Inspect status for one or all background tasks",
|
|
1161
|
+
promptGuidelines: ["Use bg_status before bg_logs when you need to know whether a background task is still running or has finished."],
|
|
1162
|
+
parameters: BgStatusParams,
|
|
1163
|
+
async execute(_toolCallId, params) {
|
|
1164
|
+
const selected = params.taskId ? [resolveTask(params.taskId)] : [...tasks.values()];
|
|
1165
|
+
const snapshots = selected.map(snapshot);
|
|
1166
|
+
return {
|
|
1167
|
+
content: textContent(formatSnapshotList(snapshots)),
|
|
1168
|
+
details: { tasks: snapshots },
|
|
1169
|
+
};
|
|
1170
|
+
},
|
|
1171
|
+
renderCall(args, theme) {
|
|
1172
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("bg_status"))}${args.taskId ? ` ${theme.fg("accent", args.taskId)}` : ""}`, 0, 0);
|
|
1173
|
+
},
|
|
1174
|
+
renderResult: renderPlainResult,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
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
|
+
pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
|
|
1184
|
+
name: "bg_logs",
|
|
1185
|
+
label: "Background Logs",
|
|
1186
|
+
description: `Read bounded output from a background task. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
|
|
1187
|
+
promptSnippet: "Read bounded output from a background task log",
|
|
1188
|
+
promptGuidelines: ["Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context."],
|
|
1189
|
+
parameters: BgLogsParams,
|
|
1190
|
+
async execute(_toolCallId, params) {
|
|
1191
|
+
const task = resolveTask(params.taskId);
|
|
1192
|
+
const logs = await getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
|
|
1193
|
+
return {
|
|
1194
|
+
content: textContent(logs.text),
|
|
1195
|
+
details: logs.details,
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
renderCall(args, theme) {
|
|
1199
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("bg_logs "))}${theme.fg("accent", args.taskId)}`, 0, 0);
|
|
1200
|
+
},
|
|
1201
|
+
renderResult(result, { expanded }, theme) {
|
|
1202
|
+
const details = result.details;
|
|
1203
|
+
if (!details) return renderPlainResult(result, { expanded, isPartial: false }, theme);
|
|
1204
|
+
let text = `${theme.fg("accent", taskDisplayName(details.task))} ${theme.fg("dim", `(${details.task.id})`)} ${theme.fg("muted", details.tail ? "tail" : "head")} ${formatSize(details.bytesRead)}`;
|
|
1205
|
+
if (details.truncated) text += theme.fg("warning", " (truncated)");
|
|
1206
|
+
text += `\n${theme.fg("dim", `Full output: ${details.path}`)}`;
|
|
1207
|
+
if (expanded) {
|
|
1208
|
+
const content = result.content?.[0];
|
|
1209
|
+
if (content?.type === "text") text += `\n${theme.fg("toolOutput", content.text.split("\n").slice(0, 30).join("\n"))}`;
|
|
1210
|
+
}
|
|
1211
|
+
return new Text(text, 0, 0);
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
const BgKillParams = Type.Object({
|
|
1216
|
+
taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
pi.registerTool<typeof BgKillParams, BgKillDetails>({
|
|
1220
|
+
name: "bg_kill",
|
|
1221
|
+
label: "Background Kill",
|
|
1222
|
+
description: "Stop a running background task by ID. Fails loudly if the task is unknown or already finished.",
|
|
1223
|
+
promptSnippet: "Stop a running background task by ID",
|
|
1224
|
+
promptGuidelines: ["Use bg_kill when the user asks to stop a background task or when a bg_run command is no longer needed."],
|
|
1225
|
+
parameters: BgKillParams,
|
|
1226
|
+
async execute(_toolCallId, params) {
|
|
1227
|
+
const task = resolveTask(params.taskId);
|
|
1228
|
+
await stopTask(task, "user");
|
|
1229
|
+
const message = `Killed background task ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`;
|
|
1230
|
+
return {
|
|
1231
|
+
content: textContent(message),
|
|
1232
|
+
details: { task: snapshot(task), message },
|
|
1233
|
+
};
|
|
1234
|
+
},
|
|
1235
|
+
renderCall(args, theme) {
|
|
1236
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("bg_kill "))}${theme.fg("accent", args.taskId)}`, 0, 0);
|
|
1237
|
+
},
|
|
1238
|
+
renderResult(result, _options, theme) {
|
|
1239
|
+
const task = result.details?.task;
|
|
1240
|
+
if (!task) return renderPlainResult(result, _options, theme);
|
|
1241
|
+
return new Text(`${theme.fg("warning", "■ killed")} ${theme.fg("accent", taskDisplayName(task))} ${theme.fg("dim", `(${task.id})`)}\n${theme.fg("dim", `Output: ${task.outputPath}`)}`, 0, 0);
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
}
|