pi-background-run 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,7 +53,7 @@ import { homedir } from "node:os";
53
53
  const EXIT_MARKER = "__BGRUN_EXIT__=";
54
54
 
55
55
  const DEFAULT_CLEANUP_DAYS = 7;
56
- const ADOPTED_POLL_MS = 30_000; // re-check interval for adopted (foreign) jobs
56
+ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle
57
57
 
58
58
  // ── Configuration ───────────────────────────────────────────────────────────
59
59
  //
@@ -73,9 +73,16 @@ interface BgrunConfig {
73
73
  // Include finished jobs in bgstatus listings by default. Default false —
74
74
  // completed jobs are noise; ask for them explicitly (bgstatus includeDone).
75
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).
76
+ // Log retention for cleanup (auto-sweeps and the bgclean default).
78
77
  cleanupDays: number;
78
+ // Auto-sweep the WHOLE shared jobs dir at session boundaries for orphans —
79
+ // finished (exit marker or dead pid) logs older than cleanupDays from
80
+ // sessions that crashed or are never resumed again. Running jobs are always
81
+ // pid-protected. Throttled to once per cleanupDays via a .last-clean marker.
82
+ // Default true — without it, orphaned logs accumulate forever. Set false to
83
+ // keep every sweep session-scoped (then only `bgclean all` touches foreign
84
+ // logs).
85
+ globalAutoClean: boolean;
79
86
  }
80
87
 
81
88
  interface BgrunConfigFile {
@@ -83,6 +90,7 @@ interface BgrunConfigFile {
83
90
  adoptForeignJobs?: unknown;
84
91
  showCompletedJobs?: unknown;
85
92
  cleanupDays?: unknown;
93
+ globalAutoClean?: unknown;
86
94
  }
87
95
 
88
96
  function parseBoolEnv(v: string | undefined): boolean | undefined {
@@ -130,6 +138,10 @@ function resolveConfig(ctx?: {
130
138
  typeof merged.showCompletedJobs === "boolean"
131
139
  ? merged.showCompletedJobs
132
140
  : undefined;
141
+ const globalCleanFile =
142
+ typeof merged.globalAutoClean === "boolean"
143
+ ? merged.globalAutoClean
144
+ : undefined;
133
145
  const dirFile =
134
146
  typeof merged.jobsDir === "string" && merged.jobsDir
135
147
  ? merged.jobsDir
@@ -154,9 +166,38 @@ function resolveConfig(ctx?: {
154
166
  completedFile ??
155
167
  false,
156
168
  cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
169
+ globalAutoClean:
170
+ parseBoolEnv(process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN) ??
171
+ globalCleanFile ??
172
+ true,
157
173
  };
158
174
  }
159
175
 
176
+ // Widget "since" formatting: time-only when the job started today; otherwise
177
+ // include the date (and the year too when it differs) — a job that has been
178
+ // running since a previous day shouldn't render as if it started today at
179
+ // that time. `now` is injectable for deterministic tests.
180
+ export function formatSince(started: number, now: number = Date.now()): string {
181
+ const d = new Date(started);
182
+ const n = new Date(now);
183
+ const time = d.toLocaleTimeString([], { hour12: false });
184
+ const sameDay =
185
+ d.getFullYear() === n.getFullYear() &&
186
+ d.getMonth() === n.getMonth() &&
187
+ d.getDate() === n.getDate();
188
+ if (sameDay) return time;
189
+ if (d.getFullYear() === n.getFullYear()) {
190
+ const md = d.toLocaleDateString([], { month: "short", day: "numeric" });
191
+ return `${md} ${time}`;
192
+ }
193
+ const ymd = d.toLocaleDateString([], {
194
+ year: "numeric",
195
+ month: "short",
196
+ day: "numeric",
197
+ });
198
+ return `${ymd} ${time}`;
199
+ }
200
+
160
201
  interface JobRecord {
161
202
  id: string;
162
203
  pid: number;
@@ -206,9 +247,11 @@ function isRunningPid(pid: number): boolean {
206
247
 
207
248
  export default function (pi: ExtensionAPI) {
208
249
  const jobs = new Map<string, JobRecord>();
209
- // Poller for adopted (foreign) jobsthey 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;
250
+ // Poller for stale job recordsanything running with no live ChildProcess
251
+ // handle (adopted foreign jobs + jobs reconstructed from transcript entries
252
+ // after a restart). No exit event exists for those, so their logs/pids are
253
+ // re-checked on an interval instead.
254
+ let stalePoller: ReturnType<typeof setInterval> | undefined;
212
255
 
213
256
  // ── Helpers ───────────────────────────────────────────────────────────────
214
257
 
@@ -269,7 +312,7 @@ export default function (pi: ExtensionAPI) {
269
312
 
270
313
  function updateWidget(ctx: ExtensionContext): void {
271
314
  if (!ctx.hasUI) return;
272
- revalidateAdoptedJobs();
315
+ revalidateStaleJobs();
273
316
  const running: JobRecord[] = [];
274
317
  for (const rec of jobs.values()) {
275
318
  if (rec.exitCode === undefined) running.push(rec);
@@ -280,9 +323,7 @@ export default function (pi: ExtensionAPI) {
280
323
  }
281
324
  const lines = [`📊 bgrun: ${running.length} running`];
282
325
  for (const rec of running) {
283
- const startedAt = new Date(rec.started).toLocaleTimeString([], {
284
- hour12: false,
285
- });
326
+ const startedAt = formatSince(rec.started);
286
327
  const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
287
328
  const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
288
329
  const tag = rec.adopted ? " (adopted)" : "";
@@ -353,14 +394,61 @@ export default function (pi: ExtensionAPI) {
353
394
  return result;
354
395
  }
355
396
 
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.
397
+ // Session-scoped sweep: remove THIS session's finished job logs older than
398
+ // `days`. Only looks at the in-memory Map (which, after reconstruction, is
399
+ // exactly this session's lineage) other sessions' logs are never touched.
400
+ // Running jobs are always skipped. Cheap (a handful of stats), so it runs
401
+ // unthrottled at session boundaries.
402
+ function cleanSessionJobs(
403
+ days: number,
404
+ ctx?: ExtensionContext,
405
+ ): { removed: number; kept: number; skippedRunning: number } {
406
+ const result = { removed: 0, kept: 0, skippedRunning: 0 };
407
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
408
+ for (const rec of jobs.values()) {
409
+ if (rec.exitCode === undefined) {
410
+ result.skippedRunning++;
411
+ continue;
412
+ }
413
+ let st;
414
+ try {
415
+ st = statSync(rec.logPath);
416
+ } catch {
417
+ continue; // already gone
418
+ }
419
+ if (st.mtimeMs > cutoff) {
420
+ result.kept++;
421
+ continue;
422
+ }
423
+ try {
424
+ unlinkSync(rec.logPath);
425
+ result.removed++;
426
+ } catch {
427
+ // ignore
428
+ }
429
+ }
430
+ if (result.removed > 0 && ctx?.hasUI) {
431
+ ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info");
432
+ }
433
+ return result;
434
+ }
435
+
436
+ // Auto-clean at session boundaries. Two parts:
437
+ // 1. Session-scoped sweep — this session's old logs only; cheap,
438
+ // unthrottled.
439
+ // 2. Global orphan sweep (default on; disable via globalAutoClean: false /
440
+ // PI_BGRUN_GLOBAL_AUTO_CLEAN=0) — the whole shared jobs dir, removing
441
+ // FINISHED logs (exit marker, or dead pid) older than cleanupDays. This
442
+ // is what keeps orphans from crashed / never-resumed sessions from
443
+ // accumulating: a week-old finished log is garbage under the same
444
+ // retention the owning session would apply itself, and running jobs are
445
+ // always pid-protected. Throttled to one sweep per cleanupDays via a
446
+ // .last-clean marker so restart-heavy workflows don't re-sweep on every
447
+ // launch.
362
448
  function autoCleanJobs(ctx: ExtensionContext): void {
363
449
  const cfg = resolveConfig(ctx);
450
+ cleanSessionJobs(cfg.cleanupDays, ctx);
451
+ if (!cfg.globalAutoClean) return;
364
452
  const markerPath = join(cfg.jobsDir, ".last-clean");
365
453
  try {
366
454
  const last = Number(readFileSync(markerPath, "utf8").trim());
@@ -381,46 +469,67 @@ export default function (pi: ExtensionAPI) {
381
469
  }
382
470
  }
383
471
 
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 {
472
+ // Re-check stale job records anything running with no live ChildProcess
473
+ // handle (rec.child unset): adopted foreign jobs, and jobs reconstructed
474
+ // from transcript entries after a restart. None of these get an exit event,
475
+ // so the exit marker in the log (or a dead pid) is the only completion
476
+ // signal. Without this they render as "running" forever e.g. a job that
477
+ // finished while pi was down reconstructs as a zombie on every resume.
478
+ // - Adopted jobs are dropped from the registry entirely (not this
479
+ // session's history; the log on disk still covers id lookup + cleanup).
480
+ // - Reconstructed jobs ARE this session's history: mark them done and
481
+ // append a done entry so future resumes reconstruct them as done too.
482
+ function revalidateStaleJobs(): void {
392
483
  for (const [id, rec] of jobs) {
393
- if (!rec.adopted || rec.exitCode !== undefined) continue;
484
+ if (rec.child || rec.exitCode !== undefined) continue;
394
485
  let exit = parseExitFromLog(rec.logPath);
395
486
  if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
396
- // pid gone with no marker — killed/crashed before the wrapper could write it
487
+ // pid gone with no marker — killed/crashed before the wrapper could write it,
488
+ // or the log was already cleaned up
397
489
  exit = -1;
398
490
  }
399
- if (exit !== null) jobs.delete(id);
491
+ if (exit === null) continue; // still genuinely running
492
+ if (rec.adopted) {
493
+ jobs.delete(id);
494
+ } else {
495
+ rec.exitCode = exit;
496
+ rec.exitedAt = Date.now();
497
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
498
+ id: rec.id,
499
+ pid: rec.pid,
500
+ cmd: rec.cmd,
501
+ name: rec.name,
502
+ started: rec.started,
503
+ logPath: rec.logPath,
504
+ state: "done",
505
+ exitCode: exit >= 0 ? exit : undefined,
506
+ exitedAt: rec.exitedAt,
507
+ });
508
+ }
400
509
  }
401
510
  }
402
511
 
403
- function hasAdoptedRunning(): boolean {
512
+ function hasUnsupervisedRunning(): boolean {
404
513
  for (const rec of jobs.values()) {
405
- if (rec.adopted && rec.exitCode === undefined) return true;
514
+ if (!rec.child && rec.exitCode === undefined) return true;
406
515
  }
407
516
  return false;
408
517
  }
409
518
 
410
- function ensureAdoptedPoller(ctx: ExtensionContext): void {
411
- if (adoptedPoller !== undefined || !hasAdoptedRunning()) return;
412
- adoptedPoller = setInterval(() => {
413
- revalidateAdoptedJobs();
519
+ function ensureStalePoller(ctx: ExtensionContext): void {
520
+ if (stalePoller !== undefined || !hasUnsupervisedRunning()) return;
521
+ stalePoller = setInterval(() => {
522
+ revalidateStaleJobs();
414
523
  updateWidget(ctx);
415
- if (!hasAdoptedRunning()) stopAdoptedPoller();
416
- }, ADOPTED_POLL_MS);
417
- adoptedPoller.unref();
524
+ if (!hasUnsupervisedRunning()) stopStalePoller();
525
+ }, STALE_POLL_MS);
526
+ stalePoller.unref();
418
527
  }
419
528
 
420
- function stopAdoptedPoller(): void {
421
- if (adoptedPoller !== undefined) {
422
- clearInterval(adoptedPoller);
423
- adoptedPoller = undefined;
529
+ function stopStalePoller(): void {
530
+ if (stalePoller !== undefined) {
531
+ clearInterval(stalePoller);
532
+ stalePoller = undefined;
424
533
  }
425
534
  }
426
535
 
@@ -500,6 +609,10 @@ export default function (pi: ExtensionAPI) {
500
609
  }
501
610
  for (const d of latestBydId.values()) {
502
611
  if (jobs.has(d.id)) continue;
612
+ // A done entry is authoritative even when exitCode is missing (jobs
613
+ // killed by a signal persist exitCode: undefined) — without the state
614
+ // check those reconstruct as "running" zombies on every resume.
615
+ const isDone = d.state === "done" || d.exitCode !== undefined;
503
616
  jobs.set(d.id, {
504
617
  id: d.id,
505
618
  pid: d.pid,
@@ -508,7 +621,7 @@ export default function (pi: ExtensionAPI) {
508
621
  started: d.started,
509
622
  logPath: d.logPath,
510
623
  exitedAt: d.exitedAt,
511
- exitCode: d.exitCode,
624
+ exitCode: isDone ? (d.exitCode ?? -1) : undefined,
512
625
  ctx,
513
626
  });
514
627
  }
@@ -557,18 +670,22 @@ export default function (pi: ExtensionAPI) {
557
670
  } catch {
558
671
  // jobs dir doesn't exist — nothing to adopt.
559
672
  }
560
- ensureAdoptedPoller(ctx);
561
673
  }
562
674
 
563
- // Show the widget if anything is now running (covers adopted + reconstructed jobs).
675
+ // Show the widget if anything is now running. revalidateStaleJobs()
676
+ // inside clears zombies — reconstructed jobs that finished while pi was
677
+ // down — before they ever render. Then start the stale poller for
678
+ // anything still genuinely running without a child handle (also gives
679
+ // resumed sessions live tracking of their still-running jobs).
564
680
  updateWidget(ctx);
681
+ ensureStalePoller(ctx);
565
682
  // Auto-cleanup of old logs, throttled to one sweep per cleanupDays via a
566
683
  // marker in the jobs dir (see autoCleanJobs). Also runs on session_shutdown.
567
684
  autoCleanJobs(ctx);
568
685
  });
569
686
 
570
687
  pi.on("session_shutdown", async (_event, ctx) => {
571
- stopAdoptedPoller();
688
+ stopStalePoller();
572
689
  // Sweep old logs on the way out. Throttled via the .last-clean marker so
573
690
  // restart-heavy workflows don't sweep more than once per cleanupDays.
574
691
  try {
@@ -594,7 +711,7 @@ export default function (pi: ExtensionAPI) {
594
711
  "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
595
712
  "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.",
596
713
  "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.",
597
- "Never cat or Read a full bgrun log — use bgtail for a peek or ctx_execute_file for failure analysis.",
714
+ "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use ctx_execute_file on the log path only when the condensed tail is insufficient.",
598
715
  ],
599
716
  parameters: Type.Object({
600
717
  command: Type.String({
@@ -764,14 +881,123 @@ export default function (pi: ExtensionAPI) {
764
881
  },
765
882
  });
766
883
 
767
- // ── bgtail: read last N lines of a job's log, stripping the exit marker ────
884
+ // ── Log condenser: ANSI strip, per-line cap, collapse runs, total budget ────
885
+ // Keeps bgtail output small enough that a "quick peek" never floods context:
886
+ // colored test output often carries 2-3x its text size in ANSI escapes, and
887
+ // one unbounded line (minified bundle, base64 blob) can blow the whole budget.
888
+ const ANSI_RE =
889
+ /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g;
890
+ const LINE_CAP = 2000; // chars per line after stripping
891
+ const TOTAL_CAP = 8000; // chars for the whole bgtail result
892
+
893
+ function condenseLogLines(
894
+ lines: string[],
895
+ opts: { raw?: boolean } = {},
896
+ ): { text: string; truncated: string[] } {
897
+ const notes: string[] = [];
898
+ if (opts.raw) return { text: lines.join("\n"), truncated: notes };
899
+ let stripped = 0;
900
+ let cappedLines = 0;
901
+ const clean = lines.map((l) => {
902
+ if (ANSI_RE.test(l)) {
903
+ stripped++;
904
+ l = l.replace(ANSI_RE, "");
905
+ }
906
+ return l;
907
+ });
908
+ ANSI_RE.lastIndex = 0;
909
+ // collapse runs of 3+ identical lines (spinner frames, retry spam)
910
+ const collapsed: { text: string; count: number }[] = [];
911
+ let runs = 0;
912
+ for (const l of clean) {
913
+ const prev = collapsed[collapsed.length - 1];
914
+ if (prev && prev.text === l) {
915
+ prev.count++;
916
+ if (prev.count === 3) runs++;
917
+ } else {
918
+ collapsed.push({ text: l, count: 1 });
919
+ }
920
+ }
921
+ const out: string[] = [];
922
+ let total = 0;
923
+ for (const c of collapsed) {
924
+ let line = c.count >= 3 ? `${c.text} [x${c.count}]` : c.text;
925
+ if (line.length > LINE_CAP) {
926
+ line = line.slice(0, LINE_CAP) + ` …[+${line.length - LINE_CAP} chars]`;
927
+ cappedLines++;
928
+ }
929
+ total += line.length + 1;
930
+ if (total > TOTAL_CAP) {
931
+ notes.push(
932
+ `output capped at ${TOTAL_CAP} chars — ${lines.length} raw lines total; raise \`lines\`, use \`raw: true\`, or run ctx_execute_file on the log for whole-log analysis`,
933
+ );
934
+ break;
935
+ }
936
+ out.push(line);
937
+ }
938
+ if (stripped > 0)
939
+ notes.push(
940
+ `${stripped} ANSI escape sequence${stripped === 1 ? "" : "s"} stripped`,
941
+ );
942
+ if (runs > 0)
943
+ notes.push(`${runs} repeated-line run${runs === 1 ? "" : "s"} collapsed`);
944
+ if (cappedLines > 0)
945
+ notes.push(
946
+ `${cappedLines} long line${cappedLines === 1 ? "" : "s"} truncated to ${LINE_CAP} chars`,
947
+ );
948
+ return { text: out.join("\n"), truncated: notes };
949
+ }
950
+
951
+ // ── bgtail: read last N lines of a job's log, condensed for context ────────
952
+
953
+ // Shared by the bgtail tool (agent-facing) and the /bgtail slash command
954
+ // (human-facing).
955
+ async function bgtailCore(
956
+ params: { id: string; lines?: number; raw?: boolean },
957
+ ctx?: ExtensionContext,
958
+ ): Promise<{
959
+ content: { type: "text"; text: string }[];
960
+ details: Record<string, unknown>;
961
+ isError?: boolean;
962
+ }> {
963
+ const { id, lines = 40, raw = false } = params;
964
+ if (!id) throw new Error("bgtail: id is required");
965
+ const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
966
+ try {
967
+ const content = readFileSync(logPath, "utf8");
968
+ const all = content
969
+ .split("\n")
970
+ .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
971
+ const tail = all.slice(-lines);
972
+ const { text, truncated } = condenseLogLines(tail, { raw });
973
+ const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
974
+ return {
975
+ content: [{ type: "text", text: text + notes || "(empty log)" }],
976
+ details: {
977
+ id,
978
+ linesShown: tail.length,
979
+ logPath,
980
+ notFound: false,
981
+ condensed: !raw,
982
+ ...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
983
+ },
984
+ };
985
+ } catch {
986
+ return {
987
+ content: [
988
+ { type: "text", text: `No log found for job ${id} at ${logPath}` },
989
+ ],
990
+ details: { id, linesShown: 0, logPath, notFound: true },
991
+ isError: true,
992
+ };
993
+ }
994
+ }
768
995
 
769
996
  pi.registerTool({
770
997
  name: "bgtail",
771
998
  label: "Tail Background Log",
772
999
  description:
773
- "Print the last N lines of a background job's log (default 40). Strips the exit-marker line. " +
774
- "Use this for a quick peek at results; use ctx_execute_file on the log path for whole-log failure analysis.",
1000
+ "Print the last N lines of a background job's log (default 40), condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. Pass raw: true for unprocessed output; use ctx_execute_file on the log path for whole-log failure analysis.",
775
1001
  promptSnippet: "Read the last N lines of a bgrun job's log",
776
1002
  parameters: Type.Object({
777
1003
  id: Type.String({
@@ -780,34 +1006,139 @@ export default function (pi: ExtensionAPI) {
780
1006
  lines: Type.Optional(
781
1007
  Type.Number({ description: "Number of lines to show (default 40)" }),
782
1008
  ),
1009
+ raw: Type.Optional(
1010
+ Type.Boolean({
1011
+ description:
1012
+ "Skip condensing (ANSI strip, collapse, caps) and return raw text",
1013
+ }),
1014
+ ),
783
1015
  }),
784
1016
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
785
- const { id, lines = 40 } = params;
786
- if (!id) throw new Error("bgtail: id is required");
787
- const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
788
- try {
789
- const content = readFileSync(logPath, "utf8");
790
- const all = content
791
- .split("\n")
792
- .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
793
- const tail = all.slice(-lines);
1017
+ return bgtailCore(params, ctx);
1018
+ },
1019
+ });
1020
+
1021
+ // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
1022
+
1023
+ // Shared by the bgstatus tool (agent-facing) and the /bgstatus slash command
1024
+ // (human-facing).
1025
+ async function bgstatusCore(
1026
+ params: { id?: string; includeDone?: boolean },
1027
+ ctx: ExtensionContext,
1028
+ ): Promise<{
1029
+ content: { type: "text"; text: string }[];
1030
+ details: BgStatusDetails;
1031
+ isError?: boolean;
1032
+ }> {
1033
+ const { id } = params;
1034
+ const cfg = resolveConfig(ctx);
1035
+ const jobsDir = cfg.jobsDir;
1036
+ if (id) {
1037
+ const rec = jobs.get(id);
1038
+ if (rec) {
1039
+ const state = rec.exitCode === undefined ? "running" : "done";
1040
+ const exit = rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
1041
+ const lines = [`${id}: ${state}${exit}`];
1042
+ if (rec.name) lines.push(` name: ${rec.name}`);
1043
+ lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
794
1044
  return {
795
- content: [{ type: "text", text: tail.join("\n") || "(empty log)" }],
796
- details: { id, linesShown: tail.length, logPath, notFound: false },
1045
+ content: [{ type: "text", text: lines.join("\n") }],
1046
+ details: {
1047
+ id,
1048
+ state,
1049
+ exitCode: rec.exitCode ?? undefined,
1050
+ cmd: rec.cmd,
1051
+ name: rec.name,
1052
+ recovered: false,
1053
+ },
797
1054
  };
798
- } catch {
1055
+ }
1056
+ const logPath = join(jobsDir, `${id}.log`);
1057
+ try {
1058
+ const exit = parseExitFromLog(logPath);
1059
+ const state = exit === null ? "running" : "done";
799
1060
  return {
800
1061
  content: [
801
- { type: "text", text: `No log found for job ${id} at ${logPath}` },
1062
+ {
1063
+ type: "text",
1064
+ text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
1065
+ },
802
1066
  ],
803
- details: { id, linesShown: 0, logPath, notFound: true },
1067
+ details: {
1068
+ id,
1069
+ state,
1070
+ exitCode: exit ?? undefined,
1071
+ recovered: true,
1072
+ },
1073
+ };
1074
+ } catch {
1075
+ return {
1076
+ content: [{ type: "text", text: `No job found with id ${id}` }],
1077
+ details: { id, state: "unknown" },
804
1078
  isError: true,
805
1079
  };
806
1080
  }
807
- },
808
- });
809
-
810
- // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
1081
+ }
1082
+ // List: this session's jobs (running by default; finished only when
1083
+ // includeDone / showCompletedJobs is set), plus — when opted in — other
1084
+ // sessions' jobs from the shared jobs dir. Hidden disk logs get a
1085
+ // one-line count instead of spamming the listing.
1086
+ const showDone = params.includeDone ?? cfg.showCompletedJobs;
1087
+ revalidateStaleJobs();
1088
+ updateWidget(ctx);
1089
+ const lines: string[] = [];
1090
+ const seen = new Set<string>();
1091
+ for (const [jid, rec] of jobs) {
1092
+ seen.add(jid);
1093
+ if (rec.exitCode === undefined || showDone) {
1094
+ const state = rec.exitCode === undefined ? "running" : "done";
1095
+ const exit = rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
1096
+ const label = rec.name ? `${jid} — ${rec.name}` : jid;
1097
+ const from = rec.adopted ? " (adopted)" : "";
1098
+ lines.push(` ${label}: ${state}${exit}${from}`);
1099
+ }
1100
+ }
1101
+ let hiddenOnDisk = 0;
1102
+ try {
1103
+ for (const name of readdirSync(jobsDir)) {
1104
+ if (!name.endsWith(".log")) continue;
1105
+ const jid = name.slice(0, -".log".length);
1106
+ if (seen.has(jid)) continue;
1107
+ const logPath = join(jobsDir, name);
1108
+ const exit = parseExitFromLog(logPath);
1109
+ if (exit !== null) {
1110
+ // finished log on disk (other or older session)
1111
+ if (showDone) {
1112
+ lines.push(` ${jid}: done exit=${exit} (from log)`);
1113
+ } else {
1114
+ hiddenOnDisk++;
1115
+ }
1116
+ } else if (cfg.adoptForeignJobs) {
1117
+ // running foreign job — only surfaced when adoption is enabled
1118
+ lines.push(` ${jid}: running (from log)`);
1119
+ } else {
1120
+ hiddenOnDisk++;
1121
+ }
1122
+ }
1123
+ } catch {
1124
+ // jobs dir doesn't exist — nothing to scan.
1125
+ }
1126
+ if (hiddenOnDisk > 0) {
1127
+ lines.push(
1128
+ ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean all to prune)`,
1129
+ );
1130
+ }
1131
+ if (lines.length === 0) {
1132
+ return {
1133
+ content: [{ type: "text", text: "(no bgrun jobs)" }],
1134
+ details: { count: 0 },
1135
+ };
1136
+ }
1137
+ return {
1138
+ content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
1139
+ details: { count: lines.length },
1140
+ };
1141
+ }
811
1142
 
812
1143
  pi.registerTool({
813
1144
  name: "bgstatus",
@@ -828,168 +1159,153 @@ export default function (pi: ExtensionAPI) {
828
1159
  }),
829
1160
  ),
830
1161
  }),
831
- async execute(
832
- _toolCallId,
833
- params,
834
- _signal,
835
- _onUpdate,
836
- ctx,
837
- ): Promise<{
838
- content: { type: "text"; text: string }[];
839
- details: BgStatusDetails;
840
- isError?: boolean;
841
- }> {
842
- const { id } = params;
843
- const cfg = resolveConfig(ctx);
844
- const jobsDir = cfg.jobsDir;
845
- if (id) {
846
- const rec = jobs.get(id);
847
- if (rec) {
848
- const state = rec.exitCode === undefined ? "running" : "done";
849
- const exit =
850
- rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
851
- const lines = [`${id}: ${state}${exit}`];
852
- if (rec.name) lines.push(` name: ${rec.name}`);
853
- lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
854
- return {
855
- content: [{ type: "text", text: lines.join("\n") }],
856
- details: {
857
- id,
858
- state,
859
- exitCode: rec.exitCode ?? undefined,
860
- cmd: rec.cmd,
861
- name: rec.name,
862
- recovered: false,
863
- },
864
- };
865
- }
866
- const logPath = join(jobsDir, `${id}.log`);
867
- try {
868
- const exit = parseExitFromLog(logPath);
869
- const state = exit === null ? "running" : "done";
870
- return {
871
- content: [
872
- {
873
- type: "text",
874
- text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
875
- },
876
- ],
877
- details: {
878
- id,
879
- state,
880
- exitCode: exit ?? undefined,
881
- recovered: true,
882
- },
883
- };
884
- } catch {
885
- return {
886
- content: [{ type: "text", text: `No job found with id ${id}` }],
887
- details: { id, state: "unknown" },
888
- isError: true,
889
- };
890
- }
891
- }
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);
899
- const lines: string[] = [];
900
- const seen = new Set<string>();
901
- for (const [jid, rec] of jobs) {
902
- seen.add(jid);
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
- }
911
- }
912
- let hiddenOnDisk = 0;
913
- try {
914
- for (const name of readdirSync(jobsDir)) {
915
- if (!name.endsWith(".log")) continue;
916
- const jid = name.slice(0, -".log".length);
917
- if (seen.has(jid)) continue;
918
- const logPath = join(jobsDir, name);
919
- const exit = parseExitFromLog(logPath);
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
- }
933
- }
934
- } catch {
935
- // jobs dir doesn't exist — nothing to scan.
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
- }
942
- if (lines.length === 0) {
943
- return {
944
- content: [{ type: "text", text: "(no bgrun jobs)" }],
945
- details: { count: 0 },
946
- };
947
- }
948
- return {
949
- content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
950
- details: { count: lines.length },
951
- };
1162
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1163
+ return bgstatusCore(params, ctx);
952
1164
  },
953
1165
  });
954
1166
 
955
1167
  // ── bgclean: remove old job logs ───────────────────────────────────────────
956
1168
 
1169
+ // ── bgclean: remove old job logs ──────────────────────────────────────
1170
+
1171
+ // Shared by the bgclean tool (agent-facing) and the /bgclean slash command
1172
+ // (human-facing).
1173
+ async function bgcleanCore(
1174
+ params: { days?: number; all?: boolean },
1175
+ ctx?: ExtensionContext,
1176
+ ): Promise<{
1177
+ content: { type: "text"; text: string }[];
1178
+ details: { removed: number; kept: number; skippedRunning: number };
1179
+ }> {
1180
+ const cfg = resolveConfig(ctx);
1181
+ const { days = cfg.cleanupDays, all = false } = params;
1182
+ if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
1183
+ throw new Error(
1184
+ `bgclean: days must be a non-negative number, got ${days}`,
1185
+ );
1186
+ }
1187
+ let result;
1188
+ if (all) {
1189
+ result = cleanOldJobs(days, cfg.jobsDir, ctx);
1190
+ // A manual global clean refreshes the throttle marker so the next
1191
+ // auto-sweep doesn't immediately redo this work.
1192
+ try {
1193
+ mkdirSync(cfg.jobsDir, { recursive: true });
1194
+ writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
1195
+ } catch {
1196
+ // best-effort
1197
+ }
1198
+ } else {
1199
+ // Session-scoped by default: bg* commands apply to the current
1200
+ // session's jobs only.
1201
+ result = cleanSessionJobs(days, ctx);
1202
+ }
1203
+ const scope = all ? "all sessions" : "this session";
1204
+ const summary = `removed ${result.removed} job log(s) (${scope}), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
1205
+ return {
1206
+ content: [{ type: "text", text: summary }],
1207
+ details: result,
1208
+ };
1209
+ }
1210
+
957
1211
  pi.registerTool({
958
1212
  name: "bgclean",
959
1213
  label: "Clean Old Background Jobs",
960
1214
  description:
961
- "Remove old background job logs from disk. Default: 7 days. Never removes a running job's log. " +
962
- "Prints a summary of what was removed vs kept.",
963
- promptSnippet: "Remove old bgrun job logs",
1215
+ "Remove old background job logs from disk. Default scope: THIS session's jobs only (other sessions' logs are " +
1216
+ "untouched). Pass all: true to sweep the whole shared jobs dir. Retention: cleanupDays config (default 7 days). " +
1217
+ "Never removes a running job's log. Prints a summary of what was removed vs kept.",
1218
+ promptSnippet:
1219
+ "Remove old bgrun job logs (this session by default; all: true for every session's)",
964
1220
  parameters: Type.Object({
965
1221
  days: Type.Optional(
966
1222
  Type.Number({
967
1223
  description: "Remove logs older than this many days (default 7)",
968
1224
  }),
969
1225
  ),
1226
+ all: Type.Optional(
1227
+ Type.Boolean({
1228
+ description:
1229
+ "Sweep the whole shared jobs dir (all sessions' logs), not just this session's (default false)",
1230
+ }),
1231
+ ),
970
1232
  }),
971
1233
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
972
- const cfg = resolveConfig(ctx);
973
- const { days = cfg.cleanupDays } = params;
974
- if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
975
- throw new Error(
976
- `bgclean: days must be a non-negative number, got ${days}`,
977
- );
1234
+ return bgcleanCore(params, ctx);
1235
+ },
1236
+ });
1237
+
1238
+ // ── Slash commands: human-facing mirrors of the read/clean tools ───────────
1239
+ //
1240
+ // pi.registerTool registers AGENT tools; slash commands need a separate
1241
+ // pi.registerCommand registration. These let the human check jobs or prune
1242
+ // logs directly from the TUI without asking the agent. /bgrun is
1243
+ // deliberately NOT a command — starting jobs (and reacting to their wakes)
1244
+ // is the agent's workflow.
1245
+
1246
+ pi.registerCommand("bgstatus", {
1247
+ description: "Background jobs: status (/bgstatus [id] [done])",
1248
+ handler: async (args: string, ctx: ExtensionContext) => {
1249
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
1250
+ const includeDone = tokens.some((t) =>
1251
+ ["done", "all"].includes(t.toLowerCase()),
1252
+ );
1253
+ const id = tokens.find((t) => !["done", "all"].includes(t.toLowerCase()));
1254
+ const res = await bgstatusCore(
1255
+ { id, includeDone: includeDone || undefined },
1256
+ ctx,
1257
+ );
1258
+ if (ctx.hasUI) {
1259
+ ctx.ui.notify(res.content[0].text, res.isError ? "error" : "info");
1260
+ }
1261
+ },
1262
+ });
1263
+
1264
+ pi.registerCommand("bgtail", {
1265
+ description: "Background jobs: tail a log (/bgtail <id> [lines])",
1266
+ handler: async (args: string, ctx: ExtensionContext) => {
1267
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
1268
+ const id = tokens[0];
1269
+ if (!id) {
1270
+ if (ctx.hasUI) {
1271
+ ctx.ui.notify("Usage: /bgtail <job-id> [lines]", "error");
1272
+ }
1273
+ return;
1274
+ }
1275
+ const n = Number(tokens[1]);
1276
+ const res = await bgtailCore(
1277
+ { id, lines: Number.isFinite(n) && n > 0 ? n : undefined },
1278
+ ctx,
1279
+ );
1280
+ if (ctx.hasUI) {
1281
+ ctx.ui.notify(res.content[0].text, res.isError ? "error" : "info");
978
1282
  }
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.
1283
+ },
1284
+ });
1285
+
1286
+ pi.registerCommand("bgclean", {
1287
+ description: "Background jobs: remove old logs (/bgclean [days] [all])",
1288
+ handler: async (args: string, ctx: ExtensionContext) => {
1289
+ const tokens = (args ?? "")
1290
+ .trim()
1291
+ .toLowerCase()
1292
+ .split(/\s+/)
1293
+ .filter(Boolean);
1294
+ const daysToken = Number(tokens.find((t) => /^\d+(\.\d+)?$/.test(t)));
1295
+ const all = tokens.includes("all");
982
1296
  try {
983
- mkdirSync(cfg.jobsDir, { recursive: true });
984
- writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
985
- } catch {
986
- // best-effort
1297
+ const res = await bgcleanCore(
1298
+ { days: Number.isFinite(daysToken) ? daysToken : undefined, all },
1299
+ ctx,
1300
+ );
1301
+ if (ctx.hasUI) {
1302
+ ctx.ui.notify(res.content[0].text, "info");
1303
+ }
1304
+ } catch (err) {
1305
+ if (ctx.hasUI) {
1306
+ ctx.ui.notify(String(err), "error");
1307
+ }
987
1308
  }
988
- const summary = `removed ${result.removed} job log(s), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
989
- return {
990
- content: [{ type: "text", text: summary }],
991
- details: result,
992
- };
993
1309
  },
994
1310
  });
995
1311
  }