pi-background-tasks 0.6.0 → 0.7.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,30 +1,51 @@
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";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ Theme,
6
+ ThemeColor,
7
+ ToolRenderResultOptions,
8
+ } from '@earendil-works/pi-coding-agent';
9
+ import { formatSize } from '@earendil-works/pi-coding-agent';
10
+ import { Text, type KeyId } from '@earendil-works/pi-tui';
11
+ import { Type, type Static } from 'typebox';
5
12
  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";
13
+ DEFAULT_LOG_BYTES,
14
+ MAX_LOG_BYTES,
15
+ deriveTaskNameFromCommand,
16
+ formatSnapshotList,
17
+ formatUpdateSegment,
18
+ isNewerVersion,
19
+ normalizeMaxBytes,
20
+ normalizeTaskName,
21
+ parseBgCommandArgs,
22
+ taskDisplayName,
23
+ truncateChars,
24
+ type BgKillDetails,
25
+ type BgLogsDetails,
26
+ type BgRunDetails,
27
+ type BgStatusDetails,
28
+ type BgTask,
29
+ type BgTaskSnapshot,
30
+ type StartAttestedPiTaskOptions,
31
+ type StartTaskOptions,
32
+ } from './core/common.js';
33
+ import {
34
+ fetchLatestVersion,
35
+ readPackageInfo,
36
+ type FetchLatestVersionOptions,
37
+ } from './core/update-check.js';
38
+ import { BackgroundTaskRegistry } from './core/registry.js';
39
+ import {
40
+ installBackgroundTaskExtensionApi,
41
+ type BackgroundTaskExtensionService,
42
+ } from './core/extension-api.js';
43
+ import {
44
+ BackgroundTasksManager,
45
+ type BackgroundTaskForUi,
46
+ type TaskManagerResult,
47
+ } from './ui/background-tasks-manager.js';
48
+ import { registerFusionExtension } from './fusion-extension.js';
28
49
 
29
50
  /**
30
51
  * Project-local Pi background task manager.
@@ -38,529 +59,847 @@ import { BackgroundTasksManager, type BackgroundTaskForUi, type TaskManagerResul
38
59
 
39
60
  const STATUS_INTERVAL_MS = 1000;
40
61
  const COMMAND_PREVIEW_CHARS = 90;
41
- const GIT_INSTALL_TARGET = "git:github.com/ismailsaleekh/pi-background-tasks";
62
+ const GIT_INSTALL_TARGET = 'git:github.com/ismailsaleekh/pi-background-tasks';
42
63
 
43
- const packageInfo = readPackageInfo(new URL("../package.json", import.meta.url), (error) => {
44
- console.error(`[background-tasks] failed to read package version: ${error.message}`);
64
+ const packageInfo = readPackageInfo(new URL('../package.json', import.meta.url), (error) => {
65
+ console.error(`[background-tasks] failed to read package version: ${error.message}`);
45
66
  });
46
- const PACKAGE_NAME = packageInfo.name ?? "pi-background-tasks";
67
+ const PACKAGE_NAME = packageInfo.name ?? 'pi-background-tasks';
47
68
  const PACKAGE_VERSION = packageInfo.version;
48
- const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
49
- const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
50
- const ANSI_RESET = "\x1b[0m";
69
+ const LIGHT_BLUE_BG = '\x1b[48;2;183;223;255m';
70
+ const LIGHT_BLUE_FG = '\x1b[38;2;11;70;110m';
71
+ const ANSI_RESET = '\x1b[0m';
51
72
 
52
73
  function lightBlue(value: string): string {
53
- return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
74
+ return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
54
75
  }
55
76
 
56
77
  function textContent(text: string) {
57
- return [{ type: "text" as const, text }];
78
+ return [{ type: 'text' as const, text }];
58
79
  }
59
80
 
60
- type TextToolResult = { content?: readonly { type: string; text?: string }[] };
81
+ interface TextToolResult {
82
+ content?: ReadonlyArray<{ type: string; text?: string }>;
83
+ }
84
+
85
+ interface BgToolArgumentRecord {
86
+ readonly command?: unknown;
87
+ readonly name?: unknown;
88
+ readonly description?: unknown;
89
+ readonly isAgent?: unknown;
90
+ readonly timeoutSeconds?: unknown;
91
+ readonly notifyOnCompletion?: unknown;
92
+ readonly triggerOnCompletion?: unknown;
93
+ }
94
+
95
+ interface BgPiAttestedArgumentRecord {
96
+ readonly name?: unknown;
97
+ readonly provider?: unknown;
98
+ readonly model?: unknown;
99
+ readonly prompt?: unknown;
100
+ readonly reportPath?: unknown;
101
+ readonly extraPiArgs?: unknown;
102
+ readonly thinking?: unknown;
103
+ readonly timeoutSeconds?: unknown;
104
+ }
105
+
106
+ function optionalTrimmed(value: string): string | undefined {
107
+ const trimmed = value.trim();
108
+ return trimmed.length > 0 ? trimmed : undefined;
109
+ }
61
110
 
62
111
  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." })),
112
+ name: Type.String({
113
+ description:
114
+ 'Short human-readable task name shown in the bg footer dock. Required; use 2-6 words, not the raw command.',
115
+ }),
116
+ command: Type.String({ description: 'Shell command to start in the background' }),
117
+ isAgent: Type.Boolean({
118
+ description:
119
+ '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.',
120
+ }),
121
+ description: Type.Optional(
122
+ Type.String({ description: 'Optional longer human-readable context for the task' }),
123
+ ),
124
+ timeoutSeconds: Type.Optional(
125
+ Type.Number({ description: 'Optional timeout; task is failed and killed when exceeded' }),
126
+ ),
127
+ notifyOnCompletion: Type.Optional(
128
+ Type.Boolean({ description: 'Whether to show a completion notification. Default: true.' }),
129
+ ),
130
+ triggerOnCompletion: Type.Optional(
131
+ Type.Boolean({
132
+ description:
133
+ 'Whether completion should trigger a follow-up agent turn. Default: true for bg_run.',
134
+ }),
135
+ ),
136
+ });
137
+
138
+ const BgPiAttestedParams = Type.Object({
139
+ name: Type.String({ description: 'Short human-readable name for this attested Pi task.' }),
140
+ provider: Type.String({
141
+ description: 'Exact Pi provider to launch, for example openai-codex or anthropic.',
142
+ }),
143
+ model: Type.String({ description: 'Exact provider-local Pi model id to launch.' }),
144
+ prompt: Type.String({ description: 'Prompt bytes passed as the single user prompt to Pi.' }),
145
+ reportPath: Type.String({
146
+ description:
147
+ 'Relative path, inside the task cwd, that the child Pi run must write as its report.',
148
+ }),
149
+ extraPiArgs: Type.Optional(
150
+ Type.Array(
151
+ Type.String({
152
+ description:
153
+ 'Additional literal Pi argv entries; mode/provider/model/api-key args are rejected.',
154
+ }),
155
+ ),
156
+ ),
157
+ thinking: Type.Optional(Type.String({ description: 'Optional Pi thinking level argument.' })),
158
+ timeoutSeconds: Type.Optional(
159
+ Type.Number({ description: 'Optional timeout; task is failed and killed when exceeded' }),
160
+ ),
70
161
  });
71
162
 
72
163
  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." })),
164
+ taskId: Type.Optional(
165
+ Type.String({
166
+ description:
167
+ 'Optional task ID or unambiguous prefix. If omitted, all running/recent tasks are returned.',
168
+ }),
169
+ ),
74
170
  });
75
171
 
76
172
  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." })),
173
+ taskId: Type.String({ description: 'Task ID or unambiguous prefix' }),
174
+ maxBytes: Type.Optional(
175
+ Type.Number({
176
+ description: `Maximum bytes to return, capped at ${formatSize(MAX_LOG_BYTES)}. Default: ${formatSize(DEFAULT_LOG_BYTES)}.`,
177
+ }),
178
+ ),
179
+ tail: Type.Optional(
180
+ Type.Boolean({
181
+ description: 'Read the tail of the log when true, head when false. Default: true.',
182
+ }),
183
+ ),
80
184
  });
81
185
 
82
186
  const BgKillParams = Type.Object({
83
- taskId: Type.String({ description: "Task ID or unambiguous prefix to stop" }),
187
+ taskId: Type.String({ description: 'Task ID or unambiguous prefix to stop' }),
84
188
  });
85
189
 
86
190
  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);
191
+ type BgPiAttestedParamsValue = Static<typeof BgPiAttestedParams>;
192
+
193
+ function renderPlainResult(result: TextToolResult, options: ToolRenderResultOptions, theme: Theme) {
194
+ void options;
195
+ void theme;
196
+ const text =
197
+ result.content?.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join('\n') ?? '';
198
+ return new Text(text, 0, 0);
94
199
  }
95
200
 
96
201
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
97
- const seenTaskIds = new Set<string>();
98
- let currentCtx: ExtensionContext | undefined;
99
- let dockOpen = false;
100
- let statusInterval: NodeJS.Timeout | undefined;
101
- let latestKnownVersion: string | undefined;
102
- let updateCheckStarted = false;
103
-
104
- const registry = new BackgroundTaskRegistry({
105
- onChange: () => updateUi(),
106
- sendCompletionNotification: (message, options) => {
107
- pi.sendMessage(message, options);
108
- },
109
- });
110
-
111
- function unseenFinishedTasks(): BgTask[] {
112
- return registry.allTasks().filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
113
- }
114
-
115
- function clearFinishedNotices(ctx = currentCtx): number {
116
- const unseen = unseenFinishedTasks();
117
- for (const task of unseen) seenTaskIds.add(task.id);
118
- updateUi(ctx);
119
- return unseen.length;
120
- }
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
-
134
- function updateUi(ctx = currentCtx): void {
135
- if (registry.isShuttingDown() || !ctx) return;
136
- try {
137
- if (!ctx.hasUI) return;
138
- const allTasks = registry.allTasks();
139
- const running = allTasks.filter((task) => task.status === "running");
140
- const unseenFailed = allTasks.filter((task) => task.status === "failed" && !seenTaskIds.has(task.id));
141
- const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
142
- const unseenDone = allTasks.filter((task) => task.status === "completed" && !seenTaskIds.has(task.id));
143
- const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
144
- const updateSegment = formatUpdateSegment(latestKnownVersion, PACKAGE_VERSION ?? "");
145
- ctx.ui.setWidget("background-tasks", undefined);
146
- if (running.length === 0 && unseenFinishedCount === 0) {
147
- ctx.ui.setStatus("background-tasks", updateSegment ? lightBlue(` bg ${updateSegment} `) : undefined);
148
- return;
149
- }
150
-
151
- const parts: string[] = [];
152
- if (running.length > 0) parts.push(`${running.length} running`);
153
- if (unseenFailed.length > 0) parts.push(`${unseenFailed.length} failed`);
154
- if (unseenStopped.length > 0) parts.push(`${unseenStopped.length} stopped`);
155
- if (unseenDone.length > 0) parts.push(`${unseenDone.length} done`);
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(" · ")} `;
160
- ctx.ui.setStatus("background-tasks", lightBlue(label));
161
- } catch (error) {
162
- console.error(`[background-tasks] UI update failed: ${error instanceof Error ? error.message : String(error)}`);
163
- currentCtx = undefined;
164
- }
165
- }
166
-
167
- async function startTask(ctx: ExtensionContext, command: string, options: StartTaskOptions = {}): Promise<BgTask> {
168
- currentCtx = ctx;
169
- return registry.startTask(ctx, command, options);
170
- }
171
-
172
- async function openTaskManager(ctx: ExtensionCommandContext | ExtensionContext, initialTaskId?: string): Promise<void> {
173
- currentCtx = ctx;
174
- if (!ctx.hasUI) {
175
- ctx.ui.notify("Background task manager requires an interactive Pi UI. Use /jobs, /logs, or the bg_status/bg_logs tools in non-interactive mode.", "error");
176
- return;
177
- }
178
- dockOpen = true;
179
- updateUi(ctx);
180
- try {
181
- await ctx.ui.custom<TaskManagerResult>(
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");
187
- updateUi(ctx);
188
- },
189
- stopAllRunning: async () => {
190
- const result = await registry.stopAllRunning("user");
191
- updateUi(ctx);
192
- return result;
193
- },
194
- rerunTask: async (task: BackgroundTaskForUi) => {
195
- const rerunOptions: StartTaskOptions = {
196
- name: taskDisplayName(task),
197
- isAgent: task.isAgent,
198
- notifyOnCompletion: true,
199
- triggerOnCompletion: false,
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);
204
- updateUi(ctx);
205
- return rerun;
206
- },
207
- showOutputPath: (task: BackgroundTaskForUi) => {
208
- ctx.ui.notify(`Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`, "info");
209
- },
210
- markSeen: (taskId: string) => {
211
- seenTaskIds.add(taskId);
212
- updateUi(ctx);
213
- },
214
- markFinishedSeen: (taskIds: string[]) => {
215
- for (const taskId of taskIds) seenTaskIds.add(taskId);
216
- updateUi(ctx);
217
- },
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
- },
223
- {
224
- overlay: true,
225
- overlayOptions: { anchor: "bottom-center", width: "96%", minWidth: 64, maxHeight: "60%", margin: { bottom: 1, left: 1, right: 1 } },
226
- },
227
- );
228
- } finally {
229
- dockOpen = false;
230
- updateUi(ctx);
231
- }
232
- }
233
-
234
- pi.registerMessageRenderer<BgTaskSnapshot>("background-task-notification", (message, _options, theme) => {
235
- const task = message.details;
236
- const status = task?.status ?? "completed";
237
- const color: ThemeColor = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : "accent";
238
- const id = task?.id ?? "background task";
239
- const name = task ? taskDisplayName(task) : "Background task";
240
- const output = task?.outputPath ? `\n${theme.fg("dim", `Output: ${task.outputPath}`)}` : "";
241
- const error = task?.error ? `\n${theme.fg("error", task.error)}` : "";
242
- return new Text(`${theme.fg(color, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
243
- });
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
-
265
- pi.on("session_start", async (_event, ctx) => {
266
- registry.setShuttingDown(false);
267
- currentCtx = ctx;
268
- await registry.ensureRuntimeDir(ctx);
269
- updateUi(ctx);
270
- if (statusInterval) clearInterval(statusInterval);
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);
274
- });
275
-
276
- pi.on("session_shutdown", async (_event, ctx) => {
277
- registry.setShuttingDown(true);
278
- currentCtx = undefined;
279
- if (statusInterval) {
280
- clearInterval(statusInterval);
281
- statusInterval = undefined;
282
- }
283
- const running = registry.allTasks().filter((task) => task.status === "running");
284
- if (running.length === 0) return;
285
-
286
- const failures: string[] = [];
287
- await Promise.all(
288
- running.map(async (task) => {
289
- try {
290
- await registry.stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
291
- } catch (error) {
292
- const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
293
- failures.push(message);
294
- console.error(`[background-tasks] shutdown cleanup failed for ${message}`);
295
- }
296
- }),
297
- );
298
- if (failures.length > 0 && ctx.hasUI) {
299
- ctx.ui.notify(`Background task cleanup failed:\n${failures.join("\n")}`, "error");
300
- }
301
- });
302
-
303
- pi.registerCommand("bg", {
304
- description: "Start a shell command as a tracked background task: /bg [--agent] [--name \"Task name\"] <command>",
305
- handler: async (args, ctx) => {
306
- try {
307
- const parsed = parseBgCommandArgs(args);
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);
311
- ctx.ui.notify(`Started ${taskDisplayName(task)} (${task.id})\nOutput: ${task.outputPath}\nCommand: ${task.command}`, "info");
312
- } catch (error) {
313
- ctx.ui.notify(`Background task failed to start: ${error instanceof Error ? error.message : String(error)}`, "error");
314
- }
315
- },
316
- });
317
-
318
- pi.registerCommand("tasks", {
319
- description: "Open the Claude-like background task manager UI",
320
- handler: async (args, ctx) => {
321
- const taskId = args.trim() || undefined;
322
- await openTaskManager(ctx, taskId);
323
- },
324
- });
325
-
326
- pi.registerCommand("bg-tasks", {
327
- description: "Open the background task manager UI",
328
- handler: async (args, ctx) => {
329
- const taskId = args.trim() || undefined;
330
- await openTaskManager(ctx, taskId);
331
- },
332
- });
333
-
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, {
364
- description: "Open focused background task footer dock",
365
- handler: async (ctx) => {
366
- await openTaskManager(ctx);
367
- },
368
- });
369
-
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),
373
- });
374
-
375
- pi.registerCommand("jobs", {
376
- description: "List running and recent background tasks",
377
- handler: async (_args, ctx) => {
378
- currentCtx = ctx;
379
- ctx.ui.notify(formatSnapshotList(registry.allTasks().map((task) => registry.snapshot(task))), "info");
380
- updateUi(ctx);
381
- },
382
- });
383
-
384
- pi.registerCommand("logs", {
385
- description: "Show bounded output from a background task: /logs <id> [maxBytes]",
386
- getArgumentCompletions: (prefix) => {
387
- const matches = registry.allTasks()
388
- .filter((task) => task.id.startsWith(prefix.trim()))
389
- .slice(0, 20)
390
- .map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: `${task.status} — ${truncateChars(task.command, 60)}` }));
391
- return matches.length > 0 ? matches : null;
392
- },
393
- handler: async (args, ctx) => {
394
- try {
395
- currentCtx = ctx;
396
- const [id, bytes] = args.trim().split(/\s+/, 2);
397
- const task = registry.resolveTask(id || "");
398
- const maxBytes = normalizeMaxBytes(Number(bytes), DEFAULT_LOG_BYTES);
399
- const logs = await registry.getTaskLogs(task, maxBytes, true);
400
- ctx.ui.notify(logs.text, "info");
401
- } catch (error) {
402
- ctx.ui.notify(`Background logs error: ${error instanceof Error ? error.message : String(error)}`, "error");
403
- }
404
- },
405
- });
406
-
407
- pi.registerCommand("kill", {
408
- description: "Stop a running background task: /kill <id>",
409
- getArgumentCompletions: (prefix) => {
410
- const matches = registry.allTasks()
411
- .filter((task) => task.status === "running" && task.id.startsWith(prefix.trim()))
412
- .slice(0, 20)
413
- .map((task) => ({ value: task.id, label: `${task.id} ${taskDisplayName(task)}`, description: truncateChars(task.command, 70) }));
414
- return matches.length > 0 ? matches : null;
415
- },
416
- handler: async (args, ctx) => {
417
- try {
418
- currentCtx = ctx;
419
- const task = registry.resolveTask(args.trim());
420
- await registry.stopTask(task, "user");
421
- ctx.ui.notify(`Killed ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`, "info");
422
- updateUi(ctx);
423
- } catch (error) {
424
- ctx.ui.notify(`Background kill error: ${error instanceof Error ? error.message : String(error)}`, "error");
425
- }
426
- },
427
- });
428
-
429
- pi.registerTool<typeof BgRunParams, BgRunDetails>({
430
- name: "bg_run",
431
- label: "Background Run",
432
- description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
433
- promptSnippet: "Start named long-running shell commands in the background and return a task ID plus output file path",
434
- promptGuidelines: [
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.",
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.",
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.",
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.",
440
- ],
441
- parameters: BgRunParams,
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"],
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;
459
- },
460
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
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 = {
465
- name: params.name,
466
- isAgent: params.isAgent,
467
- notifyOnCompletion: params.notifyOnCompletion ?? true,
468
- triggerOnCompletion: params.triggerOnCompletion ?? true,
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);
473
- return {
474
- content: textContent(`Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${task.pid ?? "unknown"}\nOutput: ${task.outputPath}`),
475
- details: { task: registry.snapshot(task) },
476
- };
477
- },
478
- renderCall(args, theme) {
479
- return new Text(`${theme.fg("toolTitle", theme.bold("bg_run "))}${theme.fg("muted", truncateChars(taskDisplayName(args), COMMAND_PREVIEW_CHARS))}`, 0, 0);
480
- },
481
- renderResult(result, _options, theme) {
482
- const task = result.details?.task;
483
- if (!task) return renderPlainResult(result, _options, theme);
484
- return new Text(`${theme.fg("success", "✓ started")} ${theme.fg("accent", taskDisplayName(task))} ${theme.fg("dim", `(${task.id})`)}\n${theme.fg("dim", `Output: ${task.outputPath}`)}`, 0, 0);
485
- },
486
- });
487
-
488
- pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
489
- name: "bg_status",
490
- label: "Background Status",
491
- description: "Inspect one background task or list all running/recent background tasks.",
492
- promptSnippet: "Inspect status for one or all background tasks",
493
- promptGuidelines: ["Use bg_status before bg_logs when you need to know whether a background task is still running or has finished."],
494
- parameters: BgStatusParams,
495
- async execute(_toolCallId, params) {
496
- const selected = params.taskId ? [registry.resolveTask(params.taskId)] : registry.allTasks();
497
- const snapshots = selected.map((task) => registry.snapshot(task));
498
- return {
499
- content: textContent(formatSnapshotList(snapshots)),
500
- details: { tasks: snapshots },
501
- };
502
- },
503
- renderCall(args, theme) {
504
- return new Text(`${theme.fg("toolTitle", theme.bold("bg_status"))}${args.taskId ? ` ${theme.fg("accent", args.taskId)}` : ""}`, 0, 0);
505
- },
506
- renderResult: renderPlainResult,
507
- });
508
-
509
- pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
510
- name: "bg_logs",
511
- label: "Background Logs",
512
- description: `Read bounded output from a background task. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
513
- promptSnippet: "Read bounded output from a background task log",
514
- promptGuidelines: ["Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context."],
515
- parameters: BgLogsParams,
516
- async execute(_toolCallId, params) {
517
- const task = registry.resolveTask(params.taskId);
518
- const logs = await registry.getTaskLogs(task, normalizeMaxBytes(params.maxBytes), params.tail ?? true);
519
- return {
520
- content: textContent(logs.text),
521
- details: logs.details,
522
- };
523
- },
524
- renderCall(args, theme) {
525
- return new Text(`${theme.fg("toolTitle", theme.bold("bg_logs "))}${theme.fg("accent", args.taskId)}`, 0, 0);
526
- },
527
- renderResult(result, { expanded }, theme) {
528
- const details = result.details;
529
- if (!details) return renderPlainResult(result, { expanded, isPartial: false }, theme);
530
- let text = `${theme.fg("accent", taskDisplayName(details.task))} ${theme.fg("dim", `(${details.task.id})`)} ${theme.fg("muted", details.tail ? "tail" : "head")} ${formatSize(details.bytesRead)}`;
531
- if (details.truncated) text += theme.fg("warning", " (truncated)");
532
- text += `\n${theme.fg("dim", `Full output: ${details.path}`)}`;
533
- if (expanded) {
534
- const content = result.content?.[0];
535
- if (content?.type === "text") text += `\n${theme.fg("toolOutput", content.text.split("\n").slice(0, 30).join("\n"))}`;
536
- }
537
- return new Text(text, 0, 0);
538
- },
539
- });
540
-
541
- pi.registerTool<typeof BgKillParams, BgKillDetails>({
542
- name: "bg_kill",
543
- label: "Background Kill",
544
- description: "Stop a running background task by ID. Fails loudly if the task is unknown or already finished.",
545
- promptSnippet: "Stop a running background task by ID",
546
- promptGuidelines: ["Use bg_kill when the user asks to stop a background task or when a bg_run command is no longer needed."],
547
- parameters: BgKillParams,
548
- async execute(_toolCallId, params) {
549
- const task = registry.resolveTask(params.taskId);
550
- await registry.stopTask(task, "user");
551
- const message = `Killed background task ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`;
552
- return {
553
- content: textContent(message),
554
- details: { task: registry.snapshot(task), message },
555
- };
556
- },
557
- renderCall(args, theme) {
558
- return new Text(`${theme.fg("toolTitle", theme.bold("bg_kill "))}${theme.fg("accent", args.taskId)}`, 0, 0);
559
- },
560
- renderResult(result, _options, theme) {
561
- const task = result.details?.task;
562
- if (!task) return renderPlainResult(result, _options, theme);
563
- return new Text(`${theme.fg("warning", "■ killed")} ${theme.fg("accent", taskDisplayName(task))} ${theme.fg("dim", `(${task.id})`)}\n${theme.fg("dim", `Output: ${task.outputPath}`)}`, 0, 0);
564
- },
565
- });
202
+ registerFusionExtension(pi);
203
+
204
+ const seenTaskIds = new Set<string>();
205
+ let currentCtx: ExtensionContext | undefined;
206
+ let dockOpen = false;
207
+ let statusInterval: NodeJS.Timeout | undefined;
208
+ let latestKnownVersion: string | undefined;
209
+ let updateCheckStarted = false;
210
+
211
+ let eventService: BackgroundTaskExtensionService | undefined;
212
+ const registry = new BackgroundTaskRegistry({
213
+ onChange: () => {
214
+ updateUi();
215
+ },
216
+ sendCompletionNotification: (message, options) => {
217
+ pi.sendMessage(message, options);
218
+ },
219
+ publishTerminal: (task) => {
220
+ if (!eventService) throw new Error('Background task EventBus service is not installed');
221
+ eventService.publishTerminal(task);
222
+ },
223
+ });
224
+ eventService = installBackgroundTaskExtensionApi({
225
+ events: pi.events,
226
+ registry,
227
+ getContext: () => currentCtx,
228
+ isShuttingDown: () => registry.isShuttingDown(),
229
+ });
230
+
231
+ function unseenFinishedTasks(): BgTask[] {
232
+ return registry
233
+ .allTasks()
234
+ .filter((task) => task.status !== 'running' && !seenTaskIds.has(task.id));
235
+ }
236
+
237
+ function clearFinishedNotices(ctx = currentCtx): number {
238
+ const unseen = unseenFinishedTasks();
239
+ for (const task of unseen) seenTaskIds.add(task.id);
240
+ updateUi(ctx);
241
+ return unseen.length;
242
+ }
243
+
244
+ function notifyClearFinishedNotices(ctx: ExtensionContext): void {
245
+ currentCtx = ctx;
246
+ const cleared = clearFinishedNotices(ctx);
247
+ if (!ctx.hasUI) return;
248
+ ctx.ui.notify(
249
+ cleared > 0
250
+ ? `Cleared ${String(cleared)} finished background task notice${cleared === 1 ? '' : 's'}.`
251
+ : 'No finished background task notices to clear.',
252
+ cleared > 0 ? 'info' : 'warning',
253
+ );
254
+ }
255
+
256
+ function updateUi(ctx = currentCtx): void {
257
+ if (registry.isShuttingDown() || !ctx) return;
258
+ try {
259
+ if (!ctx.hasUI) return;
260
+ const allTasks = registry.allTasks();
261
+ const running = allTasks.filter((task) => task.status === 'running');
262
+ const unseenFailed = allTasks.filter(
263
+ (task) => task.status === 'failed' && !seenTaskIds.has(task.id),
264
+ );
265
+ const unseenStopped = allTasks.filter(
266
+ (task) => task.status === 'killed' && !seenTaskIds.has(task.id),
267
+ );
268
+ const unseenDone = allTasks.filter(
269
+ (task) => task.status === 'completed' && !seenTaskIds.has(task.id),
270
+ );
271
+ const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
272
+ const updateSegment = formatUpdateSegment(latestKnownVersion, PACKAGE_VERSION ?? '');
273
+ ctx.ui.setWidget('background-tasks', undefined);
274
+ if (running.length === 0 && unseenFinishedCount === 0) {
275
+ ctx.ui.setStatus(
276
+ 'background-tasks',
277
+ updateSegment ? lightBlue(` bg ${updateSegment} `) : undefined,
278
+ );
279
+ return;
280
+ }
281
+
282
+ const parts: string[] = [];
283
+ if (running.length > 0) parts.push(`${String(running.length)} running`);
284
+ if (unseenFailed.length > 0) parts.push(`${String(unseenFailed.length)} failed`);
285
+ if (unseenStopped.length > 0) parts.push(`${String(unseenStopped.length)} stopped`);
286
+ if (unseenDone.length > 0) parts.push(`${String(unseenDone.length)} done`);
287
+ const entryHint = dockOpen
288
+ ? 'focused'
289
+ : `Shift↓${unseenFinishedCount > 0 ? ' · /bg-clear' : ''}`;
290
+ const segments = [...parts, entryHint];
291
+ if (updateSegment) segments.push(updateSegment);
292
+ const label = ` bg ${segments.join(' · ')} `;
293
+ ctx.ui.setStatus('background-tasks', lightBlue(label));
294
+ } catch (error) {
295
+ console.error(
296
+ `[background-tasks] UI update failed: ${error instanceof Error ? error.message : String(error)}`,
297
+ );
298
+ currentCtx = undefined;
299
+ }
300
+ }
301
+
302
+ async function startTask(
303
+ ctx: ExtensionContext,
304
+ command: string,
305
+ options: StartTaskOptions = {},
306
+ ): Promise<BgTask> {
307
+ currentCtx = ctx;
308
+ return registry.startTask(ctx, command, options);
309
+ }
310
+
311
+ async function startAttestedPiTask(
312
+ ctx: ExtensionContext,
313
+ options: StartAttestedPiTaskOptions,
314
+ ): Promise<BgTask> {
315
+ currentCtx = ctx;
316
+ return registry.startAttestedPiTask(ctx, options);
317
+ }
318
+
319
+ async function openTaskManager(
320
+ ctx: ExtensionCommandContext | ExtensionContext,
321
+ initialTaskId?: string,
322
+ ): Promise<void> {
323
+ currentCtx = ctx;
324
+ if (!ctx.hasUI) {
325
+ ctx.ui.notify(
326
+ 'Background task manager requires an interactive Pi UI. Use /jobs, /logs, or the bg_status/bg_logs tools in non-interactive mode.',
327
+ 'error',
328
+ );
329
+ return;
330
+ }
331
+ dockOpen = true;
332
+ updateUi(ctx);
333
+ try {
334
+ await ctx.ui.custom<TaskManagerResult>(
335
+ (tui, theme, _keybindings, done) => {
336
+ const managerOptions = {
337
+ getTasks: () => registry.allTasks(),
338
+ stopTask: async (task: BackgroundTaskForUi) => {
339
+ await registry.stopTask(registry.resolveTask(task.id), 'user');
340
+ updateUi(ctx);
341
+ },
342
+ stopAllRunning: async () => {
343
+ const result = await registry.stopAllRunning('user');
344
+ updateUi(ctx);
345
+ return result;
346
+ },
347
+ rerunTask: async (task: BackgroundTaskForUi) => {
348
+ const rerunOptions: StartTaskOptions = {
349
+ name: taskDisplayName(task),
350
+ isAgent: task.isAgent,
351
+ notifyOnCompletion: true,
352
+ triggerOnCompletion: false,
353
+ };
354
+ if (task.description !== undefined) rerunOptions.description = task.description;
355
+ if (task.timeoutSeconds !== undefined)
356
+ rerunOptions.timeoutSeconds = task.timeoutSeconds;
357
+ const rerun = await startTask(ctx, task.command, rerunOptions);
358
+ updateUi(ctx);
359
+ return rerun;
360
+ },
361
+ showOutputPath: (task: BackgroundTaskForUi) => {
362
+ ctx.ui.notify(
363
+ `Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`,
364
+ 'info',
365
+ );
366
+ },
367
+ markSeen: (taskId: string) => {
368
+ seenTaskIds.add(taskId);
369
+ updateUi(ctx);
370
+ },
371
+ markFinishedSeen: (taskIds: string[]) => {
372
+ for (const taskId of taskIds) seenTaskIds.add(taskId);
373
+ updateUi(ctx);
374
+ },
375
+ isSeen: (taskId: string) => seenTaskIds.has(taskId),
376
+ };
377
+ if (initialTaskId)
378
+ return new BackgroundTasksManager(tui, theme, done, {
379
+ ...managerOptions,
380
+ initialTaskId,
381
+ });
382
+ return new BackgroundTasksManager(tui, theme, done, managerOptions);
383
+ },
384
+ {
385
+ overlay: true,
386
+ overlayOptions: {
387
+ anchor: 'bottom-center',
388
+ width: '96%',
389
+ minWidth: 64,
390
+ maxHeight: '60%',
391
+ margin: { bottom: 1, left: 1, right: 1 },
392
+ },
393
+ },
394
+ );
395
+ } finally {
396
+ dockOpen = false;
397
+ updateUi(ctx);
398
+ }
399
+ }
400
+
401
+ pi.registerMessageRenderer<BgTaskSnapshot>(
402
+ 'background-task-notification',
403
+ (message, _options, theme) => {
404
+ const task = message.details;
405
+ const status = task?.status ?? 'completed';
406
+ const color: ThemeColor =
407
+ status === 'completed'
408
+ ? 'success'
409
+ : status === 'failed'
410
+ ? 'error'
411
+ : status === 'killed'
412
+ ? 'warning'
413
+ : 'accent';
414
+ const id = task?.id ?? 'background task';
415
+ const name = task ? taskDisplayName(task) : 'Background task';
416
+ const output = task?.outputPath ? `\n${theme.fg('dim', `Output: ${task.outputPath}`)}` : '';
417
+ const error = task?.error ? `\n${theme.fg('error', task.error)}` : '';
418
+ return new Text(
419
+ `${theme.fg(color, `[bg ${status}]`)} ${theme.fg('accent', name)} ${theme.fg('dim', `(${id})`)}${output}${error}`,
420
+ 0,
421
+ 0,
422
+ );
423
+ },
424
+ );
425
+
426
+ async function scheduleUpdateCheck(ctx: ExtensionContext): Promise<void> {
427
+ if (updateCheckStarted) return;
428
+ updateCheckStarted = true;
429
+ const env = process.env;
430
+ if (env['PI_BG_DISABLE_UPDATE_CHECK'] === '1') return;
431
+ if (env['PI_OFFLINE'] === '1') return;
432
+ if (!PACKAGE_VERSION) return;
433
+ const options: FetchLatestVersionOptions = {
434
+ packageName: PACKAGE_NAME,
435
+ onError: (error) => {
436
+ console.error(`[background-tasks] update check skipped: ${error.message}`);
437
+ },
438
+ };
439
+ const registryUrl = env['PI_BG_REGISTRY_URL'];
440
+ if (registryUrl) options.registryUrl = registryUrl;
441
+ const latest = await fetchLatestVersion(options);
442
+ if (latest && isNewerVersion(latest, PACKAGE_VERSION)) {
443
+ latestKnownVersion = latest;
444
+ updateUi(ctx);
445
+ }
446
+ }
447
+
448
+ pi.on('session_start', async (_event, ctx) => {
449
+ registry.setShuttingDown(false);
450
+ currentCtx = ctx;
451
+ await registry.ensureRuntimeDir(ctx);
452
+ updateUi(ctx);
453
+ if (statusInterval) clearInterval(statusInterval);
454
+ statusInterval = setInterval(() => {
455
+ updateUi();
456
+ }, STATUS_INTERVAL_MS);
457
+ // One-shot, non-blocking: never awaited on the session-start path or the status tick.
458
+ void scheduleUpdateCheck(ctx);
459
+ });
460
+
461
+ pi.on('session_shutdown', async (_event, ctx) => {
462
+ registry.setShuttingDown(true);
463
+ currentCtx = undefined;
464
+ if (statusInterval) {
465
+ clearInterval(statusInterval);
466
+ statusInterval = undefined;
467
+ }
468
+ try {
469
+ const running = registry.allTasks().filter((task) => task.status === 'running');
470
+ if (running.length === 0) return;
471
+
472
+ const failures: string[] = [];
473
+ await Promise.all(
474
+ running.map(async (task) => {
475
+ try {
476
+ await registry.stopTask(task, 'shutdown', 'Killed during Pi session shutdown/reload');
477
+ } catch (error) {
478
+ const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
479
+ failures.push(message);
480
+ console.error(`[background-tasks] shutdown cleanup failed for ${message}`);
481
+ }
482
+ }),
483
+ );
484
+ if (failures.length > 0 && ctx.hasUI) {
485
+ ctx.ui.notify(`Background task cleanup failed:\n${failures.join('\n')}`, 'error');
486
+ }
487
+ } finally {
488
+ eventService?.close();
489
+ }
490
+ });
491
+
492
+ pi.registerCommand('bg', {
493
+ description:
494
+ 'Start a shell command as a tracked background task: /bg [--agent] [--name "Task name"] <command>',
495
+ handler: async (args, ctx) => {
496
+ try {
497
+ const parsed = parseBgCommandArgs(args);
498
+ const taskOptions: StartTaskOptions = {
499
+ isAgent: parsed.isAgent,
500
+ notifyOnCompletion: true,
501
+ triggerOnCompletion: false,
502
+ };
503
+ if (parsed.name !== undefined) taskOptions.name = parsed.name;
504
+ const task = await startTask(ctx, parsed.command, taskOptions);
505
+ ctx.ui.notify(
506
+ `Started ${taskDisplayName(task)} (${task.id})\nOutput: ${task.outputPath}\nCommand: ${task.command}`,
507
+ 'info',
508
+ );
509
+ } catch (error) {
510
+ ctx.ui.notify(
511
+ `Background task failed to start: ${error instanceof Error ? error.message : String(error)}`,
512
+ 'error',
513
+ );
514
+ }
515
+ },
516
+ });
517
+
518
+ pi.registerCommand('tasks', {
519
+ description: 'Open the Claude-like background task manager UI',
520
+ handler: async (args, ctx) => {
521
+ const taskId = optionalTrimmed(args);
522
+ await openTaskManager(ctx, taskId);
523
+ },
524
+ });
525
+
526
+ pi.registerCommand('bg-tasks', {
527
+ description: 'Open the background task manager UI',
528
+ handler: async (args, ctx) => {
529
+ const taskId = optionalTrimmed(args);
530
+ await openTaskManager(ctx, taskId);
531
+ },
532
+ });
533
+
534
+ pi.registerCommand('bg-clear', {
535
+ description: 'Clear finished background task footer notices',
536
+ handler: (_args, ctx) => {
537
+ notifyClearFinishedNotices(ctx);
538
+ return Promise.resolve();
539
+ },
540
+ });
541
+
542
+ pi.registerCommand('bg-update', {
543
+ description: 'Show how to update pi-background-tasks to the latest published version',
544
+ handler: (_args, ctx) => {
545
+ const current = PACKAGE_VERSION ?? 'unknown';
546
+ const latest = latestKnownVersion;
547
+ const pinnedNpm = latest ? `${PACKAGE_NAME}@${latest}` : `${PACKAGE_NAME}@<version>`;
548
+ const pinnedGit = latest ? `${GIT_INSTALL_TARGET}@v${latest}` : `${GIT_INSTALL_TARGET}@<tag>`;
549
+ const lines = [
550
+ latest
551
+ ? `pi-background-tasks ${current} is installed; ${latest} is the latest published version.`
552
+ : `pi-background-tasks ${current} is installed.`,
553
+ 'Update from npm:',
554
+ ` pi install npm:${PACKAGE_NAME}@latest`,
555
+ ` pi install npm:${pinnedNpm}`,
556
+ 'Or update from git tags:',
557
+ ` pi install ${pinnedGit}`,
558
+ 'This command only prints update instructions; it does not install or self-update.',
559
+ ];
560
+ ctx.ui.notify(lines.join('\n'), 'info');
561
+ return Promise.resolve();
562
+ },
563
+ });
564
+
565
+ pi.registerShortcut('shift+down' satisfies KeyId, {
566
+ description: 'Open focused background task footer dock',
567
+ handler: async (ctx) => {
568
+ await openTaskManager(ctx);
569
+ },
570
+ });
571
+
572
+ pi.registerShortcut('ctrl+alt+c' satisfies KeyId, {
573
+ description:
574
+ 'Clear finished background task footer notices (terminal-dependent fallback for /bg-clear)',
575
+ handler: (ctx) => {
576
+ notifyClearFinishedNotices(ctx);
577
+ },
578
+ });
579
+
580
+ pi.registerCommand('jobs', {
581
+ description: 'List running and recent background tasks',
582
+ handler: (_args, ctx) => {
583
+ currentCtx = ctx;
584
+ ctx.ui.notify(
585
+ formatSnapshotList(registry.allTasks().map((task) => registry.snapshot(task))),
586
+ 'info',
587
+ );
588
+ updateUi(ctx);
589
+ return Promise.resolve();
590
+ },
591
+ });
592
+
593
+ pi.registerCommand('logs', {
594
+ description: 'Show bounded output from a background task: /logs <id> [maxBytes]',
595
+ getArgumentCompletions: (prefix) => {
596
+ const matches = registry
597
+ .allTasks()
598
+ .filter((task) => task.id.startsWith(prefix.trim()))
599
+ .slice(0, 20)
600
+ .map((task) => ({
601
+ value: task.id,
602
+ label: `${task.id} ${taskDisplayName(task)}`,
603
+ description: `${task.status} — ${truncateChars(task.command, 60)}`,
604
+ }));
605
+ return matches.length > 0 ? matches : null;
606
+ },
607
+ handler: async (args, ctx) => {
608
+ try {
609
+ currentCtx = ctx;
610
+ const [id, bytes] = args.trim().split(/\s+/, 2);
611
+ const task = registry.resolveTask(id ?? '');
612
+ const maxBytes = normalizeMaxBytes(Number(bytes), DEFAULT_LOG_BYTES);
613
+ const logs = await registry.getTaskLogs(task, maxBytes, true);
614
+ ctx.ui.notify(logs.text, 'info');
615
+ } catch (error) {
616
+ ctx.ui.notify(
617
+ `Background logs error: ${error instanceof Error ? error.message : String(error)}`,
618
+ 'error',
619
+ );
620
+ }
621
+ },
622
+ });
623
+
624
+ pi.registerCommand('kill', {
625
+ description: 'Stop a running background task: /kill <id>',
626
+ getArgumentCompletions: (prefix) => {
627
+ const matches = registry
628
+ .allTasks()
629
+ .filter((task) => task.status === 'running' && task.id.startsWith(prefix.trim()))
630
+ .slice(0, 20)
631
+ .map((task) => ({
632
+ value: task.id,
633
+ label: `${task.id} ${taskDisplayName(task)}`,
634
+ description: truncateChars(task.command, 70),
635
+ }));
636
+ return matches.length > 0 ? matches : null;
637
+ },
638
+ handler: async (args, ctx) => {
639
+ try {
640
+ currentCtx = ctx;
641
+ const task = registry.resolveTask(args.trim());
642
+ await registry.stopTask(task, 'user');
643
+ ctx.ui.notify(
644
+ `Killed ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`,
645
+ 'info',
646
+ );
647
+ updateUi(ctx);
648
+ } catch (error) {
649
+ ctx.ui.notify(
650
+ `Background kill error: ${error instanceof Error ? error.message : String(error)}`,
651
+ 'error',
652
+ );
653
+ }
654
+ },
655
+ });
656
+
657
+ pi.registerTool<typeof BgRunParams, BgRunDetails>({
658
+ name: 'bg_run',
659
+ label: 'Background Run',
660
+ description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
661
+ promptSnippet:
662
+ 'Start named long-running shell commands in the background and return a task ID plus output file path',
663
+ promptGuidelines: [
664
+ '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.',
665
+ '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.',
666
+ '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.',
667
+ '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.',
668
+ '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.',
669
+ ],
670
+ parameters: BgRunParams,
671
+ prepareArguments(args): BgRunParamsValue {
672
+ if (!args || typeof args !== 'object') throw new Error('bg_run arguments must be an object');
673
+ const input = args as BgToolArgumentRecord;
674
+ if (typeof input.command !== 'string') throw new Error('bg_run requires command string');
675
+ if (typeof input.isAgent !== 'boolean') {
676
+ throw new Error(
677
+ 'bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.',
678
+ );
679
+ }
680
+ const prepared: BgRunParamsValue = {
681
+ command: input.command,
682
+ name:
683
+ normalizeTaskName(input.name) ??
684
+ normalizeTaskName(input.description) ??
685
+ deriveTaskNameFromCommand(input.command),
686
+ isAgent: input.isAgent,
687
+ };
688
+ if (typeof input.description === 'string') prepared.description = input.description;
689
+ if (typeof input.timeoutSeconds === 'number') prepared.timeoutSeconds = input.timeoutSeconds;
690
+ if (typeof input.notifyOnCompletion === 'boolean')
691
+ prepared.notifyOnCompletion = input.notifyOnCompletion;
692
+ if (typeof input.triggerOnCompletion === 'boolean')
693
+ prepared.triggerOnCompletion = input.triggerOnCompletion;
694
+ return prepared;
695
+ },
696
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
697
+ if (typeof params.isAgent !== 'boolean') {
698
+ throw new Error(
699
+ 'bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.',
700
+ );
701
+ }
702
+ const taskOptions: StartTaskOptions = {
703
+ name: params.name,
704
+ isAgent: params.isAgent,
705
+ notifyOnCompletion: params.notifyOnCompletion ?? true,
706
+ triggerOnCompletion: params.triggerOnCompletion ?? true,
707
+ };
708
+ if (params.description !== undefined) taskOptions.description = params.description;
709
+ if (params.timeoutSeconds !== undefined) taskOptions.timeoutSeconds = params.timeoutSeconds;
710
+ const task = await startTask(ctx, params.command, taskOptions);
711
+ return {
712
+ content: textContent(
713
+ `Started background task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}`,
714
+ ),
715
+ details: { task: registry.snapshot(task) },
716
+ };
717
+ },
718
+ renderCall(args, theme) {
719
+ return new Text(
720
+ `${theme.fg('toolTitle', theme.bold('bg_run '))}${theme.fg('muted', truncateChars(taskDisplayName(args), COMMAND_PREVIEW_CHARS))}`,
721
+ 0,
722
+ 0,
723
+ );
724
+ },
725
+ renderResult(result, _options, theme) {
726
+ const { task } = result.details;
727
+ return new Text(
728
+ `${theme.fg('success', '✓ started')} ${theme.fg('accent', taskDisplayName(task))} ${theme.fg('dim', `(${task.id})`)}\n${theme.fg('dim', `Output: ${task.outputPath}`)}`,
729
+ 0,
730
+ 0,
731
+ );
732
+ },
733
+ });
734
+
735
+ pi.registerTool<typeof BgPiAttestedParams, BgRunDetails>({
736
+ name: 'bg_run_pi_attested',
737
+ label: 'Attested Pi Run',
738
+ description:
739
+ 'Opt-in evidence-oriented direct Pi spawn. Launches exactly one `pi --mode json` child, records raw Pi events/stderr, hashes prompt/report/output, observes OAuth through ModelRegistry, and emits a strict attestation sidecar only after successful completion.',
740
+ promptSnippet: 'Start an attested direct Pi agent task and return its task ID plus output path',
741
+ promptGuidelines: [
742
+ 'Use only when the user explicitly asks for an attested Pi evidence-producing task; ordinary background work should use bg_run unchanged.',
743
+ 'Provide provider/model as structured fields and a relative reportPath that the child Pi prompt will write before exit.',
744
+ 'Do not provide channel, auth, route, or hash claims; the producer observes those facts itself and fails loudly if it cannot attest them.',
745
+ ],
746
+ parameters: BgPiAttestedParams,
747
+ prepareArguments(args): BgPiAttestedParamsValue {
748
+ if (!args || typeof args !== 'object')
749
+ throw new Error('bg_run_pi_attested arguments must be an object');
750
+ const input = args as BgPiAttestedArgumentRecord;
751
+ if (typeof input.name !== 'string') throw new Error('bg_run_pi_attested requires name');
752
+ if (typeof input.provider !== 'string')
753
+ throw new Error('bg_run_pi_attested requires provider');
754
+ if (typeof input.model !== 'string') throw new Error('bg_run_pi_attested requires model');
755
+ if (typeof input.prompt !== 'string') throw new Error('bg_run_pi_attested requires prompt');
756
+ if (typeof input.reportPath !== 'string')
757
+ throw new Error('bg_run_pi_attested requires reportPath');
758
+ const prepared: BgPiAttestedParamsValue = {
759
+ name: input.name,
760
+ provider: input.provider,
761
+ model: input.model,
762
+ prompt: input.prompt,
763
+ reportPath: input.reportPath,
764
+ };
765
+ if (Array.isArray(input.extraPiArgs)) {
766
+ if (!input.extraPiArgs.every((entry) => typeof entry === 'string'))
767
+ throw new Error('bg_run_pi_attested extraPiArgs entries must be strings');
768
+ prepared.extraPiArgs = input.extraPiArgs;
769
+ }
770
+ if (typeof input.thinking === 'string') prepared.thinking = input.thinking;
771
+ if (typeof input.timeoutSeconds === 'number') prepared.timeoutSeconds = input.timeoutSeconds;
772
+ return prepared;
773
+ },
774
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
775
+ const task = await startAttestedPiTask(ctx, params);
776
+ return {
777
+ content: textContent(
778
+ `Started attested Pi task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}\nAttestation: ${task.attestationPath ?? 'pending until completion'}`,
779
+ ),
780
+ details: { task: registry.snapshot(task) },
781
+ };
782
+ },
783
+ renderCall(args, theme) {
784
+ return new Text(
785
+ `${theme.fg('toolTitle', theme.bold('bg_run_pi_attested '))}${theme.fg('muted', truncateChars(args.name, COMMAND_PREVIEW_CHARS))}`,
786
+ 0,
787
+ 0,
788
+ );
789
+ },
790
+ renderResult(result, _options, theme) {
791
+ const { task } = result.details;
792
+ return new Text(
793
+ `${theme.fg('success', '✓ started')} ${theme.fg('accent', taskDisplayName(task))} ${theme.fg('dim', `(${task.id})`)}\n${theme.fg('dim', `Output: ${task.outputPath}`)}\n${theme.fg('dim', `Attestation: ${task.attestationPath ?? 'pending'}`)}`,
794
+ 0,
795
+ 0,
796
+ );
797
+ },
798
+ });
799
+
800
+ pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
801
+ name: 'bg_status',
802
+ label: 'Background Status',
803
+ description: 'Inspect one background task or list all running/recent background tasks.',
804
+ promptSnippet: 'Inspect status for one or all background tasks',
805
+ promptGuidelines: [
806
+ 'Use bg_status before bg_logs when you need to know whether a background task is still running or has finished.',
807
+ ],
808
+ parameters: BgStatusParams,
809
+ execute(_toolCallId, params) {
810
+ const selected = params.taskId ? [registry.resolveTask(params.taskId)] : registry.allTasks();
811
+ const snapshots = selected.map((task) => registry.snapshot(task));
812
+ return Promise.resolve({
813
+ content: textContent(formatSnapshotList(snapshots)),
814
+ details: { tasks: snapshots },
815
+ });
816
+ },
817
+ renderCall(args, theme) {
818
+ return new Text(
819
+ `${theme.fg('toolTitle', theme.bold('bg_status'))}${args.taskId ? ` ${theme.fg('accent', args.taskId)}` : ''}`,
820
+ 0,
821
+ 0,
822
+ );
823
+ },
824
+ renderResult: renderPlainResult,
825
+ });
826
+
827
+ pi.registerTool<typeof BgLogsParams, BgLogsDetails>({
828
+ name: 'bg_logs',
829
+ label: 'Background Logs',
830
+ description: `Read bounded output from a background task. Output is capped at ${formatSize(MAX_LOG_BYTES)} for model safety and points to the full output file when truncated.`,
831
+ promptSnippet: 'Read bounded output from a background task log',
832
+ promptGuidelines: [
833
+ 'Use bg_logs with a modest maxBytes value to inspect background task progress without flooding context.',
834
+ ],
835
+ parameters: BgLogsParams,
836
+ async execute(_toolCallId, params) {
837
+ const task = registry.resolveTask(params.taskId);
838
+ const logs = await registry.getTaskLogs(
839
+ task,
840
+ normalizeMaxBytes(params.maxBytes),
841
+ params.tail ?? true,
842
+ );
843
+ return {
844
+ content: textContent(logs.text),
845
+ details: logs.details,
846
+ };
847
+ },
848
+ renderCall(args, theme) {
849
+ return new Text(
850
+ `${theme.fg('toolTitle', theme.bold('bg_logs '))}${theme.fg('accent', args.taskId)}`,
851
+ 0,
852
+ 0,
853
+ );
854
+ },
855
+ renderResult(result, { expanded }, theme) {
856
+ const details = result.details;
857
+ let text = `${theme.fg('accent', taskDisplayName(details.task))} ${theme.fg('dim', `(${details.task.id})`)} ${theme.fg('muted', details.tail ? 'tail' : 'head')} ${formatSize(details.bytesRead)}`;
858
+ if (details.truncated) text += theme.fg('warning', ' (truncated)');
859
+ text += `\n${theme.fg('dim', `Full output: ${details.path}`)}`;
860
+ if (expanded) {
861
+ const output = result.content
862
+ .map((content) => (content.type === 'text' ? content.text : '[image content]'))
863
+ .join('\n');
864
+ text += `\n${theme.fg('toolOutput', output.split('\n').slice(0, 30).join('\n'))}`;
865
+ }
866
+ return new Text(text, 0, 0);
867
+ },
868
+ });
869
+
870
+ pi.registerTool<typeof BgKillParams, BgKillDetails>({
871
+ name: 'bg_kill',
872
+ label: 'Background Kill',
873
+ description:
874
+ 'Stop a running background task by ID. Fails loudly if the task is unknown or already finished.',
875
+ promptSnippet: 'Stop a running background task by ID',
876
+ promptGuidelines: [
877
+ 'Use bg_kill when the user asks to stop a background task or when a bg_run command is no longer needed.',
878
+ ],
879
+ parameters: BgKillParams,
880
+ async execute(_toolCallId, params) {
881
+ const task = registry.resolveTask(params.taskId);
882
+ await registry.stopTask(task, 'user');
883
+ const message = `Killed background task ${taskDisplayName(task)} (${task.id}). Output: ${task.outputPath}`;
884
+ return {
885
+ content: textContent(message),
886
+ details: { task: registry.snapshot(task), message },
887
+ };
888
+ },
889
+ renderCall(args, theme) {
890
+ return new Text(
891
+ `${theme.fg('toolTitle', theme.bold('bg_kill '))}${theme.fg('accent', args.taskId)}`,
892
+ 0,
893
+ 0,
894
+ );
895
+ },
896
+ renderResult(result, _options, theme) {
897
+ const { task } = result.details;
898
+ return new Text(
899
+ `${theme.fg('warning', '■ killed')} ${theme.fg('accent', taskDisplayName(task))} ${theme.fg('dim', `(${task.id})`)}\n${theme.fg('dim', `Output: ${task.outputPath}`)}`,
900
+ 0,
901
+ 0,
902
+ );
903
+ },
904
+ });
566
905
  }