pi-background-tasks 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/extension.ts CHANGED
@@ -1,13 +1,27 @@
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";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme, ThemeColor, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { formatSize } from "@earendil-works/pi-coding-agent";
3
+ import { Text, type KeyId } from "@earendil-works/pi-tui";
4
+ import { Type, type Static } from "typebox";
5
+ import {
6
+ DEFAULT_LOG_BYTES,
7
+ MAX_LOG_BYTES,
8
+ deriveTaskNameFromCommand,
9
+ formatSnapshotList,
10
+ normalizeMaxBytes,
11
+ normalizeTaskName,
12
+ parseBgCommandArgs,
13
+ taskDisplayName,
14
+ truncateChars,
15
+ type BgKillDetails,
16
+ type BgLogsDetails,
17
+ type BgRunDetails,
18
+ type BgStatusDetails,
19
+ type BgTask,
20
+ type BgTaskSnapshot,
21
+ type StartTaskOptions,
22
+ } from "./core/common.js";
23
+ import { BackgroundTaskRegistry } from "./core/registry.js";
24
+ import { BackgroundTasksManager, type BackgroundTaskForUi, type TaskManagerResult } from "./ui/background-tasks-manager.js";
11
25
 
12
26
  /**
13
27
  * Project-local Pi background task manager.
@@ -19,389 +33,71 @@ import { Type } from "typebox";
19
33
  * extension runtime and are killed on session shutdown/reload.
20
34
  */
21
35
 
22
- type TaskStatus = "running" | "completed" | "failed" | "killed";
23
- type KillKind = "user" | "timeout" | "output_cap" | "shutdown";
24
-
25
- type TaskContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
26
-
27
- type BgTaskSnapshot = {
28
- id: string;
29
- name: string;
30
- command: string;
31
- description?: string;
32
- status: TaskStatus;
33
- outputPath: string;
34
- cwd: string;
35
- startTime: number;
36
- endTime?: number;
37
- exitCode?: number | null;
38
- signal?: string | null;
39
- pid?: number;
40
- bytesWritten: number;
41
- error?: string;
42
- notified: boolean;
43
- notifyOnCompletion: boolean;
44
- triggerOnCompletion: boolean;
45
- timeoutSeconds?: number;
46
- contextUsage?: TaskContextUsage;
47
- };
48
-
49
- type BgTask = BgTaskSnapshot & {
50
- outputAbsPath: string;
51
- metadataAbsPath: string;
52
- child?: ChildProcess;
53
- stream?: WriteStream;
54
- timeoutHandle?: NodeJS.Timeout;
55
- killKind?: KillKind;
56
- killSignalSent?: boolean;
57
- capExceeded?: boolean;
58
- finalized?: boolean;
59
- contextUsageBuffer?: string;
60
- waiters: Array<() => void>;
61
- };
62
-
63
- type BgRunDetails = {
64
- task: BgTaskSnapshot;
65
- };
66
-
67
- type BgStatusDetails = {
68
- tasks: BgTaskSnapshot[];
69
- };
70
-
71
- type BgLogsDetails = {
72
- task: BgTaskSnapshot;
73
- path: string;
74
- bytesRead: number;
75
- truncated: boolean;
76
- tail: boolean;
77
- };
78
-
79
- type BgKillDetails = {
80
- task: BgTaskSnapshot;
81
- message: string;
82
- };
83
-
84
- type StartTaskOptions = {
85
- name?: string;
86
- description?: string;
87
- timeoutSeconds?: number;
88
- notifyOnCompletion?: boolean;
89
- triggerOnCompletion?: boolean;
90
- };
91
-
92
- const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
93
- const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
94
- const MAX_OUTPUT_BYTES = Number(process.env.PI_BG_MAX_OUTPUT_BYTES ?? 20 * 1024 * 1024);
95
- const KILL_GRACE_MS = 3000;
96
- const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
97
- const MAX_RECENT_TASKS = 100;
98
36
  const STATUS_INTERVAL_MS = 1000;
99
37
  const COMMAND_PREVIEW_CHARS = 90;
100
- const DETAIL_TAIL_BYTES = 8 * 1024;
101
- const LIST_VISIBLE_ROWS = 14;
102
- const DETAIL_VISIBLE_OUTPUT_LINES = 12;
103
38
  const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
104
39
  const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
105
- const LIGHT_BLUE_BORDER = "\x1b[38;2;83;160;215m";
106
40
  const ANSI_RESET = "\x1b[0m";
107
41
 
108
- function sanitizePathSegment(value: string): string {
109
- const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
110
- return sanitized || "session";
111
- }
112
-
113
- function stripMatchingQuotes(value: string): string {
114
- const trimmed = value.trim();
115
- if (trimmed.length >= 2) {
116
- const first = trimmed[0];
117
- const last = trimmed[trimmed.length - 1];
118
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
119
- return trimmed.slice(1, -1);
120
- }
121
- }
122
- return trimmed;
123
- }
124
-
125
- function compactWhitespace(value: string): string {
126
- return value.replace(/\s+/g, " ").trim();
127
- }
128
-
129
- function truncateChars(value: string, maxChars: number): string {
130
- if (value.length <= maxChars) return value;
131
- return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
132
- }
133
-
134
- function normalizeTaskName(value: unknown): string | undefined {
135
- if (typeof value !== "string") return undefined;
136
- const normalized = compactWhitespace(stripMatchingQuotes(value));
137
- if (!normalized) return undefined;
138
- return truncateChars(normalized, 80);
139
- }
140
-
141
- function deriveTaskNameFromCommand(command: string): string {
142
- const normalized = compactWhitespace(stripMatchingQuotes(command));
143
- if (!normalized) return "Background task";
144
-
145
- const packageScript = normalized.match(/^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/);
146
- if (packageScript) {
147
- const runner = packageScript[1];
148
- const run = packageScript[2] ? " run" : "";
149
- const script = packageScript[3];
150
- return truncateChars(`${runner}${run} ${script}`, 48);
151
- }
152
-
153
- const words = normalized.split(/\s+/).slice(0, 5).join(" ");
154
- return truncateChars(words || normalized, 48);
155
- }
156
-
157
- function taskDisplayName(task: { name?: string; description?: string; command?: string; id?: string }): string {
158
- return normalizeTaskName(task.name) ?? normalizeTaskName(task.description) ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined) ?? task.id ?? "Background task";
159
- }
160
-
161
- function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
162
- const input = valueAndRest.trimStart();
163
- if (!input) return undefined;
164
- const quote = input[0];
165
- if (quote === '"' || quote === "'") {
166
- let escaped = false;
167
- let value = "";
168
- for (let i = 1; i < input.length; i++) {
169
- const char = input[i]!;
170
- if (escaped) {
171
- value += char;
172
- escaped = false;
173
- continue;
174
- }
175
- if (char === "\\") {
176
- escaped = true;
177
- continue;
178
- }
179
- if (char === quote) {
180
- return { value, rest: input.slice(i + 1).trimStart() };
181
- }
182
- value += char;
183
- }
184
- return undefined;
185
- }
186
- const match = input.match(/^(\S+)(?:\s+([\s\S]*))?$/);
187
- if (!match) return undefined;
188
- return { value: match[1]!, rest: match[2]?.trimStart() ?? "" };
189
- }
190
-
191
- function parseBgCommandArgs(args: string): { name?: string; command: string } {
192
- const input = args.trim();
193
- for (const prefix of ["--name=", "-n="]) {
194
- if (input.startsWith(prefix)) {
195
- const parsed = parseNameValueAndRest(input.slice(prefix.length));
196
- if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
197
- return { name: normalizeTaskName(parsed.value), command: parsed.rest };
198
- }
199
- }
200
- for (const prefix of ["--name", "-n"]) {
201
- if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
202
- const parsed = parseNameValueAndRest(input.slice(prefix.length));
203
- if (!parsed) throw new Error(`${prefix} requires a task name`);
204
- return { name: normalizeTaskName(parsed.value), command: parsed.rest };
205
- }
206
- }
207
- return { command: input };
208
- }
209
-
210
- function formatDuration(ms: number): string {
211
- if (ms < 1000) return `${ms}ms`;
212
- const seconds = Math.floor(ms / 1000);
213
- if (seconds < 60) return `${seconds}s`;
214
- const minutes = Math.floor(seconds / 60);
215
- const remSeconds = seconds % 60;
216
- if (minutes < 60) return `${minutes}m${remSeconds ? `${remSeconds}s` : ""}`;
217
- const hours = Math.floor(minutes / 60);
218
- const remMinutes = minutes % 60;
219
- return `${hours}h${remMinutes ? `${remMinutes}m` : ""}`;
220
- }
221
-
222
- function formatTime(timestamp: number): string {
223
- return new Date(timestamp).toLocaleTimeString();
224
- }
225
-
226
- function padAnsi(value: string, width: number): string {
227
- return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
228
- }
229
-
230
42
  function lightBlue(value: string): string {
231
43
  return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
232
44
  }
233
45
 
234
- function blueBorder(value: string): string {
235
- return `${LIGHT_BLUE_BORDER}${value}${ANSI_RESET}`;
236
- }
237
-
238
- function statusLabel(status: TaskStatus): string {
239
- if (status === "completed") return "done";
240
- if (status === "failed") return "error";
241
- if (status === "killed") return "stopped";
242
- return "running";
243
- }
244
-
245
- function statusColor(theme: Theme, status: TaskStatus, text = statusLabel(status)): string {
246
- if (status === "completed") return theme.fg("success", text);
247
- if (status === "failed") return theme.fg("error", text);
248
- if (status === "killed") return theme.fg("warning", text);
249
- return theme.fg("accent", text);
250
- }
251
-
252
- function sortTasksForUi(tasks: BgTask[]): BgTask[] {
253
- const rank = (task: BgTask) => (task.status === "running" ? 0 : task.status === "failed" ? 1 : task.status === "killed" ? 2 : 3);
254
- return [...tasks].sort((a, b) => {
255
- const rankDiff = rank(a) - rank(b);
256
- if (rankDiff !== 0) return rankDiff;
257
- return (b.endTime ?? b.startTime) - (a.endTime ?? a.startTime);
258
- });
259
- }
260
-
261
- function shellInvocation(command: string): { shell: string; args: string[] } {
262
- if (process.platform === "win32") {
263
- return { shell: process.env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", command] };
264
- }
265
- return { shell: process.env.SHELL || "/bin/sh", args: ["-c", command] };
266
- }
267
-
268
- function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
269
- const raw = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
270
- return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
271
- }
272
-
273
- function snapshot(task: BgTask): BgTaskSnapshot {
274
- return {
275
- id: task.id,
276
- name: taskDisplayName(task),
277
- command: task.command,
278
- description: task.description,
279
- status: task.status,
280
- outputPath: task.outputPath,
281
- cwd: task.cwd,
282
- startTime: task.startTime,
283
- endTime: task.endTime,
284
- exitCode: task.exitCode,
285
- signal: task.signal,
286
- pid: task.pid,
287
- bytesWritten: task.bytesWritten,
288
- error: task.error,
289
- notified: task.notified,
290
- notifyOnCompletion: task.notifyOnCompletion,
291
- triggerOnCompletion: task.triggerOnCompletion,
292
- timeoutSeconds: task.timeoutSeconds,
293
- contextUsage: task.contextUsage,
294
- };
295
- }
296
-
297
46
  function textContent(text: string) {
298
47
  return [{ type: "text" as const, text }];
299
48
  }
300
49
 
301
- 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);
50
+ type TextToolResult = { content?: readonly { type: string; text?: string }[] };
51
+
52
+ const BgRunParams = Type.Object({
53
+ name: Type.String({ description: "Short human-readable task name shown in the bg footer dock. Required; use 2-6 words, not the raw command." }),
54
+ command: Type.String({ description: "Shell command to start in the background" }),
55
+ isAgent: Type.Boolean({ description: "Required. Set true only when this background task launches an LLM/agent process, such as a child `pi -p ...` or `pi --mode json ...`, so Pi-agent telemetry can be collected. Set false for scripts, tests, servers, sleeps, and ordinary shell commands." }),
56
+ description: Type.Optional(Type.String({ description: "Optional longer human-readable context for the task" })),
57
+ timeoutSeconds: Type.Optional(Type.Number({ description: "Optional timeout; task is failed and killed when exceeded" })),
58
+ notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Whether to show a completion notification. Default: true." })),
59
+ triggerOnCompletion: Type.Optional(Type.Boolean({ description: "Whether completion should trigger a follow-up agent turn. Default: true for bg_run." })),
60
+ });
61
+
62
+ const BgStatusParams = Type.Object({
63
+ taskId: Type.Optional(Type.String({ description: "Optional task ID or unambiguous prefix. If omitted, all running/recent tasks are returned." })),
64
+ });
65
+
66
+ const BgLogsParams = Type.Object({
67
+ taskId: Type.String({ description: "Task ID or unambiguous prefix" }),
68
+ maxBytes: Type.Optional(Type.Number({ description: `Maximum bytes to return, capped at ${formatSize(MAX_LOG_BYTES)}. Default: ${formatSize(DEFAULT_LOG_BYTES)}.` })),
69
+ tail: Type.Optional(Type.Boolean({ description: "Read the tail of the log when true, head when false. Default: true." })),
70
+ });
71
+
72
+ const BgKillParams = Type.Object({
73
+ taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
74
+ });
75
+
76
+ type BgRunParamsValue = Static<typeof BgRunParams>;
77
+ type BgStatusParamsValue = Static<typeof BgStatusParams>;
78
+ type BgLogsParamsValue = Static<typeof BgLogsParams>;
79
+ type BgKillParamsValue = Static<typeof BgKillParams>;
80
+
81
+ function renderPlainResult(result: TextToolResult, _options: ToolRenderResultOptions, _theme: Theme) {
82
+ const text = result.content?.map((part) => part.type === "text" ? (part.text ?? "") : "").join("\n") ?? "";
83
+ return new Text(text, 0, 0);
369
84
  }
370
85
 
371
86
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
372
- const tasks = new Map<string, BgTask>();
373
- let runtimeDirAbs: string | undefined;
374
- let runtimeDirDisplay: string | undefined;
87
+ const seenTaskIds = new Set<string>();
375
88
  let currentCtx: ExtensionContext | undefined;
376
89
  let dockOpen = false;
377
- let shuttingDown = false;
378
90
  let statusInterval: NodeJS.Timeout | undefined;
379
- const seenTaskIds = new Set<string>();
380
-
381
- function makeTaskId(): string {
382
- for (let attempt = 0; attempt < 20; attempt++) {
383
- const id = `b${randomBytes(4).toString("hex")}`;
384
- if (!tasks.has(id)) return id;
385
- }
386
- throw new Error("Could not generate a unique background task ID after 20 attempts");
387
- }
388
91
 
389
- 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
- }
92
+ const registry = new BackgroundTaskRegistry({
93
+ onChange: () => updateUi(),
94
+ sendCompletionNotification: (message, options) => {
95
+ pi.sendMessage(message, options);
96
+ },
97
+ });
402
98
 
403
99
  function unseenFinishedTasks(): BgTask[] {
404
- return [...tasks.values()].filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
100
+ return registry.allTasks().filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
405
101
  }
406
102
 
407
103
  function clearFinishedNotices(ctx = currentCtx): number {
@@ -411,11 +107,23 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
411
107
  return unseen.length;
412
108
  }
413
109
 
110
+ function notifyClearFinishedNotices(ctx: ExtensionContext): void {
111
+ currentCtx = ctx;
112
+ const cleared = clearFinishedNotices(ctx);
113
+ if (!ctx.hasUI) return;
114
+ ctx.ui.notify(
115
+ cleared > 0
116
+ ? `Cleared ${cleared} finished background task notice${cleared === 1 ? "" : "s"}.`
117
+ : "No finished background task notices to clear.",
118
+ cleared > 0 ? "info" : "warning",
119
+ );
120
+ }
121
+
414
122
  function updateUi(ctx = currentCtx): void {
415
- if (shuttingDown || !ctx) return;
123
+ if (registry.isShuttingDown() || !ctx) return;
416
124
  try {
417
125
  if (!ctx.hasUI) return;
418
- const allTasks = [...tasks.values()];
126
+ const allTasks = registry.allTasks();
419
127
  const running = allTasks.filter((task) => task.status === "running");
420
128
  const unseenFailed = allTasks.filter((task) => task.status === "failed" && !seenTaskIds.has(task.id));
421
129
  const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
@@ -432,7 +140,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
432
140
  if (unseenFailed.length > 0) parts.push(`${unseenFailed.length} failed`);
433
141
  if (unseenStopped.length > 0) parts.push(`${unseenStopped.length} stopped`);
434
142
  if (unseenDone.length > 0) parts.push(`${unseenDone.length} done`);
435
- const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · C clear" : ""}`;
143
+ const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · /bg-clear" : ""}`;
436
144
  const label = ` bg ${parts.join(" · ")} · ${entryHint} `;
437
145
  ctx.ui.setStatus("background-tasks", lightBlue(label));
438
146
  } catch (error) {
@@ -441,434 +149,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
441
149
  }
442
150
  }
443
151
 
444
- function 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
662
- }
663
-
664
- async function finalizeTask(task: BgTask, status: TaskStatus, exitCode: number | null, signal?: string | null, error?: string): Promise<void> {
665
- if (task.finalized) return;
666
- task.finalized = true;
667
- if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
668
- task.status = status;
669
- task.exitCode = exitCode;
670
- task.signal = signal ?? null;
671
- task.endTime = Date.now();
672
- if (error) task.error = error;
673
- if (task.stream && !task.stream.destroyed) task.stream.end();
674
-
675
- for (const waiter of task.waiters.splice(0)) waiter();
676
-
677
- try {
678
- await writeMetadata(task);
679
- } catch (metadataError) {
680
- console.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
681
- }
682
-
683
- updateUi();
684
- try {
685
- await notifyCompletion(task);
686
- } catch (notificationError) {
687
- console.error(`[background-tasks] notification failed for ${task.id}:`, notificationError);
688
- }
689
- try {
690
- await writeMetadata(task);
691
- } catch (metadataError) {
692
- console.error(`[background-tasks] failed to update notification metadata for ${task.id}:`, metadataError);
693
- }
694
- pruneOldTasks();
695
- }
696
-
697
- function pruneOldTasks(): void {
698
- if (tasks.size <= MAX_RECENT_TASKS) return;
699
- const removable = [...tasks.values()]
700
- .filter((task) => task.status !== "running")
701
- .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
702
- while (tasks.size > MAX_RECENT_TASKS && removable.length > 0) {
703
- const task = removable.shift();
704
- if (task) tasks.delete(task.id);
705
- }
706
- }
707
-
708
- async function startTask(
709
- ctx: ExtensionContext,
710
- command: string,
711
- options: StartTaskOptions = {},
712
- ): Promise<BgTask> {
713
- const normalizedCommand = stripMatchingQuotes(command);
714
- if (!normalizedCommand) throw new Error("Background command is empty");
715
- if (shuttingDown) throw new Error("Cannot start a background task while Pi is shutting down");
716
-
152
+ async function startTask(ctx: ExtensionContext, command: string, options: StartTaskOptions = {}): Promise<BgTask> {
717
153
  currentCtx = ctx;
718
- 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
- };
154
+ return registry.startTask(ctx, command, options);
872
155
  }
873
156
 
874
157
  async function openTaskManager(ctx: ExtensionCommandContext | ExtensionContext, initialTaskId?: string): Promise<void> {
@@ -881,55 +164,47 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
881
164
  updateUi(ctx);
882
165
  try {
883
166
  await ctx.ui.custom<TaskManagerResult>(
884
- (tui, theme, _keybindings, done) =>
885
- new BackgroundTasksManager(tui, theme, done, {
886
- initialTaskId,
887
- getTasks: () => [...tasks.values()],
888
- stopTask: async (task) => {
889
- await stopTask(resolveTask(task.id), "user");
167
+ (tui, theme, _keybindings, done) => {
168
+ const managerOptions = {
169
+ getTasks: () => registry.allTasks(),
170
+ stopTask: async (task: BackgroundTaskForUi) => {
171
+ await registry.stopTask(registry.resolveTask(task.id), "user");
890
172
  updateUi(ctx);
891
173
  },
892
174
  stopAllRunning: async () => {
893
- const 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
- );
175
+ const result = await registry.stopAllRunning("user");
906
176
  updateUi(ctx);
907
- return { stopped, failures };
177
+ return result;
908
178
  },
909
- rerunTask: async (task) => {
910
- const rerun = await startTask(ctx, task.command, {
179
+ rerunTask: async (task: BackgroundTaskForUi) => {
180
+ const rerunOptions: StartTaskOptions = {
911
181
  name: taskDisplayName(task),
912
- description: task.description,
913
- timeoutSeconds: task.timeoutSeconds,
182
+ isAgent: task.isAgent,
914
183
  notifyOnCompletion: true,
915
184
  triggerOnCompletion: false,
916
- });
185
+ };
186
+ if (task.description !== undefined) rerunOptions.description = task.description;
187
+ if (task.timeoutSeconds !== undefined) rerunOptions.timeoutSeconds = task.timeoutSeconds;
188
+ const rerun = await startTask(ctx, task.command, rerunOptions);
917
189
  updateUi(ctx);
918
190
  return rerun;
919
191
  },
920
- showOutputPath: (task) => {
192
+ showOutputPath: (task: BackgroundTaskForUi) => {
921
193
  ctx.ui.notify(`Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`, "info");
922
194
  },
923
- markSeen: (taskId) => {
195
+ markSeen: (taskId: string) => {
924
196
  seenTaskIds.add(taskId);
925
197
  updateUi(ctx);
926
198
  },
927
- markFinishedSeen: (taskIds) => {
199
+ markFinishedSeen: (taskIds: string[]) => {
928
200
  for (const taskId of taskIds) seenTaskIds.add(taskId);
929
201
  updateUi(ctx);
930
202
  },
931
- isSeen: (taskId) => seenTaskIds.has(taskId),
932
- }),
203
+ isSeen: (taskId: string) => seenTaskIds.has(taskId),
204
+ };
205
+ if (initialTaskId) return new BackgroundTasksManager(tui, theme, done, { ...managerOptions, initialTaskId });
206
+ return new BackgroundTasksManager(tui, theme, done, managerOptions);
207
+ },
933
208
  {
934
209
  overlay: true,
935
210
  overlayOptions: { anchor: "bottom-center", width: "96%", minWidth: 64, maxHeight: "60%", margin: { bottom: 1, left: 1, right: 1 } },
@@ -944,38 +219,38 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
944
219
  pi.registerMessageRenderer<BgTaskSnapshot>("background-task-notification", (message, _options, theme) => {
945
220
  const task = message.details;
946
221
  const status = task?.status ?? "completed";
947
- const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
222
+ const color: ThemeColor = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
948
223
  const id = task?.id ?? "background task";
949
224
  const name = task ? taskDisplayName(task) : "Background task";
950
225
  const output = task?.outputPath ? `\n${theme.fg("dim", `Output: ${task.outputPath}`)}` : "";
951
226
  const error = task?.error ? `\n${theme.fg("error", task.error)}` : "";
952
- return new Text(`${theme.fg(color as any, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
227
+ return new Text(`${theme.fg(color, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
953
228
  });
954
229
 
955
230
  pi.on("session_start", async (_event, ctx) => {
956
- shuttingDown = false;
231
+ registry.setShuttingDown(false);
957
232
  currentCtx = ctx;
958
- await ensureRuntimeDir(ctx);
233
+ await registry.ensureRuntimeDir(ctx);
959
234
  updateUi(ctx);
960
235
  if (statusInterval) clearInterval(statusInterval);
961
236
  statusInterval = setInterval(() => updateUi(), STATUS_INTERVAL_MS);
962
237
  });
963
238
 
964
239
  pi.on("session_shutdown", async (_event, ctx) => {
965
- shuttingDown = true;
240
+ registry.setShuttingDown(true);
966
241
  currentCtx = undefined;
967
242
  if (statusInterval) {
968
243
  clearInterval(statusInterval);
969
244
  statusInterval = undefined;
970
245
  }
971
- const running = [...tasks.values()].filter((task) => task.status === "running");
246
+ const running = registry.allTasks().filter((task) => task.status === "running");
972
247
  if (running.length === 0) return;
973
248
 
974
249
  const failures: string[] = [];
975
250
  await Promise.all(
976
251
  running.map(async (task) => {
977
252
  try {
978
- await stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
253
+ await registry.stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
979
254
  } catch (error) {
980
255
  const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
981
256
  failures.push(message);
@@ -989,11 +264,13 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
989
264
  });
990
265
 
991
266
  pi.registerCommand("bg", {
992
- description: "Start a shell command as a tracked background task: /bg [--name \"Task name\"] <command>",
267
+ description: "Start a shell command as a tracked background task: /bg [--agent] [--name \"Task name\"] <command>",
993
268
  handler: async (args, ctx) => {
994
269
  try {
995
270
  const parsed = parseBgCommandArgs(args);
996
- const task = await startTask(ctx, parsed.command, { name: parsed.name, notifyOnCompletion: true, triggerOnCompletion: false });
271
+ const taskOptions: StartTaskOptions = { isAgent: parsed.isAgent, notifyOnCompletion: true, triggerOnCompletion: false };
272
+ if (parsed.name !== undefined) taskOptions.name = parsed.name;
273
+ const task = await startTask(ctx, parsed.command, taskOptions);
997
274
  ctx.ui.notify(`Started ${taskDisplayName(task)} (${task.id})\nOutput: ${task.outputPath}\nCommand: ${task.command}`, "info");
998
275
  } catch (error) {
999
276
  ctx.ui.notify(`Background task failed to start: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -1017,34 +294,30 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1017
294
  },
1018
295
  });
1019
296
 
1020
- pi.registerShortcut("shift+down" as any, {
297
+ pi.registerCommand("bg-clear", {
298
+ description: "Clear finished background task footer notices",
299
+ handler: async (_args, ctx) => {
300
+ notifyClearFinishedNotices(ctx);
301
+ },
302
+ });
303
+
304
+ pi.registerShortcut("shift+down" satisfies KeyId, {
1021
305
  description: "Open focused background task footer dock",
1022
306
  handler: async (ctx) => {
1023
307
  await openTaskManager(ctx);
1024
308
  },
1025
309
  });
1026
310
 
1027
- pi.registerShortcut("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
- },
311
+ pi.registerShortcut("ctrl+alt+c" satisfies KeyId, {
312
+ description: "Clear finished background task footer notices (terminal-dependent fallback for /bg-clear)",
313
+ handler: (ctx) => notifyClearFinishedNotices(ctx),
1041
314
  });
1042
315
 
1043
316
  pi.registerCommand("jobs", {
1044
317
  description: "List running and recent background tasks",
1045
318
  handler: async (_args, ctx) => {
1046
319
  currentCtx = ctx;
1047
- ctx.ui.notify(formatTaskList([...tasks.values()]), "info");
320
+ ctx.ui.notify(formatSnapshotList(registry.allTasks().map((task) => registry.snapshot(task))), "info");
1048
321
  updateUi(ctx);
1049
322
  },
1050
323
  });
@@ -1052,7 +325,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1052
325
  pi.registerCommand("logs", {
1053
326
  description: "Show bounded output from a background task: /logs <id> [maxBytes]",
1054
327
  getArgumentCompletions: (prefix) => {
1055
- const matches = [...tasks.values()]
328
+ const matches = registry.allTasks()
1056
329
  .filter((task) => task.id.startsWith(prefix.trim()))
1057
330
  .slice(0, 20)
1058
331
  .map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: `${task.status} — ${truncateChars(task.command, 60)}` }));
@@ -1062,9 +335,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1062
335
  try {
1063
336
  currentCtx = ctx;
1064
337
  const [id, bytes] = args.trim().split(/\s+/, 2);
1065
- const task = resolveTask(id || "");
1066
- const maxBytes = normalizeMaxBytes(Number(bytes));
1067
- const logs = await getTaskLogs(task, maxBytes, true);
338
+ const task = registry.resolveTask(id || "");
339
+ const maxBytes = normalizeMaxBytes(Number(bytes), DEFAULT_LOG_BYTES);
340
+ const logs = await registry.getTaskLogs(task, maxBytes, true);
1068
341
  ctx.ui.notify(logs.text, "info");
1069
342
  } catch (error) {
1070
343
  ctx.ui.notify(`Background logs error: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -1075,7 +348,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1075
348
  pi.registerCommand("kill", {
1076
349
  description: "Stop a running background task: /kill <id>",
1077
350
  getArgumentCompletions: (prefix) => {
1078
- const matches = [...tasks.values()]
351
+ const matches = registry.allTasks()
1079
352
  .filter((task) => task.status === "running" && task.id.startsWith(prefix.trim()))
1080
353
  .slice(0, 20)
1081
354
  .map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: truncateChars(task.command, 70) }));
@@ -1084,8 +357,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1084
357
  handler: async (args, ctx) => {
1085
358
  try {
1086
359
  currentCtx = ctx;
1087
- const task = resolveTask(args.trim());
1088
- await stopTask(task, "user");
360
+ const task = registry.resolveTask(args.trim());
361
+ await registry.stopTask(task, "user");
1089
362
  ctx.ui.notify(`Killed ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`, "info");
1090
363
  updateUi(ctx);
1091
364
  } catch (error) {
@@ -1094,15 +367,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1094
367
  },
1095
368
  });
1096
369
 
1097
- const BgRunParams = Type.Object({
1098
- name: Type.String({ description: "Short human-readable task name shown in the bg footer dock. Required; use 2-6 words, not the raw command." }),
1099
- command: Type.String({ description: "Shell command to start in the background" }),
1100
- description: Type.Optional(Type.String({ description: "Optional longer human-readable context for the task" })),
1101
- timeoutSeconds: Type.Optional(Type.Number({ description: "Optional timeout; task is failed and killed when exceeded" })),
1102
- notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Whether to show a completion notification. Default: true." })),
1103
- triggerOnCompletion: Type.Optional(Type.Boolean({ description: "Whether completion should trigger a follow-up agent turn. Default: true for bg_run." })),
1104
- });
1105
-
1106
370
  pi.registerTool<typeof BgRunParams, BgRunDetails>({
1107
371
  name: "bg_run",
1108
372
  label: "Background Run",
@@ -1110,37 +374,50 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1110
374
  promptSnippet: "Start named long-running shell commands in the background and return a task ID plus output file path",
1111
375
  promptGuidelines: [
1112
376
  "Use bg_run instead of bash for commands expected to run for a long time, such as test suites, dev servers, watchers, builds, or sleeps.",
377
+ "Always set isAgent: true only when the background task launches an LLM/agent process; set isAgent: false for scripts, tests, dev servers, sleeps, and ordinary shell commands.",
1113
378
  "When using bg_run, always set name to a concise 2-6 word human-readable label for the footer task dock; do not use the raw command as the name unless it is already short and meaningful.",
1114
379
  "After bg_run, use bg_status and bg_logs to inspect progress; do not assume the background task completed until status says completed, failed, or killed.",
1115
380
  "When a <background-task-notification> appears, react to it: inspect bg_status/bg_logs as needed, then report completion, failure, or next steps to the user.",
1116
381
  ],
1117
382
  parameters: BgRunParams,
1118
- prepareArguments(args) {
1119
- 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),
383
+ prepareArguments(args): BgRunParamsValue {
384
+ if (!args || typeof args !== "object") throw new Error("bg_run arguments must be an object");
385
+ const input = args as Record<string, unknown>;
386
+ if (typeof input["command"] !== "string") throw new Error("bg_run requires command string");
387
+ if (typeof input["isAgent"] !== "boolean") {
388
+ throw new Error("bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.");
389
+ }
390
+ const prepared: BgRunParamsValue = {
391
+ command: input["command"],
392
+ name: normalizeTaskName(input["name"]) ?? normalizeTaskName(input["description"]) ?? deriveTaskNameFromCommand(input["command"]),
393
+ isAgent: input["isAgent"],
1127
394
  };
395
+ if (typeof input["description"] === "string") prepared.description = input["description"];
396
+ if (typeof input["timeoutSeconds"] === "number") prepared.timeoutSeconds = input["timeoutSeconds"];
397
+ if (typeof input["notifyOnCompletion"] === "boolean") prepared.notifyOnCompletion = input["notifyOnCompletion"];
398
+ if (typeof input["triggerOnCompletion"] === "boolean") prepared.triggerOnCompletion = input["triggerOnCompletion"];
399
+ return prepared;
1128
400
  },
1129
401
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1130
- const task = await startTask(ctx, params.command, {
402
+ if (typeof params.isAgent !== "boolean") {
403
+ throw new Error("bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.");
404
+ }
405
+ const taskOptions: StartTaskOptions = {
1131
406
  name: params.name,
1132
- description: params.description,
1133
- timeoutSeconds: params.timeoutSeconds,
407
+ isAgent: params.isAgent,
1134
408
  notifyOnCompletion: params.notifyOnCompletion ?? true,
1135
409
  triggerOnCompletion: params.triggerOnCompletion ?? true,
1136
- });
410
+ };
411
+ if (params.description !== undefined) taskOptions.description = params.description;
412
+ if (params.timeoutSeconds !== undefined) taskOptions.timeoutSeconds = params.timeoutSeconds;
413
+ const task = await startTask(ctx, params.command, taskOptions);
1137
414
  return {
1138
415
  content: textContent(`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${task.pid ?? "unknown"}\nOutput: ${task.outputPath}`),
1139
- details: { task: snapshot(task) },
416
+ details: { task: registry.snapshot(task) },
1140
417
  };
1141
418
  },
1142
419
  renderCall(args, theme) {
1143
- return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args), 90))}`, 0, 0);
420
+ return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args), COMMAND_PREVIEW_CHARS))}`, 0, 0);
1144
421
  },
1145
422
  renderResult(result, _options, theme) {
1146
423
  const task = result.details?.task;
@@ -1149,10 +426,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1149
426
  },
1150
427
  });
1151
428
 
1152
- const BgStatusParams = Type.Object({
1153
- taskId: Type.Optional(Type.String({ description: "Optional task ID or unambiguous prefix. If omitted, all running/recent tasks are returned." })),
1154
- });
1155
-
1156
429
  pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
1157
430
  name: "bg_status",
1158
431
  label: "Background Status",
@@ -1161,8 +434,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1161
434
  promptGuidelines: ["Use bg_status before bg_logs when you need to know whether a background task is still running or has finished."],
1162
435
  parameters: BgStatusParams,
1163
436
  async execute(_toolCallId, params) {
1164
- const selected = params.taskId ? [resolveTask(params.taskId)] : [...tasks.values()];
1165
- const snapshots = selected.map(snapshot);
437
+ const selected = params.taskId ? [registry.resolveTask(params.taskId)] : registry.allTasks();
438
+ const snapshots = selected.map((task) => registry.snapshot(task));
1166
439
  return {
1167
440
  content: textContent(formatSnapshotList(snapshots)),
1168
441
  details: { tasks: snapshots },
@@ -1174,12 +447,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1174
447
  renderResult: renderPlainResult,
1175
448
  });
1176
449
 
1177
- const BgLogsParams = Type.Object({
1178
- taskId: Type.String({ description: "Task ID or unambiguous prefix" }),
1179
- maxBytes: Type.Optional(Type.Number({ description: `Maximum bytes to return, capped at ${formatSize(MAX_LOG_BYTES)}. Default: ${formatSize(DEFAULT_LOG_BYTES)}.` })),
1180
- tail: Type.Optional(Type.Boolean({ description: "Read the tail of the log when true, head when false. Default: true." })),
1181
- });
1182
-
1183
450
  pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
1184
451
  name: "bg_logs",
1185
452
  label: "Background Logs",
@@ -1188,8 +455,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1188
455
  promptGuidelines: ["Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context."],
1189
456
  parameters: BgLogsParams,
1190
457
  async execute(_toolCallId, params) {
1191
- const task = resolveTask(params.taskId);
1192
- const logs = await getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
458
+ const task = registry.resolveTask(params.taskId);
459
+ const logs = await registry.getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
1193
460
  return {
1194
461
  content: textContent(logs.text),
1195
462
  details: logs.details,
@@ -1212,10 +479,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1212
479
  },
1213
480
  });
1214
481
 
1215
- const BgKillParams = Type.Object({
1216
- taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
1217
- });
1218
-
1219
482
  pi.registerTool<typeof BgKillParams, BgKillDetails>({
1220
483
  name: "bg_kill",
1221
484
  label: "Background Kill",
@@ -1224,12 +487,12 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1224
487
  promptGuidelines: ["Use bg_kill when the user asks to stop a background task or when a bg_run command is no longer needed."],
1225
488
  parameters: BgKillParams,
1226
489
  async execute(_toolCallId, params) {
1227
- const task = resolveTask(params.taskId);
1228
- await stopTask(task, "user");
490
+ const task = registry.resolveTask(params.taskId);
491
+ await registry.stopTask(task, "user");
1229
492
  const message = `Killed background task ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`;
1230
493
  return {
1231
494
  content: textContent(message),
1232
- details: { task: snapshot(task), message },
495
+ details: { task: registry.snapshot(task), message },
1233
496
  };
1234
497
  },
1235
498
  renderCall(args, theme) {