@stablekernel/pi-background-run 0.1.0 → 0.2.1

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.
@@ -20,9 +20,16 @@
20
20
  * the Map from bgrun-job entries.
21
21
  * 3. Filesystem scan (cross-session, cross-restart, cross-worktree) — the jobs
22
22
  * dir is the permanent truth: filename→pid, log→exit code, kill -0→liveness.
23
+ * Surfaced via bgstatus by id (always) or includeDone (explicit); other
24
+ * sessions' RUNNING jobs are adopted into the live widget only when
25
+ * adoptForeignJobs is enabled.
23
26
  */
24
27
 
25
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
+ import {
29
+ CONFIG_DIR_NAME,
30
+ type ExtensionAPI,
31
+ type ExtensionContext,
32
+ } from "@earendil-works/pi-coding-agent";
26
33
  import { Type } from "typebox";
27
34
  import { Box, Text } from "@earendil-works/pi-tui";
28
35
  import { spawn } from "node:child_process";
@@ -35,6 +42,7 @@ import {
35
42
  renameSync,
36
43
  unlinkSync,
37
44
  statSync,
45
+ writeFileSync,
38
46
  } from "node:fs";
39
47
  import { join } from "node:path";
40
48
  import { homedir } from "node:os";
@@ -44,8 +52,110 @@ import { homedir } from "node:os";
44
52
  // the command fails. Never use `set -e` in the wrapper.
45
53
  const EXIT_MARKER = "__BGRUN_EXIT__=";
46
54
 
47
- const AUTO_CLEANUP_DAYS = 14;
48
55
  const DEFAULT_CLEANUP_DAYS = 7;
56
+ const ADOPTED_POLL_MS = 30_000; // re-check interval for adopted (foreign) jobs
57
+
58
+ // ── Configuration ───────────────────────────────────────────────────────────
59
+ //
60
+ // Layered: defaults ← user config file ← project config file (trusted projects
61
+ // only) ← environment variables. Pi passes no first-class per-extension config
62
+ // through the ExtensionAPI, so this follows the documented pattern: the
63
+ // extension reads its own JSON config from ~/.pi/agent/pi-bgrun.json (user) and
64
+ // <cwd>/<CONFIG_DIR_NAME>/pi-bgrun.json (project, honored only when the project
65
+ // is trusted), with PI_BGRUN_* env vars as overrides.
66
+
67
+ interface BgrunConfig {
68
+ jobsDir: string;
69
+ // Adopt other sessions' running jobs (found in the shared jobs dir) into
70
+ // this session's widget and job list. Default false — most sessions don't
71
+ // want unrelated jobs from other projects cluttering the widget.
72
+ adoptForeignJobs: boolean;
73
+ // Include finished jobs in bgstatus listings by default. Default false —
74
+ // completed jobs are noise; ask for them explicitly (bgstatus includeDone).
75
+ showCompletedJobs: boolean;
76
+ // Log retention for auto-clean sweeps and the bgclean default. Also the
77
+ // throttle interval for auto-clean (at most one sweep per cleanupDays).
78
+ cleanupDays: number;
79
+ }
80
+
81
+ interface BgrunConfigFile {
82
+ jobsDir?: unknown;
83
+ adoptForeignJobs?: unknown;
84
+ showCompletedJobs?: unknown;
85
+ cleanupDays?: unknown;
86
+ }
87
+
88
+ function parseBoolEnv(v: string | undefined): boolean | undefined {
89
+ if (v === undefined) return undefined;
90
+ const t = v.trim().toLowerCase();
91
+ if (["1", "true", "yes", "on"].includes(t)) return true;
92
+ if (["0", "false", "no", "off"].includes(t)) return false;
93
+ return undefined;
94
+ }
95
+
96
+ function readConfigFile(path: string): BgrunConfigFile {
97
+ try {
98
+ const raw = JSON.parse(readFileSync(path, "utf8"));
99
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
100
+ return raw as BgrunConfigFile;
101
+ } catch {
102
+ // missing or malformed — treat as empty
103
+ }
104
+ return {};
105
+ }
106
+
107
+ // Resolved per call (cheap: at most two small file reads) so env/config
108
+ // changes are picked up without module reloads — and tests can isolate.
109
+ function resolveConfig(ctx?: {
110
+ cwd?: string;
111
+ isProjectTrusted?: () => boolean;
112
+ }): BgrunConfig {
113
+ const user = readConfigFile(join(homedir(), ".pi", "agent", "pi-bgrun.json"));
114
+ let project: BgrunConfigFile = {};
115
+ try {
116
+ if (ctx?.isProjectTrusted?.()) {
117
+ project = readConfigFile(
118
+ join(ctx.cwd ?? process.cwd(), CONFIG_DIR_NAME, "pi-bgrun.json"),
119
+ );
120
+ }
121
+ } catch {
122
+ // unreadable project config — ignore
123
+ }
124
+ const merged: BgrunConfigFile = { ...user, ...project };
125
+ const foreignFile =
126
+ typeof merged.adoptForeignJobs === "boolean"
127
+ ? merged.adoptForeignJobs
128
+ : undefined;
129
+ const completedFile =
130
+ typeof merged.showCompletedJobs === "boolean"
131
+ ? merged.showCompletedJobs
132
+ : undefined;
133
+ const dirFile =
134
+ typeof merged.jobsDir === "string" && merged.jobsDir
135
+ ? merged.jobsDir
136
+ : undefined;
137
+ const daysFile =
138
+ typeof merged.cleanupDays === "number" &&
139
+ Number.isFinite(merged.cleanupDays) &&
140
+ merged.cleanupDays > 0
141
+ ? merged.cleanupDays
142
+ : undefined;
143
+ const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
144
+ const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined;
145
+ return {
146
+ jobsDir:
147
+ process.env.PI_BGRUN_DIR ||
148
+ dirFile ||
149
+ join(homedir(), ".pi-bgrun", "jobs"),
150
+ adoptForeignJobs:
151
+ parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false,
152
+ showCompletedJobs:
153
+ parseBoolEnv(process.env.PI_BGRUN_SHOW_COMPLETED) ??
154
+ completedFile ??
155
+ false,
156
+ cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
157
+ };
158
+ }
49
159
 
50
160
  interface JobRecord {
51
161
  id: string;
@@ -96,13 +206,21 @@ function isRunningPid(pid: number): boolean {
96
206
 
97
207
  export default function (pi: ExtensionAPI) {
98
208
  const jobs = new Map<string, JobRecord>();
99
- const jobsDir = process.env.PI_BGRUN_DIR || join(homedir(), ".pi-bgrun", "jobs");
209
+ // Poller for adopted (foreign) jobs — they have no ChildProcess handle, so
210
+ // no exit event; their logs/pids are re-checked on an interval instead.
211
+ let adoptedPoller: ReturnType<typeof setInterval> | undefined;
100
212
 
101
213
  // ── Helpers ───────────────────────────────────────────────────────────────
102
214
 
103
215
  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);
216
+ const raw = command
217
+ .toLowerCase()
218
+ .replace(/[/\\.-]+/g, " ")
219
+ .trim();
220
+ const slug = raw
221
+ .replace(/[^a-z0-9]+/g, "-")
222
+ .replace(/^-+|-+$/g, "")
223
+ .slice(0, 60);
106
224
  return slug || "job";
107
225
  }
108
226
 
@@ -129,7 +247,9 @@ export default function (pi: ExtensionAPI) {
129
247
  function parseExitFromLog(logPath: string): number | null {
130
248
  try {
131
249
  const content = readFileSync(logPath, "utf8");
132
- const lines = content.split("\n").filter((l) => l.startsWith(EXIT_MARKER));
250
+ const lines = content
251
+ .split("\n")
252
+ .filter((l) => l.startsWith(EXIT_MARKER));
133
253
  if (lines.length === 0) return null;
134
254
  const match = lines[lines.length - 1].match(/^__BGRUN_EXIT__=(\d+)/);
135
255
  return match ? parseInt(match[1], 10) : null;
@@ -149,6 +269,7 @@ export default function (pi: ExtensionAPI) {
149
269
 
150
270
  function updateWidget(ctx: ExtensionContext): void {
151
271
  if (!ctx.hasUI) return;
272
+ revalidateAdoptedJobs();
152
273
  const running: JobRecord[] = [];
153
274
  for (const rec of jobs.values()) {
154
275
  if (rec.exitCode === undefined) running.push(rec);
@@ -159,23 +280,26 @@ export default function (pi: ExtensionAPI) {
159
280
  }
160
281
  const lines = [`📊 bgrun: ${running.length} running`];
161
282
  for (const rec of running) {
162
- const startedAt = new Date(rec.started).toLocaleTimeString([], { hour12: false });
283
+ const startedAt = new Date(rec.started).toLocaleTimeString([], {
284
+ hour12: false,
285
+ });
163
286
  const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
164
287
  const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
165
288
  const tag = rec.adopted ? " (adopted)" : "";
166
- lines.push(` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`);
289
+ lines.push(
290
+ ` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`,
291
+ );
167
292
  }
168
293
  ctx.ui.setWidget("bgrun", lines);
169
294
  }
170
295
 
171
- function clearWidget(ctx: ExtensionContext): void {
172
- if (!ctx.hasUI) return;
173
- ctx.ui.setWidget("bgrun", undefined);
174
- }
175
-
176
296
  // ── Cleanup ───────────────────────────────────────────────────────────────
177
297
 
178
- function cleanOldJobs(days: number, ctx?: ExtensionContext): { removed: number; kept: number; skippedRunning: number } {
298
+ function cleanOldJobs(
299
+ days: number,
300
+ jobsDir: string,
301
+ ctx?: ExtensionContext,
302
+ ): { removed: number; kept: number; skippedRunning: number } {
179
303
  const result = { removed: 0, kept: 0, skippedRunning: 0 };
180
304
  let entries: string[];
181
305
  try {
@@ -229,26 +353,132 @@ export default function (pi: ExtensionAPI) {
229
353
  return result;
230
354
  }
231
355
 
232
- // ── Entry renderer: job cards in the transcript ───────────────────────────
356
+ // Throttled auto-clean: runs at session_start/session_shutdown at most once
357
+ // per cleanupDays (tracked via a .last-clean marker in the jobs dir). Manual
358
+ // bgclean always runs and refreshes the marker. This is the "7-day timer" —
359
+ // any session boundary after the interval fires the sweep, so long-lived
360
+ // sessions and restart-heavy workflows both stay covered without cleaning on
361
+ // every bgrun call.
362
+ function autoCleanJobs(ctx: ExtensionContext): void {
363
+ const cfg = resolveConfig(ctx);
364
+ const markerPath = join(cfg.jobsDir, ".last-clean");
365
+ try {
366
+ const last = Number(readFileSync(markerPath, "utf8").trim());
367
+ if (
368
+ Number.isFinite(last) &&
369
+ Date.now() - last < cfg.cleanupDays * 24 * 60 * 60 * 1000
370
+ )
371
+ return;
372
+ } catch {
373
+ // no marker yet — run the sweep
374
+ }
375
+ cleanOldJobs(cfg.cleanupDays, cfg.jobsDir, ctx);
376
+ try {
377
+ mkdirSync(cfg.jobsDir, { recursive: true });
378
+ writeFileSync(markerPath, String(Date.now()));
379
+ } catch {
380
+ // best-effort
381
+ }
382
+ }
233
383
 
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));
384
+ // Re-check adopted (foreign) jobs: they have no exit event, so the exit
385
+ // marker in the log (or a dead pid) is the only completion signal. Without
386
+ // this, adopted jobs render as "running" forever even after they finish.
387
+ // Finished adopted jobs are dropped from the in-memory registry entirely
388
+ // they aren't this session's history; the log stays on disk (id lookup,
389
+ // disk note, and cleanup all still cover it). Called from the adopted poller
390
+ // and before rendering the widget / listing jobs.
391
+ function revalidateAdoptedJobs(): void {
392
+ for (const [id, rec] of jobs) {
393
+ if (!rec.adopted || rec.exitCode !== undefined) continue;
394
+ let exit = parseExitFromLog(rec.logPath);
395
+ if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
396
+ // pid gone with no marker — killed/crashed before the wrapper could write it
397
+ exit = -1;
248
398
  }
399
+ if (exit !== null) jobs.delete(id);
249
400
  }
250
- return box;
251
- });
401
+ }
402
+
403
+ function hasAdoptedRunning(): boolean {
404
+ for (const rec of jobs.values()) {
405
+ if (rec.adopted && rec.exitCode === undefined) return true;
406
+ }
407
+ return false;
408
+ }
409
+
410
+ function ensureAdoptedPoller(ctx: ExtensionContext): void {
411
+ if (adoptedPoller !== undefined || !hasAdoptedRunning()) return;
412
+ adoptedPoller = setInterval(() => {
413
+ revalidateAdoptedJobs();
414
+ updateWidget(ctx);
415
+ if (!hasAdoptedRunning()) stopAdoptedPoller();
416
+ }, ADOPTED_POLL_MS);
417
+ adoptedPoller.unref();
418
+ }
419
+
420
+ function stopAdoptedPoller(): void {
421
+ if (adoptedPoller !== undefined) {
422
+ clearInterval(adoptedPoller);
423
+ adoptedPoller = undefined;
424
+ }
425
+ }
426
+
427
+ // ── Entry renderer: job cards in the transcript ───────────────────────────
428
+
429
+ pi.registerEntryRenderer<BgrunJobEntryData>(
430
+ "bgrun-job",
431
+ (entry, { expanded }, theme) => {
432
+ const d =
433
+ entry.data ??
434
+ ({
435
+ id: "?",
436
+ cmd: "",
437
+ started: 0,
438
+ logPath: "",
439
+ state: "running",
440
+ } as BgrunJobEntryData);
441
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
442
+ const icon = d.state === "done" ? (d.exitCode === 0 ? "✅" : "❌") : "🔄";
443
+ const exitStr = d.state === "done" ? ` exit=${d.exitCode ?? "?"}` : "";
444
+ const namePrefix = d.name ? `"${d.name}" ` : "";
445
+ box.addChild(
446
+ new Text(
447
+ `${icon} ${theme.fg("accent", "bgrun")} ${namePrefix}${d.id}${exitStr}`,
448
+ 0,
449
+ 0,
450
+ ),
451
+ );
452
+ const cmdPreview = d.cmd.length > 60 ? d.cmd.slice(0, 57) + "…" : d.cmd;
453
+ box.addChild(new Text(theme.fg("dim", ` $ ${cmdPreview}`), 0, 0));
454
+ if (expanded) {
455
+ box.addChild(new Text(theme.fg("dim", ` log: ${d.logPath}`), 0, 0));
456
+ box.addChild(
457
+ new Text(
458
+ theme.fg(
459
+ "dim",
460
+ ` started: ${new Date(d.started).toLocaleString()}`,
461
+ ),
462
+ 0,
463
+ 0,
464
+ ),
465
+ );
466
+ if (d.exitedAt) {
467
+ box.addChild(
468
+ new Text(
469
+ theme.fg(
470
+ "dim",
471
+ ` finished: ${new Date(d.exitedAt).toLocaleString()}`,
472
+ ),
473
+ 0,
474
+ 0,
475
+ ),
476
+ );
477
+ }
478
+ }
479
+ return box;
480
+ },
481
+ );
252
482
 
253
483
  // ── session_start: reconstruct Map from entries + auto-cleanup ────────────
254
484
 
@@ -283,52 +513,69 @@ export default function (pi: ExtensionAPI) {
283
513
  });
284
514
  }
285
515
  } catch (err) {
286
- console.error("[pi-bgrun] session_start reconstruction failed:", (err as Error).message);
516
+ console.error(
517
+ "[pi-bgrun] session_start reconstruction failed:",
518
+ (err as Error).message,
519
+ );
287
520
  }
288
521
 
289
522
  // 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
523
+ // Opt-in (adoptForeignJobs / PI_BGRUN_FOREIGN_JOBS=1): the jobs dir is shared
524
+ // across every pi session on the machine, and most sessions don't want
525
+ // unrelated jobs from other projects cluttering the widget. Adopted jobs
526
+ // have no ChildProcess handle — no exit event, so a poller re-checks their
527
+ // logs and pids instead, and they leave the widget once finished.
528
+ const cfg = resolveConfig(ctx);
529
+ const jobsDir = cfg.jobsDir;
530
+ if (cfg.adoptForeignJobs) {
531
+ try {
532
+ for (const name of readdirSync(jobsDir)) {
533
+ if (!name.endsWith(".log")) continue;
534
+ const id = name.slice(0, -".log".length);
535
+ if (jobs.has(id)) continue;
536
+ const logPath = join(jobsDir, name);
537
+ const exit = parseExitFromLog(logPath);
538
+ if (exit !== null) continue; // finished — nothing to show in the widget
539
+ const pid = pidFromId(id);
540
+ if (pid === null || pid <= 0 || !isRunningPid(pid)) continue; // dead pid, marker just not written yet
541
+ let started = Date.now();
542
+ try {
543
+ started = statSync(logPath).birthtimeMs;
544
+ } catch {
545
+ // keep fallback
546
+ }
547
+ jobs.set(id, {
548
+ id,
549
+ pid,
550
+ cmd: "(started by another session)",
551
+ started,
552
+ logPath,
553
+ ctx,
554
+ adopted: true,
555
+ });
307
556
  }
308
- jobs.set(id, {
309
- id,
310
- pid,
311
- cmd: "(started by another session)",
312
- started,
313
- logPath,
314
- ctx,
315
- adopted: true,
316
- });
557
+ } catch {
558
+ // jobs dir doesn't exist — nothing to adopt.
317
559
  }
318
- } catch {
319
- // jobs dir doesn't exist — nothing to adopt.
560
+ ensureAdoptedPoller(ctx);
320
561
  }
321
562
 
322
563
  // Show the widget if anything is now running (covers adopted + reconstructed jobs).
323
564
  updateWidget(ctx);
324
- // Auto-cleanup of old logs (14-day default, fire-and-forget).
325
- cleanOldJobs(AUTO_CLEANUP_DAYS, ctx);
565
+ // Auto-cleanup of old logs, throttled to one sweep per cleanupDays via a
566
+ // marker in the jobs dir (see autoCleanJobs). Also runs on session_shutdown.
567
+ autoCleanJobs(ctx);
326
568
  });
327
569
 
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.
570
+ pi.on("session_shutdown", async (_event, ctx) => {
571
+ stopAdoptedPoller();
572
+ // Sweep old logs on the way out. Throttled via the .last-clean marker so
573
+ // restart-heavy workflows don't sweep more than once per cleanupDays.
574
+ try {
575
+ autoCleanJobs(ctx);
576
+ } catch {
577
+ // best-effort — shutdown must never throw
578
+ }
332
579
  });
333
580
 
334
581
  // ── bgrun tool ────────────────────────────────────────────────────────────
@@ -341,7 +588,8 @@ export default function (pi: ExtensionAPI) {
341
588
  "You will be woken automatically when the job finishes. Use this instead of bash for any command " +
342
589
  "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " +
343
590
  "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",
591
+ promptSnippet:
592
+ "Run a long command detached in the background; get woken on completion",
345
593
  promptGuidelines: [
346
594
  "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
347
595
  "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.",
@@ -350,7 +598,8 @@ export default function (pi: ExtensionAPI) {
350
598
  ],
351
599
  parameters: Type.Object({
352
600
  command: Type.String({
353
- description: "Shell command to run in the background. Run as `sh -c`, so pipes and && work.",
601
+ description:
602
+ "Shell command to run in the background. Run as `sh -c`, so pipes and && work.",
354
603
  }),
355
604
  name: Type.Optional(
356
605
  Type.String({
@@ -367,18 +616,24 @@ export default function (pi: ExtensionAPI) {
367
616
  }
368
617
  const name = sanitizeName(rawName);
369
618
 
619
+ const jobsDir = resolveConfig(ctx).jobsDir;
370
620
  mkdirSync(jobsDir, { recursive: true });
371
621
 
372
622
  const slug = makeSlug(name ?? command);
373
623
  const ts = Math.floor(Date.now() / 1000);
374
624
  // The id must carry the CHILD's pid (liveness checks depend on it), but the
375
625
  // 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`);
626
+ const tmpPath = join(
627
+ jobsDir,
628
+ `.tmp-${slug}-${ts}-${Math.random().toString(36).slice(2, 8)}.log`,
629
+ );
377
630
  let logFd: number;
378
631
  try {
379
632
  logFd = openSync(tmpPath, "w");
380
633
  } catch (err) {
381
- throw new Error(`bgrun: cannot create log file: ${(err as Error).message}`);
634
+ throw new Error(
635
+ `bgrun: cannot create log file: ${(err as Error).message}`,
636
+ );
382
637
  }
383
638
  const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`;
384
639
 
@@ -394,7 +649,10 @@ export default function (pi: ExtensionAPI) {
394
649
  try {
395
650
  renameSync(tmpPath, logPath);
396
651
  } catch (err) {
397
- console.error(`[pi-bgrun] rename to final log path failed:`, (err as Error).message);
652
+ console.error(
653
+ `[pi-bgrun] rename to final log path failed:`,
654
+ (err as Error).message,
655
+ );
398
656
  }
399
657
 
400
658
  const record: JobRecord = {
@@ -433,7 +691,8 @@ export default function (pi: ExtensionAPI) {
433
691
  delete rec.child; // release the handle reference
434
692
 
435
693
  const exitCode = code ?? parseExitFromLog(logPath) ?? -1;
436
- const exitStr = exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
694
+ const exitStr =
695
+ exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
437
696
  const exitEmoji = exitCode === 0 ? "✅" : "❌";
438
697
  const lastLine = readLastLogLine(logPath);
439
698
 
@@ -466,14 +725,20 @@ export default function (pi: ExtensionAPI) {
466
725
  try {
467
726
  pi.sendUserMessage(wake, { deliverAs: "followUp" });
468
727
  } catch (e2) {
469
- console.error(`[pi-bgrun] wake failed for job ${id}:`, (e2 as Error).message);
728
+ console.error(
729
+ `[pi-bgrun] wake failed for job ${id}:`,
730
+ (e2 as Error).message,
731
+ );
470
732
  }
471
733
  }
472
734
 
473
735
  // Toast for the human.
474
736
  if (rec.ctx.hasUI) {
475
737
  const toastLabel = (rec.name ?? command).slice(0, 50);
476
- rec.ctx.ui.notify(`${exitEmoji} ${toastLabel} → exit ${exitStr}`, exitCode === 0 ? "info" : "error");
738
+ rec.ctx.ui.notify(
739
+ `${exitEmoji} ${toastLabel} → exit ${exitStr}`,
740
+ exitCode === 0 ? "info" : "error",
741
+ );
477
742
  }
478
743
 
479
744
  // Update/clear the widget.
@@ -488,7 +753,10 @@ export default function (pi: ExtensionAPI) {
488
753
 
489
754
  const startedLines = [`started: ${id}`];
490
755
  if (name) startedLines.push(` name: ${name}`);
491
- startedLines.push(` log: ${logPath}`, ` You'll be woken automatically when it finishes.`);
756
+ startedLines.push(
757
+ ` log: ${logPath}`,
758
+ ` You'll be woken automatically when it finishes.`,
759
+ );
492
760
  return {
493
761
  content: [{ type: "text", text: startedLines.join("\n") }],
494
762
  details: { id, name, logPath, pid: childPid },
@@ -506,16 +774,22 @@ export default function (pi: ExtensionAPI) {
506
774
  "Use this for a quick peek at results; use ctx_execute_file on the log path for whole-log failure analysis.",
507
775
  promptSnippet: "Read the last N lines of a bgrun job's log",
508
776
  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)" })),
777
+ id: Type.String({
778
+ description: "Job id (from bgrun's 'started: <id>' response)",
779
+ }),
780
+ lines: Type.Optional(
781
+ Type.Number({ description: "Number of lines to show (default 40)" }),
782
+ ),
511
783
  }),
512
- async execute(_toolCallId, params) {
784
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
513
785
  const { id, lines = 40 } = params;
514
786
  if (!id) throw new Error("bgtail: id is required");
515
- const logPath = join(jobsDir, `${id}.log`);
787
+ const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
516
788
  try {
517
789
  const content = readFileSync(logPath, "utf8");
518
- const all = content.split("\n").filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
790
+ const all = content
791
+ .split("\n")
792
+ .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
519
793
  const tail = all.slice(-lines);
520
794
  return {
521
795
  content: [{ type: "text", text: tail.join("\n") || "(empty log)" }],
@@ -523,7 +797,9 @@ export default function (pi: ExtensionAPI) {
523
797
  };
524
798
  } catch {
525
799
  return {
526
- content: [{ type: "text", text: `No log found for job ${id} at ${logPath}` }],
800
+ content: [
801
+ { type: "text", text: `No log found for job ${id} at ${logPath}` },
802
+ ],
527
803
  details: { id, linesShown: 0, logPath, notFound: true },
528
804
  isError: true,
529
805
  };
@@ -537,38 +813,73 @@ export default function (pi: ExtensionAPI) {
537
813
  name: "bgstatus",
538
814
  label: "Background Job Status",
539
815
  description:
540
- "Show status of background jobs. With an id: one job's state + exit code. Without: list all known jobs.",
816
+ "Show status of background jobs. With an id: one job's state + exit code. Without: list this session's " +
817
+ "running jobs (finished jobs are hidden by default — pass includeDone or set showCompletedJobs to list " +
818
+ "them; other sessions' jobs are only listed when adoptForeignJobs is enabled).",
541
819
  promptSnippet: "Check status of bgrun jobs",
542
820
  parameters: Type.Object({
543
- id: Type.Optional(Type.String({ description: "Optional job id to inspect" })),
821
+ id: Type.Optional(
822
+ Type.String({ description: "Optional job id to inspect" }),
823
+ ),
824
+ includeDone: Type.Optional(
825
+ Type.Boolean({
826
+ description:
827
+ "Include finished jobs (and other logs on disk) in the listing",
828
+ }),
829
+ ),
544
830
  }),
545
831
  async execute(
546
832
  _toolCallId,
547
833
  params,
548
- ): Promise<{ content: { type: "text"; text: string }[]; details: BgStatusDetails; isError?: boolean }> {
834
+ _signal,
835
+ _onUpdate,
836
+ ctx,
837
+ ): Promise<{
838
+ content: { type: "text"; text: string }[];
839
+ details: BgStatusDetails;
840
+ isError?: boolean;
841
+ }> {
549
842
  const { id } = params;
843
+ const cfg = resolveConfig(ctx);
844
+ const jobsDir = cfg.jobsDir;
550
845
  if (id) {
551
846
  const rec = jobs.get(id);
552
847
  if (rec) {
553
- const state = rec.exitCode !== undefined ? "done" : "running";
554
- const exit = rec.exitCode !== undefined ? ` exit=${rec.exitCode}` : "";
848
+ const state = rec.exitCode === undefined ? "running" : "done";
849
+ const exit =
850
+ rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
555
851
  const lines = [`${id}: ${state}${exit}`];
556
852
  if (rec.name) lines.push(` name: ${rec.name}`);
557
853
  lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
558
854
  return {
559
855
  content: [{ type: "text", text: lines.join("\n") }],
560
- details: { id, state, exitCode: rec.exitCode ?? undefined, cmd: rec.cmd, name: rec.name, recovered: false },
856
+ details: {
857
+ id,
858
+ state,
859
+ exitCode: rec.exitCode ?? undefined,
860
+ cmd: rec.cmd,
861
+ name: rec.name,
862
+ recovered: false,
863
+ },
561
864
  };
562
865
  }
563
866
  const logPath = join(jobsDir, `${id}.log`);
564
867
  try {
565
868
  const exit = parseExitFromLog(logPath);
566
- const state = exit !== null ? "done" : "running";
869
+ const state = exit === null ? "running" : "done";
567
870
  return {
568
871
  content: [
569
- { type: "text", text: `${id}: ${state}${exit !== null ? ` exit=${exit}` : ""} (recovered from log)\n log: ${logPath}` },
872
+ {
873
+ type: "text",
874
+ text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
875
+ },
570
876
  ],
571
- details: { id, state, exitCode: exit ?? undefined, recovered: true },
877
+ details: {
878
+ id,
879
+ state,
880
+ exitCode: exit ?? undefined,
881
+ recovered: true,
882
+ },
572
883
  };
573
884
  } catch {
574
885
  return {
@@ -578,16 +889,27 @@ export default function (pi: ExtensionAPI) {
578
889
  };
579
890
  }
580
891
  }
581
- // List all: merge in-memory records with a directory scan of log files.
892
+ // List: this session's jobs (running by default; finished only when
893
+ // includeDone / showCompletedJobs is set), plus — when opted in — other
894
+ // sessions' jobs from the shared jobs dir. Hidden disk logs get a
895
+ // one-line count instead of spamming the listing.
896
+ const showDone = params.includeDone ?? cfg.showCompletedJobs;
897
+ revalidateAdoptedJobs();
898
+ updateWidget(ctx);
582
899
  const lines: string[] = [];
583
900
  const seen = new Set<string>();
584
901
  for (const [jid, rec] of jobs) {
585
902
  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}`);
903
+ if (rec.exitCode === undefined || showDone) {
904
+ const state = rec.exitCode === undefined ? "running" : "done";
905
+ const exit =
906
+ rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
907
+ const label = rec.name ? `${jid} — ${rec.name}` : jid;
908
+ const from = rec.adopted ? " (adopted)" : "";
909
+ lines.push(` ${label}: ${state}${exit}${from}`);
910
+ }
590
911
  }
912
+ let hiddenOnDisk = 0;
591
913
  try {
592
914
  for (const name of readdirSync(jobsDir)) {
593
915
  if (!name.endsWith(".log")) continue;
@@ -595,12 +917,28 @@ export default function (pi: ExtensionAPI) {
595
917
  if (seen.has(jid)) continue;
596
918
  const logPath = join(jobsDir, name);
597
919
  const exit = parseExitFromLog(logPath);
598
- const state = exit !== null ? "done" : "running";
599
- lines.push(` ${jid}: ${state}${exit !== null ? ` exit=${exit}` : ""} (from log)`);
920
+ if (exit !== null) {
921
+ // finished log on disk (other or older session)
922
+ if (showDone) {
923
+ lines.push(` ${jid}: done exit=${exit} (from log)`);
924
+ } else {
925
+ hiddenOnDisk++;
926
+ }
927
+ } else if (cfg.adoptForeignJobs) {
928
+ // running foreign job — only surfaced when adoption is enabled
929
+ lines.push(` ${jid}: running (from log)`);
930
+ } else {
931
+ hiddenOnDisk++;
932
+ }
600
933
  }
601
934
  } catch {
602
935
  // jobs dir doesn't exist — nothing to scan.
603
936
  }
937
+ if (hiddenOnDisk > 0) {
938
+ lines.push(
939
+ ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean to prune)`,
940
+ );
941
+ }
604
942
  if (lines.length === 0) {
605
943
  return {
606
944
  content: [{ type: "text", text: "(no bgrun jobs)" }],
@@ -625,15 +963,28 @@ export default function (pi: ExtensionAPI) {
625
963
  promptSnippet: "Remove old bgrun job logs",
626
964
  parameters: Type.Object({
627
965
  days: Type.Optional(
628
- Type.Number({ description: "Remove logs older than this many days (default 7)" }),
966
+ Type.Number({
967
+ description: "Remove logs older than this many days (default 7)",
968
+ }),
629
969
  ),
630
970
  }),
631
971
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
632
- const { days = DEFAULT_CLEANUP_DAYS } = params;
972
+ const cfg = resolveConfig(ctx);
973
+ const { days = cfg.cleanupDays } = params;
633
974
  if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
634
- throw new Error(`bgclean: days must be a non-negative number, got ${days}`);
975
+ throw new Error(
976
+ `bgclean: days must be a non-negative number, got ${days}`,
977
+ );
978
+ }
979
+ const result = cleanOldJobs(days, cfg.jobsDir, ctx);
980
+ // Manual clean refreshes the throttle marker so the next auto-sweep
981
+ // doesn't immediately redo this work.
982
+ try {
983
+ mkdirSync(cfg.jobsDir, { recursive: true });
984
+ writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
985
+ } catch {
986
+ // best-effort
635
987
  }
636
- const result = cleanOldJobs(days, ctx);
637
988
  const summary = `removed ${result.removed} job log(s), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
638
989
  return {
639
990
  content: [{ type: "text", text: summary }],