pi-background-tasks 0.2.0 → 0.4.0

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