pi-background-run 0.1.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.
@@ -0,0 +1,644 @@
1
+ /**
2
+ * pi-bgrun — pi extension that runs long shell commands detached in the
3
+ * background and wakes the live agent session on completion.
4
+ *
5
+ * Architecture:
6
+ * - In-process spawn via child_process.spawn with stdio redirected to a log file
7
+ * (detached + unref so the job survives pi crashing).
8
+ * - The child wraps the command to append a trailing __BGRUN_EXIT__=N marker,
9
+ * making the log self-describing — exit codes survive pi restarting.
10
+ * - Completion is the child 'exit' event, not a poller. The exit handler wakes
11
+ * the agent via pi.sendUserMessage (triggers a turn when idle; followUp when busy).
12
+ * - Job records persist via pi.appendEntry (survives same-session restart,
13
+ * renders as a card in the transcript, does NOT enter LLM context).
14
+ * - Live status widget above the editor while jobs are running.
15
+ * - Desktop toast (ctx.ui.notify) on completion for the human.
16
+ *
17
+ * Three-tier state degradation:
18
+ * 1. In-memory Map (fast path while alive) — instant bgstatus, live exit→wake.
19
+ * 2. appendEntry reconstruction (same-session restart) — session_start rebuilds
20
+ * the Map from bgrun-job entries.
21
+ * 3. Filesystem scan (cross-session, cross-restart, cross-worktree) — the jobs
22
+ * dir is the permanent truth: filename→pid, log→exit code, kill -0→liveness.
23
+ */
24
+
25
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+ import { Type } from "typebox";
27
+ import { Box, Text } from "@earendil-works/pi-tui";
28
+ import { spawn } from "node:child_process";
29
+ import {
30
+ openSync,
31
+ closeSync,
32
+ readFileSync,
33
+ mkdirSync,
34
+ readdirSync,
35
+ renameSync,
36
+ unlinkSync,
37
+ statSync,
38
+ } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { homedir } from "node:os";
41
+
42
+ // Exit marker appended to every log so the file is self-describing: the exit
43
+ // code survives pi restarting. `;` (not `&&`) ensures the printf runs even when
44
+ // the command fails. Never use `set -e` in the wrapper.
45
+ const EXIT_MARKER = "__BGRUN_EXIT__=";
46
+
47
+ const AUTO_CLEANUP_DAYS = 14;
48
+ const DEFAULT_CLEANUP_DAYS = 7;
49
+
50
+ interface JobRecord {
51
+ id: string;
52
+ pid: number;
53
+ cmd: string;
54
+ name?: string; // optional human-readable label
55
+ started: number;
56
+ logPath: string;
57
+ exitedAt?: number;
58
+ exitCode?: number;
59
+ child?: ReturnType<typeof spawn>; // absent for adopted (fs-discovered) jobs
60
+ ctx: ExtensionContext; // captured at tool-call time for isIdle() in the exit handler
61
+ adopted?: boolean; // true when discovered from the jobs dir (another session's job)
62
+ }
63
+
64
+ // Shape persisted via pi.appendEntry — survives same-session restart, renders
65
+ // as a transcript card, does NOT enter LLM context.
66
+ interface BgrunJobEntryData {
67
+ id: string;
68
+ pid: number;
69
+ cmd: string;
70
+ name?: string;
71
+ started: number;
72
+ logPath: string;
73
+ state: "running" | "done";
74
+ exitCode?: number;
75
+ exitedAt?: number;
76
+ }
77
+
78
+ interface BgStatusDetails {
79
+ id?: string;
80
+ state?: string;
81
+ exitCode?: number;
82
+ cmd?: string;
83
+ name?: string;
84
+ count?: number;
85
+ recovered?: boolean;
86
+ }
87
+
88
+ function isRunningPid(pid: number): boolean {
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ export default function (pi: ExtensionAPI) {
98
+ const jobs = new Map<string, JobRecord>();
99
+ const jobsDir = process.env.PI_BGRUN_DIR || join(homedir(), ".pi-bgrun", "jobs");
100
+
101
+ // ── Helpers ───────────────────────────────────────────────────────────────
102
+
103
+ function makeSlug(command: string): string {
104
+ const raw = command.toLowerCase().replace(/[/\\.-]+/g, " ").trim();
105
+ const slug = raw.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
106
+ return slug || "job";
107
+ }
108
+
109
+ // Normalize an optional human-readable name: trim, drop blank, cap length.
110
+ function sanitizeName(name: string | undefined): string | undefined {
111
+ const trimmed = (name ?? "").trim();
112
+ if (!trimmed) return undefined;
113
+ return trimmed.slice(0, 80);
114
+ }
115
+
116
+ function readLastLogLine(logPath: string, maxLen = 200): string | null {
117
+ try {
118
+ const content = readFileSync(logPath, "utf8");
119
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
120
+ if (lines.length === 0) return null;
121
+ const real = lines.filter((l) => !l.startsWith(EXIT_MARKER));
122
+ const last = real[real.length - 1] ?? lines[lines.length - 1];
123
+ return last.length > maxLen ? last.slice(0, maxLen) + "…" : last;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function parseExitFromLog(logPath: string): number | null {
130
+ try {
131
+ const content = readFileSync(logPath, "utf8");
132
+ const lines = content.split("\n").filter((l) => l.startsWith(EXIT_MARKER));
133
+ if (lines.length === 0) return null;
134
+ const match = lines[lines.length - 1].match(/^__BGRUN_EXIT__=(\d+)/);
135
+ return match ? parseInt(match[1], 10) : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function pidFromId(id: string): number | null {
142
+ // id format: <slug>-<ts>-<pid>
143
+ const parts = id.split("-");
144
+ const pid = parseInt(parts[parts.length - 1], 10);
145
+ return Number.isFinite(pid) ? pid : null;
146
+ }
147
+
148
+ // ── Live status widget ────────────────────────────────────────────────────
149
+
150
+ function updateWidget(ctx: ExtensionContext): void {
151
+ if (!ctx.hasUI) return;
152
+ const running: JobRecord[] = [];
153
+ for (const rec of jobs.values()) {
154
+ if (rec.exitCode === undefined) running.push(rec);
155
+ }
156
+ if (running.length === 0) {
157
+ ctx.ui.setWidget("bgrun", undefined);
158
+ return;
159
+ }
160
+ const lines = [`📊 bgrun: ${running.length} running`];
161
+ for (const rec of running) {
162
+ const startedAt = new Date(rec.started).toLocaleTimeString([], { hour12: false });
163
+ const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
164
+ const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
165
+ const tag = rec.adopted ? " (adopted)" : "";
166
+ lines.push(` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`);
167
+ }
168
+ ctx.ui.setWidget("bgrun", lines);
169
+ }
170
+
171
+ function clearWidget(ctx: ExtensionContext): void {
172
+ if (!ctx.hasUI) return;
173
+ ctx.ui.setWidget("bgrun", undefined);
174
+ }
175
+
176
+ // ── Cleanup ───────────────────────────────────────────────────────────────
177
+
178
+ function cleanOldJobs(days: number, ctx?: ExtensionContext): { removed: number; kept: number; skippedRunning: number } {
179
+ const result = { removed: 0, kept: 0, skippedRunning: 0 };
180
+ let entries: string[];
181
+ try {
182
+ entries = readdirSync(jobsDir);
183
+ } catch {
184
+ return result;
185
+ }
186
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
187
+ for (const name of entries) {
188
+ if (!name.endsWith(".log")) continue;
189
+ const logPath = join(jobsDir, name);
190
+ let st;
191
+ try {
192
+ st = statSync(logPath);
193
+ } catch {
194
+ continue;
195
+ }
196
+ // mtime check
197
+ if (st.mtimeMs > cutoff) {
198
+ result.kept++;
199
+ continue;
200
+ }
201
+ const id = name.slice(0, -".log".length);
202
+ // Exit marker is the authoritative finished signal — check it BEFORE pid
203
+ // liveness, so completed jobs are never mistaken for running (pid reuse
204
+ // and shared pids made the old order keep stale jobs forever).
205
+ const finished = parseExitFromLog(logPath) !== null;
206
+ if (!finished) {
207
+ // No marker yet — running only if the pid is alive.
208
+ const rec = jobs.get(id);
209
+ if (rec && rec.exitCode === undefined) {
210
+ result.skippedRunning++;
211
+ continue;
212
+ }
213
+ const pid = pidFromId(id);
214
+ if (pid !== null && pid > 0 && isRunningPid(pid)) {
215
+ result.skippedRunning++;
216
+ continue;
217
+ }
218
+ }
219
+ try {
220
+ unlinkSync(logPath);
221
+ result.removed++;
222
+ } catch {
223
+ // ignore
224
+ }
225
+ }
226
+ if (result.removed > 0 && ctx?.hasUI) {
227
+ ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info");
228
+ }
229
+ return result;
230
+ }
231
+
232
+ // ── Entry renderer: job cards in the transcript ───────────────────────────
233
+
234
+ pi.registerEntryRenderer<BgrunJobEntryData>("bgrun-job", (entry, { expanded }, theme) => {
235
+ const d = entry.data ?? ({ id: "?", cmd: "", started: 0, logPath: "", state: "running" } as BgrunJobEntryData);
236
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
237
+ const icon = d.state === "done" ? (d.exitCode === 0 ? "✅" : "❌") : "🔄";
238
+ const exitStr = d.state === "done" ? ` exit=${d.exitCode ?? "?"}` : "";
239
+ const namePrefix = d.name ? `"${d.name}" ` : "";
240
+ box.addChild(new Text(`${icon} ${theme.fg("accent", "bgrun")} ${namePrefix}${d.id}${exitStr}`, 0, 0));
241
+ const cmdPreview = d.cmd.length > 60 ? d.cmd.slice(0, 57) + "…" : d.cmd;
242
+ box.addChild(new Text(theme.fg("dim", ` $ ${cmdPreview}`), 0, 0));
243
+ if (expanded) {
244
+ box.addChild(new Text(theme.fg("dim", ` log: ${d.logPath}`), 0, 0));
245
+ box.addChild(new Text(theme.fg("dim", ` started: ${new Date(d.started).toLocaleString()}`), 0, 0));
246
+ if (d.exitedAt) {
247
+ box.addChild(new Text(theme.fg("dim", ` finished: ${new Date(d.exitedAt).toLocaleString()}`), 0, 0));
248
+ }
249
+ }
250
+ return box;
251
+ });
252
+
253
+ // ── session_start: reconstruct Map from entries + auto-cleanup ────────────
254
+
255
+ pi.on("session_start", async (_event, ctx) => {
256
+ // Reconstruct the in-memory Map from this session's bgrun-job entries.
257
+ // Only the current session's entries are visible; jobs from other sessions
258
+ // remain discoverable via the filesystem scan in bgstatus.
259
+ try {
260
+ // Build a map of id → latest entry data. Entries are append-ordered, so
261
+ // the last one for a given id wins (a running entry is followed by a done
262
+ // entry when the job finishes).
263
+ const latestBydId = new Map<string, BgrunJobEntryData>();
264
+ for (const entry of ctx.sessionManager.getEntries()) {
265
+ if (entry.type === "custom" && entry.customType === "bgrun-job") {
266
+ const d = entry.data as BgrunJobEntryData | undefined;
267
+ if (!d || !d.id) continue;
268
+ latestBydId.set(d.id, d);
269
+ }
270
+ }
271
+ for (const d of latestBydId.values()) {
272
+ if (jobs.has(d.id)) continue;
273
+ jobs.set(d.id, {
274
+ id: d.id,
275
+ pid: d.pid,
276
+ cmd: d.cmd,
277
+ name: d.name,
278
+ started: d.started,
279
+ logPath: d.logPath,
280
+ exitedAt: d.exitedAt,
281
+ exitCode: d.exitCode,
282
+ ctx,
283
+ });
284
+ }
285
+ } catch (err) {
286
+ console.error("[pi-bgrun] session_start reconstruction failed:", (err as Error).message);
287
+ }
288
+
289
+ // Adopt running jobs discovered from the jobs dir (started by other sessions).
290
+ // These render in the widget and bgstatus, but have no ChildProcess handle —
291
+ // no exit event, so no wake-on-exit for adopted jobs.
292
+ try {
293
+ for (const name of readdirSync(jobsDir)) {
294
+ if (!name.endsWith(".log")) continue;
295
+ const id = name.slice(0, -".log".length);
296
+ if (jobs.has(id)) continue;
297
+ const logPath = join(jobsDir, name);
298
+ const exit = parseExitFromLog(logPath);
299
+ if (exit !== null) continue; // finished — nothing to show in the widget
300
+ const pid = pidFromId(id);
301
+ if (pid === null || pid <= 0 || !isRunningPid(pid)) continue; // dead pid, marker just not written yet
302
+ let started = Date.now();
303
+ try {
304
+ started = statSync(logPath).birthtimeMs;
305
+ } catch {
306
+ // keep fallback
307
+ }
308
+ jobs.set(id, {
309
+ id,
310
+ pid,
311
+ cmd: "(started by another session)",
312
+ started,
313
+ logPath,
314
+ ctx,
315
+ adopted: true,
316
+ });
317
+ }
318
+ } catch {
319
+ // jobs dir doesn't exist — nothing to adopt.
320
+ }
321
+
322
+ // Show the widget if anything is now running (covers adopted + reconstructed jobs).
323
+ updateWidget(ctx);
324
+ // Auto-cleanup of old logs (14-day default, fire-and-forget).
325
+ cleanOldJobs(AUTO_CLEANUP_DAYS, ctx);
326
+ });
327
+
328
+ pi.on("session_shutdown", async () => {
329
+ // Nothing to clean up — no timer; exit handlers are per-child and die with
330
+ // the ChildProcess handles. The widget is owned by the TUI which is tearing
331
+ // down anyway.
332
+ });
333
+
334
+ // ── bgrun tool ────────────────────────────────────────────────────────────
335
+
336
+ pi.registerTool({
337
+ name: "bgrun",
338
+ label: "Run in Background",
339
+ description:
340
+ "Run a long shell command detached in the background. Returns 'started: <job-id>' immediately. " +
341
+ "You will be woken automatically when the job finishes. Use this instead of bash for any command " +
342
+ "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " +
343
+ "short human-readable label used in the job id, status output, and wake messages.",
344
+ promptSnippet: "Run a long command detached in the background; get woken on completion",
345
+ promptGuidelines: [
346
+ "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
347
+ "Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.",
348
+ "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.",
349
+ "Never cat or Read a full bgrun log — use bgtail for a peek or ctx_execute_file for failure analysis.",
350
+ ],
351
+ parameters: Type.Object({
352
+ command: Type.String({
353
+ description: "Shell command to run in the background. Run as `sh -c`, so pipes and && work.",
354
+ }),
355
+ name: Type.Optional(
356
+ Type.String({
357
+ description:
358
+ "Optional short human-readable label for the job (e.g. 'unit-tests', 'frontend-build'). " +
359
+ "Used in the job id, status output, the status widget, and wake messages.",
360
+ }),
361
+ ),
362
+ }),
363
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
364
+ const { command, name: rawName } = params;
365
+ if (!command || !command.trim()) {
366
+ throw new Error("bgrun: command is required");
367
+ }
368
+ const name = sanitizeName(rawName);
369
+
370
+ mkdirSync(jobsDir, { recursive: true });
371
+
372
+ const slug = makeSlug(name ?? command);
373
+ const ts = Math.floor(Date.now() / 1000);
374
+ // The id must carry the CHILD's pid (liveness checks depend on it), but the
375
+ // log fd must exist before spawn. Create at a temp path, rename after spawn.
376
+ const tmpPath = join(jobsDir, `.tmp-${slug}-${ts}-${Math.random().toString(36).slice(2, 8)}.log`);
377
+ let logFd: number;
378
+ try {
379
+ logFd = openSync(tmpPath, "w");
380
+ } catch (err) {
381
+ throw new Error(`bgrun: cannot create log file: ${(err as Error).message}`);
382
+ }
383
+ const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`;
384
+
385
+ const child = spawn("sh", ["-c", wrapped], {
386
+ stdio: ["ignore", logFd, logFd],
387
+ detached: true,
388
+ });
389
+ child.unref();
390
+
391
+ const childPid = child.pid ?? -1;
392
+ const id = `${slug}-${ts}-${childPid}`;
393
+ const logPath = join(jobsDir, `${id}.log`);
394
+ try {
395
+ renameSync(tmpPath, logPath);
396
+ } catch (err) {
397
+ console.error(`[pi-bgrun] rename to final log path failed:`, (err as Error).message);
398
+ }
399
+
400
+ const record: JobRecord = {
401
+ id,
402
+ pid: childPid,
403
+ cmd: command,
404
+ name,
405
+ started: Date.now(),
406
+ logPath,
407
+ child,
408
+ ctx,
409
+ };
410
+ jobs.set(id, record);
411
+
412
+ // Persist a bgrun-job entry (running state) — transcript card + restart recovery.
413
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
414
+ id,
415
+ pid: childPid,
416
+ cmd: command,
417
+ name,
418
+ started: Date.now(),
419
+ logPath,
420
+ state: "running",
421
+ });
422
+
423
+ closeSync(logFd);
424
+
425
+ updateWidget(ctx);
426
+
427
+ // ── exit handler: record exit, persist done entry, wake, notify, widget ─
428
+ child.on("exit", (code, signal) => {
429
+ const rec = jobs.get(id);
430
+ if (!rec) return;
431
+ rec.exitedAt = Date.now();
432
+ rec.exitCode = code ?? -1;
433
+ delete rec.child; // release the handle reference
434
+
435
+ const exitCode = code ?? parseExitFromLog(logPath) ?? -1;
436
+ const exitStr = exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
437
+ const exitEmoji = exitCode === 0 ? "✅" : "❌";
438
+ const lastLine = readLastLogLine(logPath);
439
+
440
+ // Persist the done-state entry.
441
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
442
+ id,
443
+ pid: rec.pid,
444
+ cmd: rec.cmd,
445
+ name: rec.name,
446
+ started: rec.started,
447
+ logPath,
448
+ state: "done",
449
+ exitCode: exitCode >= 0 ? exitCode : undefined,
450
+ exitedAt: rec.exitedAt,
451
+ });
452
+
453
+ // Wake the agent.
454
+ const namePrefix = rec.name ? `"${rec.name}" ` : "";
455
+ let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`;
456
+ wake += `Command: ${command}\n`;
457
+ if (lastLine) wake += `Last output: ${lastLine}\n`;
458
+ wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`;
459
+ try {
460
+ if (rec.ctx.isIdle()) {
461
+ pi.sendUserMessage(wake);
462
+ } else {
463
+ pi.sendUserMessage(wake, { deliverAs: "followUp" });
464
+ }
465
+ } catch {
466
+ try {
467
+ pi.sendUserMessage(wake, { deliverAs: "followUp" });
468
+ } catch (e2) {
469
+ console.error(`[pi-bgrun] wake failed for job ${id}:`, (e2 as Error).message);
470
+ }
471
+ }
472
+
473
+ // Toast for the human.
474
+ if (rec.ctx.hasUI) {
475
+ const toastLabel = (rec.name ?? command).slice(0, 50);
476
+ rec.ctx.ui.notify(`${exitEmoji} ${toastLabel} → exit ${exitStr}`, exitCode === 0 ? "info" : "error");
477
+ }
478
+
479
+ // Update/clear the widget.
480
+ updateWidget(rec.ctx);
481
+ });
482
+
483
+ child.on("error", (err) => {
484
+ console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message);
485
+ jobs.delete(id);
486
+ updateWidget(ctx);
487
+ });
488
+
489
+ const startedLines = [`started: ${id}`];
490
+ if (name) startedLines.push(` name: ${name}`);
491
+ startedLines.push(` log: ${logPath}`, ` You'll be woken automatically when it finishes.`);
492
+ return {
493
+ content: [{ type: "text", text: startedLines.join("\n") }],
494
+ details: { id, name, logPath, pid: childPid },
495
+ };
496
+ },
497
+ });
498
+
499
+ // ── bgtail: read last N lines of a job's log, stripping the exit marker ────
500
+
501
+ pi.registerTool({
502
+ name: "bgtail",
503
+ label: "Tail Background Log",
504
+ description:
505
+ "Print the last N lines of a background job's log (default 40). Strips the exit-marker line. " +
506
+ "Use this for a quick peek at results; use ctx_execute_file on the log path for whole-log failure analysis.",
507
+ promptSnippet: "Read the last N lines of a bgrun job's log",
508
+ parameters: Type.Object({
509
+ id: Type.String({ description: "Job id (from bgrun's 'started: <id>' response)" }),
510
+ lines: Type.Optional(Type.Number({ description: "Number of lines to show (default 40)" })),
511
+ }),
512
+ async execute(_toolCallId, params) {
513
+ const { id, lines = 40 } = params;
514
+ if (!id) throw new Error("bgtail: id is required");
515
+ const logPath = join(jobsDir, `${id}.log`);
516
+ try {
517
+ const content = readFileSync(logPath, "utf8");
518
+ const all = content.split("\n").filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
519
+ const tail = all.slice(-lines);
520
+ return {
521
+ content: [{ type: "text", text: tail.join("\n") || "(empty log)" }],
522
+ details: { id, linesShown: tail.length, logPath, notFound: false },
523
+ };
524
+ } catch {
525
+ return {
526
+ content: [{ type: "text", text: `No log found for job ${id} at ${logPath}` }],
527
+ details: { id, linesShown: 0, logPath, notFound: true },
528
+ isError: true,
529
+ };
530
+ }
531
+ },
532
+ });
533
+
534
+ // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
535
+
536
+ pi.registerTool({
537
+ name: "bgstatus",
538
+ label: "Background Job Status",
539
+ description:
540
+ "Show status of background jobs. With an id: one job's state + exit code. Without: list all known jobs.",
541
+ promptSnippet: "Check status of bgrun jobs",
542
+ parameters: Type.Object({
543
+ id: Type.Optional(Type.String({ description: "Optional job id to inspect" })),
544
+ }),
545
+ async execute(
546
+ _toolCallId,
547
+ params,
548
+ ): Promise<{ content: { type: "text"; text: string }[]; details: BgStatusDetails; isError?: boolean }> {
549
+ const { id } = params;
550
+ if (id) {
551
+ const rec = jobs.get(id);
552
+ if (rec) {
553
+ const state = rec.exitCode !== undefined ? "done" : "running";
554
+ const exit = rec.exitCode !== undefined ? ` exit=${rec.exitCode}` : "";
555
+ const lines = [`${id}: ${state}${exit}`];
556
+ if (rec.name) lines.push(` name: ${rec.name}`);
557
+ lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
558
+ return {
559
+ content: [{ type: "text", text: lines.join("\n") }],
560
+ details: { id, state, exitCode: rec.exitCode ?? undefined, cmd: rec.cmd, name: rec.name, recovered: false },
561
+ };
562
+ }
563
+ const logPath = join(jobsDir, `${id}.log`);
564
+ try {
565
+ const exit = parseExitFromLog(logPath);
566
+ const state = exit !== null ? "done" : "running";
567
+ return {
568
+ content: [
569
+ { type: "text", text: `${id}: ${state}${exit !== null ? ` exit=${exit}` : ""} (recovered from log)\n log: ${logPath}` },
570
+ ],
571
+ details: { id, state, exitCode: exit ?? undefined, recovered: true },
572
+ };
573
+ } catch {
574
+ return {
575
+ content: [{ type: "text", text: `No job found with id ${id}` }],
576
+ details: { id, state: "unknown" },
577
+ isError: true,
578
+ };
579
+ }
580
+ }
581
+ // List all: merge in-memory records with a directory scan of log files.
582
+ const lines: string[] = [];
583
+ const seen = new Set<string>();
584
+ for (const [jid, rec] of jobs) {
585
+ seen.add(jid);
586
+ const state = rec.exitCode !== undefined ? "done" : "running";
587
+ const exit = rec.exitCode !== undefined ? ` exit=${rec.exitCode}` : "";
588
+ const label = rec.name ? `${jid} — ${rec.name}` : jid;
589
+ lines.push(` ${label}: ${state}${exit}`);
590
+ }
591
+ try {
592
+ for (const name of readdirSync(jobsDir)) {
593
+ if (!name.endsWith(".log")) continue;
594
+ const jid = name.slice(0, -".log".length);
595
+ if (seen.has(jid)) continue;
596
+ const logPath = join(jobsDir, name);
597
+ const exit = parseExitFromLog(logPath);
598
+ const state = exit !== null ? "done" : "running";
599
+ lines.push(` ${jid}: ${state}${exit !== null ? ` exit=${exit}` : ""} (from log)`);
600
+ }
601
+ } catch {
602
+ // jobs dir doesn't exist — nothing to scan.
603
+ }
604
+ if (lines.length === 0) {
605
+ return {
606
+ content: [{ type: "text", text: "(no bgrun jobs)" }],
607
+ details: { count: 0 },
608
+ };
609
+ }
610
+ return {
611
+ content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
612
+ details: { count: lines.length },
613
+ };
614
+ },
615
+ });
616
+
617
+ // ── bgclean: remove old job logs ───────────────────────────────────────────
618
+
619
+ pi.registerTool({
620
+ name: "bgclean",
621
+ label: "Clean Old Background Jobs",
622
+ description:
623
+ "Remove old background job logs from disk. Default: 7 days. Never removes a running job's log. " +
624
+ "Prints a summary of what was removed vs kept.",
625
+ promptSnippet: "Remove old bgrun job logs",
626
+ parameters: Type.Object({
627
+ days: Type.Optional(
628
+ Type.Number({ description: "Remove logs older than this many days (default 7)" }),
629
+ ),
630
+ }),
631
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
632
+ const { days = DEFAULT_CLEANUP_DAYS } = params;
633
+ if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
634
+ throw new Error(`bgclean: days must be a non-negative number, got ${days}`);
635
+ }
636
+ const result = cleanOldJobs(days, ctx);
637
+ const summary = `removed ${result.removed} job log(s), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
638
+ return {
639
+ content: [{ type: "text", text: summary }],
640
+ details: result,
641
+ };
642
+ },
643
+ });
644
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-background-run",
3
+ "version": "0.1.0",
4
+ "description": "Run long shell commands detached in the background for pi; get woken on completion. Output lands in a file; context stays clean.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": "./extension/index.ts"
12
+ },
13
+ "pi": {
14
+ "extensions": ["./extension/index.ts"],
15
+ "skills": ["./skill/run-bg"]
16
+ },
17
+ "scripts": {
18
+ "test": "bun test extension/index.test.ts",
19
+ "lint": "tsc --noEmit"
20
+ },
21
+ "files": [
22
+ "extension/",
23
+ "skill/",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/stablekernel/pi-background-run.git"
30
+ },
31
+ "homepage": "https://github.com/stablekernel/pi-background-run#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/stablekernel/pi-background-run/issues"
34
+ },
35
+ "keywords": [
36
+ "pi",
37
+ "pi-coding-agent",
38
+ "background",
39
+ "bgrun",
40
+ "ai",
41
+ "agent",
42
+ "extension"
43
+ ],
44
+ "author": "stablekernel",
45
+ "dependencies": {},
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
+ "typebox": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-coding-agent": "*",
53
+ "@earendil-works/pi-tui": "*",
54
+ "typebox": "^1.3.0",
55
+ "typescript": "^5.7.0"
56
+ }
57
+ }