pi-background-run 0.3.0 → 0.5.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.
@@ -34,17 +34,19 @@ import { Type } from "typebox";
34
34
  import { Box, Text } from "@earendil-works/pi-tui";
35
35
  import { spawn } from "node:child_process";
36
36
  import {
37
- openSync,
37
+ appendFileSync,
38
38
  closeSync,
39
- readFileSync,
39
+ existsSync,
40
40
  mkdirSync,
41
+ openSync,
42
+ readFileSync,
41
43
  readdirSync,
42
44
  renameSync,
43
- unlinkSync,
44
45
  statSync,
46
+ unlinkSync,
45
47
  writeFileSync,
46
48
  } from "node:fs";
47
- import { join } from "node:path";
49
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
48
50
  import { homedir } from "node:os";
49
51
 
50
52
  // Exit marker appended to every log so the file is self-describing: the exit
@@ -53,7 +55,15 @@ import { homedir } from "node:os";
53
55
  const EXIT_MARKER = "__BGRUN_EXIT__=";
54
56
 
55
57
  const DEFAULT_CLEANUP_DAYS = 7;
56
- const ADOPTED_POLL_MS = 30_000; // re-check interval for adopted (foreign) jobs
58
+ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle
59
+ const GLOBAL_JOBS_DIR = join(homedir(), ".pi-bgrun", "jobs");
60
+
61
+ // Default regex for bggrep when the caller passes no pattern: common failure
62
+ // signatures across test runners and build tools. ONLY a convenience default —
63
+ // bggrep's contract is that the caller's own pattern always wins, because a
64
+ // generic default on arbitrary tools/languages misses more than it catches.
65
+ export const DEFAULT_GREP_PATTERN =
66
+ "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖";
57
67
 
58
68
  // ── Configuration ───────────────────────────────────────────────────────────
59
69
  //
@@ -66,6 +76,10 @@ const ADOPTED_POLL_MS = 30_000; // re-check interval for adopted (foreign) jobs
66
76
 
67
77
  interface BgrunConfig {
68
78
  jobsDir: string;
79
+ // True when jobsDir came from a RELATIVE path resolved against the project
80
+ // root (project-local logs). Only then does bgrun auto-ignore the dir in
81
+ // .git/info/exclude — an absolute dir is the user's explicit choice.
82
+ jobsDirProjectLocal: boolean;
69
83
  // Adopt other sessions' running jobs (found in the shared jobs dir) into
70
84
  // this session's widget and job list. Default false — most sessions don't
71
85
  // want unrelated jobs from other projects cluttering the widget.
@@ -73,9 +87,16 @@ interface BgrunConfig {
73
87
  // Include finished jobs in bgstatus listings by default. Default false —
74
88
  // completed jobs are noise; ask for them explicitly (bgstatus includeDone).
75
89
  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).
90
+ // Log retention for cleanup (auto-sweeps and the bgclean default).
78
91
  cleanupDays: number;
92
+ // Auto-sweep the WHOLE shared jobs dir at session boundaries for orphans —
93
+ // finished (exit marker or dead pid) logs older than cleanupDays from
94
+ // sessions that crashed or are never resumed again. Running jobs are always
95
+ // pid-protected. Throttled to once per cleanupDays via a .last-clean marker.
96
+ // Default true — without it, orphaned logs accumulate forever. Set false to
97
+ // keep every sweep session-scoped (then only `bgclean all` touches foreign
98
+ // logs).
99
+ globalAutoClean: boolean;
79
100
  }
80
101
 
81
102
  interface BgrunConfigFile {
@@ -83,6 +104,7 @@ interface BgrunConfigFile {
83
104
  adoptForeignJobs?: unknown;
84
105
  showCompletedJobs?: unknown;
85
106
  cleanupDays?: unknown;
107
+ globalAutoClean?: unknown;
86
108
  }
87
109
 
88
110
  function parseBoolEnv(v: string | undefined): boolean | undefined {
@@ -104,6 +126,111 @@ function readConfigFile(path: string): BgrunConfigFile {
104
126
  return {};
105
127
  }
106
128
 
129
+ // ── Project-local jobs dir ──────────────────────────────────────────────────
130
+ //
131
+ // A RELATIVE `jobsDir` (from any config layer, or PI_BGRUN_DIR) opts into
132
+ // project-local logs: it resolves against the session's project root, so logs
133
+ // land inside the workspace. That keeps them within the project sandbox —
134
+ // analysis tools confined to the project root (e.g. context-mode's
135
+ // ctx_execute_file/ctx_index) can then process whole logs without flooding
136
+ // context. Absolute paths behave exactly as in older versions
137
+ // (migration-safe), and with no recognizable project root a relative path
138
+ // falls back to the global dir instead of scattering logs across whatever
139
+ // directory pi happened to start in.
140
+
141
+ function isProjectRootLike(dir: string): boolean {
142
+ // Cheap heuristic: a directory holding .git or pi's config dir is a project.
143
+ return (
144
+ existsSync(join(dir, ".git")) || existsSync(join(dir, CONFIG_DIR_NAME))
145
+ );
146
+ }
147
+
148
+ export function resolveJobsDirPath(
149
+ raw: string | undefined,
150
+ ctx?: { cwd?: string },
151
+ ): { dir: string; projectLocal: boolean } {
152
+ if (!raw) return { dir: GLOBAL_JOBS_DIR, projectLocal: false };
153
+ if (isAbsolute(raw)) return { dir: raw, projectLocal: false };
154
+ const root = ctx?.cwd ?? process.cwd();
155
+ if (!root || !isProjectRootLike(root)) {
156
+ return { dir: GLOBAL_JOBS_DIR, projectLocal: false };
157
+ }
158
+ return { dir: join(root, raw), projectLocal: true };
159
+ }
160
+
161
+ // Auto-ignore a project-local jobs dir in git so logs never pollute
162
+ // `git status`: appends the dir pattern to the enclosing repo's
163
+ // .git/info/exclude (local-only — the tracked .gitignore is never touched).
164
+ // Memoized only on SUCCESS — a transient failure (unwritable exclude file,
165
+ // .git appearing later) is retried on the next bgrun. Every step is
166
+ // best-effort and must never fail a bgrun.
167
+ const gitExcludedDirs = new Set<string>();
168
+
169
+ // Returns true when the dir is settled (pattern written, already present, or
170
+ // legitimately nothing to do — no repo above, dir is the repo root itself).
171
+ // False only on failure, so the caller retries next time.
172
+ export function ensureGitExcluded(jobsDir: string): boolean {
173
+ if (gitExcludedDirs.has(jobsDir)) return true;
174
+ if (tryEnsureGitExcluded(jobsDir)) {
175
+ gitExcludedDirs.add(jobsDir);
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ function tryEnsureGitExcluded(jobsDir: string): boolean {
182
+ try {
183
+ // Walk up from jobsDir to the enclosing work tree.
184
+ let cur = jobsDir;
185
+ for (;;) {
186
+ const dot = join(cur, ".git");
187
+ if (existsSync(dot)) return appendExcludePattern(cur, dot, jobsDir);
188
+ const parent = dirname(cur);
189
+ if (parent === cur) return true; // filesystem root — no repo above; nothing to do
190
+ cur = parent;
191
+ }
192
+ } catch {
193
+ // best-effort — ignore hygiene must never break job creation
194
+ return false;
195
+ }
196
+ }
197
+
198
+ function appendExcludePattern(
199
+ repoRoot: string,
200
+ dotGit: string,
201
+ jobsDir: string,
202
+ ): boolean {
203
+ if (jobsDir === repoRoot) return true; // can't exclude the whole repo; nothing to do
204
+ // `.git` is a directory in a normal checkout, or a file pointing at the
205
+ // real git dir in linked worktrees (git worktree add) and submodules.
206
+ let gitDir = dotGit;
207
+ if (statSync(dotGit).isFile()) {
208
+ const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
209
+ if (!m) return false; // unparseable .git file — retry later
210
+ gitDir = m[1].trim();
211
+ }
212
+ const rel = relative(repoRoot, jobsDir);
213
+ // Defense-in-depth: the walk-up guarantees jobsDir sits under repoRoot, but
214
+ // a future caller or symlinked path could break that — ../-prefixed
215
+ // patterns are silently useless in gitignore semantics, so skip them.
216
+ if (rel.startsWith("..") || isAbsolute(rel)) return true;
217
+ const pattern = rel.split(sep).join("/") + "/";
218
+ const excludePath = join(gitDir, "info", "exclude");
219
+ let existing = "";
220
+ try {
221
+ existing = readFileSync(excludePath, "utf8");
222
+ } catch {
223
+ // no exclude file yet — we'll create it
224
+ }
225
+ if (existing.split("\n").some((l) => l.trim() === pattern)) return true;
226
+ mkdirSync(join(gitDir, "info"), { recursive: true });
227
+ appendFileSync(
228
+ excludePath,
229
+ `\n# pi-bgrun job logs (auto-added)\n${pattern}\n`,
230
+ );
231
+ return true;
232
+ }
233
+
107
234
  // Resolved per call (cheap: at most two small file reads) so env/config
108
235
  // changes are picked up without module reloads — and tests can isolate.
109
236
  function resolveConfig(ctx?: {
@@ -130,6 +257,10 @@ function resolveConfig(ctx?: {
130
257
  typeof merged.showCompletedJobs === "boolean"
131
258
  ? merged.showCompletedJobs
132
259
  : undefined;
260
+ const globalCleanFile =
261
+ typeof merged.globalAutoClean === "boolean"
262
+ ? merged.globalAutoClean
263
+ : undefined;
133
264
  const dirFile =
134
265
  typeof merged.jobsDir === "string" && merged.jobsDir
135
266
  ? merged.jobsDir
@@ -142,11 +273,11 @@ function resolveConfig(ctx?: {
142
273
  : undefined;
143
274
  const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
144
275
  const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined;
276
+ const { dir: jobsDir, projectLocal: jobsDirProjectLocal } =
277
+ resolveJobsDirPath(process.env.PI_BGRUN_DIR || dirFile, ctx);
145
278
  return {
146
- jobsDir:
147
- process.env.PI_BGRUN_DIR ||
148
- dirFile ||
149
- join(homedir(), ".pi-bgrun", "jobs"),
279
+ jobsDir,
280
+ jobsDirProjectLocal,
150
281
  adoptForeignJobs:
151
282
  parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false,
152
283
  showCompletedJobs:
@@ -154,9 +285,38 @@ function resolveConfig(ctx?: {
154
285
  completedFile ??
155
286
  false,
156
287
  cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
288
+ globalAutoClean:
289
+ parseBoolEnv(process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN) ??
290
+ globalCleanFile ??
291
+ true,
157
292
  };
158
293
  }
159
294
 
295
+ // Widget "since" formatting: time-only when the job started today; otherwise
296
+ // include the date (and the year too when it differs) — a job that has been
297
+ // running since a previous day shouldn't render as if it started today at
298
+ // that time. `now` is injectable for deterministic tests.
299
+ export function formatSince(started: number, now: number = Date.now()): string {
300
+ const d = new Date(started);
301
+ const n = new Date(now);
302
+ const time = d.toLocaleTimeString([], { hour12: false });
303
+ const sameDay =
304
+ d.getFullYear() === n.getFullYear() &&
305
+ d.getMonth() === n.getMonth() &&
306
+ d.getDate() === n.getDate();
307
+ if (sameDay) return time;
308
+ if (d.getFullYear() === n.getFullYear()) {
309
+ const md = d.toLocaleDateString([], { month: "short", day: "numeric" });
310
+ return `${md} ${time}`;
311
+ }
312
+ const ymd = d.toLocaleDateString([], {
313
+ year: "numeric",
314
+ month: "short",
315
+ day: "numeric",
316
+ });
317
+ return `${ymd} ${time}`;
318
+ }
319
+
160
320
  interface JobRecord {
161
321
  id: string;
162
322
  pid: number;
@@ -206,9 +366,11 @@ function isRunningPid(pid: number): boolean {
206
366
 
207
367
  export default function (pi: ExtensionAPI) {
208
368
  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;
369
+ // Poller for stale job recordsanything running with no live ChildProcess
370
+ // handle (adopted foreign jobs + jobs reconstructed from transcript entries
371
+ // after a restart). No exit event exists for those, so their logs/pids are
372
+ // re-checked on an interval instead.
373
+ let stalePoller: ReturnType<typeof setInterval> | undefined;
212
374
 
213
375
  // ── Helpers ───────────────────────────────────────────────────────────────
214
376
 
@@ -269,7 +431,7 @@ export default function (pi: ExtensionAPI) {
269
431
 
270
432
  function updateWidget(ctx: ExtensionContext): void {
271
433
  if (!ctx.hasUI) return;
272
- revalidateAdoptedJobs();
434
+ revalidateStaleJobs();
273
435
  const running: JobRecord[] = [];
274
436
  for (const rec of jobs.values()) {
275
437
  if (rec.exitCode === undefined) running.push(rec);
@@ -280,9 +442,7 @@ export default function (pi: ExtensionAPI) {
280
442
  }
281
443
  const lines = [`📊 bgrun: ${running.length} running`];
282
444
  for (const rec of running) {
283
- const startedAt = new Date(rec.started).toLocaleTimeString([], {
284
- hour12: false,
285
- });
445
+ const startedAt = formatSince(rec.started);
286
446
  const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
287
447
  const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
288
448
  const tag = rec.adopted ? " (adopted)" : "";
@@ -353,14 +513,61 @@ export default function (pi: ExtensionAPI) {
353
513
  return result;
354
514
  }
355
515
 
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.
516
+ // Session-scoped sweep: remove THIS session's finished job logs older than
517
+ // `days`. Only looks at the in-memory Map (which, after reconstruction, is
518
+ // exactly this session's lineage) other sessions' logs are never touched.
519
+ // Running jobs are always skipped. Cheap (a handful of stats), so it runs
520
+ // unthrottled at session boundaries.
521
+ function cleanSessionJobs(
522
+ days: number,
523
+ ctx?: ExtensionContext,
524
+ ): { removed: number; kept: number; skippedRunning: number } {
525
+ const result = { removed: 0, kept: 0, skippedRunning: 0 };
526
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
527
+ for (const rec of jobs.values()) {
528
+ if (rec.exitCode === undefined) {
529
+ result.skippedRunning++;
530
+ continue;
531
+ }
532
+ let st;
533
+ try {
534
+ st = statSync(rec.logPath);
535
+ } catch {
536
+ continue; // already gone
537
+ }
538
+ if (st.mtimeMs > cutoff) {
539
+ result.kept++;
540
+ continue;
541
+ }
542
+ try {
543
+ unlinkSync(rec.logPath);
544
+ result.removed++;
545
+ } catch {
546
+ // ignore
547
+ }
548
+ }
549
+ if (result.removed > 0 && ctx?.hasUI) {
550
+ ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info");
551
+ }
552
+ return result;
553
+ }
554
+
555
+ // Auto-clean at session boundaries. Two parts:
556
+ // 1. Session-scoped sweep — this session's old logs only; cheap,
557
+ // unthrottled.
558
+ // 2. Global orphan sweep (default on; disable via globalAutoClean: false /
559
+ // PI_BGRUN_GLOBAL_AUTO_CLEAN=0) — the whole shared jobs dir, removing
560
+ // FINISHED logs (exit marker, or dead pid) older than cleanupDays. This
561
+ // is what keeps orphans from crashed / never-resumed sessions from
562
+ // accumulating: a week-old finished log is garbage under the same
563
+ // retention the owning session would apply itself, and running jobs are
564
+ // always pid-protected. Throttled to one sweep per cleanupDays via a
565
+ // .last-clean marker so restart-heavy workflows don't re-sweep on every
566
+ // launch.
362
567
  function autoCleanJobs(ctx: ExtensionContext): void {
363
568
  const cfg = resolveConfig(ctx);
569
+ cleanSessionJobs(cfg.cleanupDays, ctx);
570
+ if (!cfg.globalAutoClean) return;
364
571
  const markerPath = join(cfg.jobsDir, ".last-clean");
365
572
  try {
366
573
  const last = Number(readFileSync(markerPath, "utf8").trim());
@@ -381,46 +588,67 @@ export default function (pi: ExtensionAPI) {
381
588
  }
382
589
  }
383
590
 
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 {
591
+ // Re-check stale job records anything running with no live ChildProcess
592
+ // handle (rec.child unset): adopted foreign jobs, and jobs reconstructed
593
+ // from transcript entries after a restart. None of these get an exit event,
594
+ // so the exit marker in the log (or a dead pid) is the only completion
595
+ // signal. Without this they render as "running" forever e.g. a job that
596
+ // finished while pi was down reconstructs as a zombie on every resume.
597
+ // - Adopted jobs are dropped from the registry entirely (not this
598
+ // session's history; the log on disk still covers id lookup + cleanup).
599
+ // - Reconstructed jobs ARE this session's history: mark them done and
600
+ // append a done entry so future resumes reconstruct them as done too.
601
+ function revalidateStaleJobs(): void {
392
602
  for (const [id, rec] of jobs) {
393
- if (!rec.adopted || rec.exitCode !== undefined) continue;
603
+ if (rec.child || rec.exitCode !== undefined) continue;
394
604
  let exit = parseExitFromLog(rec.logPath);
395
605
  if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
396
- // pid gone with no marker — killed/crashed before the wrapper could write it
606
+ // pid gone with no marker — killed/crashed before the wrapper could write it,
607
+ // or the log was already cleaned up
397
608
  exit = -1;
398
609
  }
399
- if (exit !== null) jobs.delete(id);
610
+ if (exit === null) continue; // still genuinely running
611
+ if (rec.adopted) {
612
+ jobs.delete(id);
613
+ } else {
614
+ rec.exitCode = exit;
615
+ rec.exitedAt = Date.now();
616
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
617
+ id: rec.id,
618
+ pid: rec.pid,
619
+ cmd: rec.cmd,
620
+ name: rec.name,
621
+ started: rec.started,
622
+ logPath: rec.logPath,
623
+ state: "done",
624
+ exitCode: exit >= 0 ? exit : undefined,
625
+ exitedAt: rec.exitedAt,
626
+ });
627
+ }
400
628
  }
401
629
  }
402
630
 
403
- function hasAdoptedRunning(): boolean {
631
+ function hasUnsupervisedRunning(): boolean {
404
632
  for (const rec of jobs.values()) {
405
- if (rec.adopted && rec.exitCode === undefined) return true;
633
+ if (!rec.child && rec.exitCode === undefined) return true;
406
634
  }
407
635
  return false;
408
636
  }
409
637
 
410
- function ensureAdoptedPoller(ctx: ExtensionContext): void {
411
- if (adoptedPoller !== undefined || !hasAdoptedRunning()) return;
412
- adoptedPoller = setInterval(() => {
413
- revalidateAdoptedJobs();
638
+ function ensureStalePoller(ctx: ExtensionContext): void {
639
+ if (stalePoller !== undefined || !hasUnsupervisedRunning()) return;
640
+ stalePoller = setInterval(() => {
641
+ revalidateStaleJobs();
414
642
  updateWidget(ctx);
415
- if (!hasAdoptedRunning()) stopAdoptedPoller();
416
- }, ADOPTED_POLL_MS);
417
- adoptedPoller.unref();
643
+ if (!hasUnsupervisedRunning()) stopStalePoller();
644
+ }, STALE_POLL_MS);
645
+ stalePoller.unref();
418
646
  }
419
647
 
420
- function stopAdoptedPoller(): void {
421
- if (adoptedPoller !== undefined) {
422
- clearInterval(adoptedPoller);
423
- adoptedPoller = undefined;
648
+ function stopStalePoller(): void {
649
+ if (stalePoller !== undefined) {
650
+ clearInterval(stalePoller);
651
+ stalePoller = undefined;
424
652
  }
425
653
  }
426
654
 
@@ -500,6 +728,10 @@ export default function (pi: ExtensionAPI) {
500
728
  }
501
729
  for (const d of latestBydId.values()) {
502
730
  if (jobs.has(d.id)) continue;
731
+ // A done entry is authoritative even when exitCode is missing (jobs
732
+ // killed by a signal persist exitCode: undefined) — without the state
733
+ // check those reconstruct as "running" zombies on every resume.
734
+ const isDone = d.state === "done" || d.exitCode !== undefined;
503
735
  jobs.set(d.id, {
504
736
  id: d.id,
505
737
  pid: d.pid,
@@ -508,7 +740,7 @@ export default function (pi: ExtensionAPI) {
508
740
  started: d.started,
509
741
  logPath: d.logPath,
510
742
  exitedAt: d.exitedAt,
511
- exitCode: d.exitCode,
743
+ exitCode: isDone ? (d.exitCode ?? -1) : undefined,
512
744
  ctx,
513
745
  });
514
746
  }
@@ -557,18 +789,22 @@ export default function (pi: ExtensionAPI) {
557
789
  } catch {
558
790
  // jobs dir doesn't exist — nothing to adopt.
559
791
  }
560
- ensureAdoptedPoller(ctx);
561
792
  }
562
793
 
563
- // Show the widget if anything is now running (covers adopted + reconstructed jobs).
794
+ // Show the widget if anything is now running. revalidateStaleJobs()
795
+ // inside clears zombies — reconstructed jobs that finished while pi was
796
+ // down — before they ever render. Then start the stale poller for
797
+ // anything still genuinely running without a child handle (also gives
798
+ // resumed sessions live tracking of their still-running jobs).
564
799
  updateWidget(ctx);
800
+ ensureStalePoller(ctx);
565
801
  // Auto-cleanup of old logs, throttled to one sweep per cleanupDays via a
566
802
  // marker in the jobs dir (see autoCleanJobs). Also runs on session_shutdown.
567
803
  autoCleanJobs(ctx);
568
804
  });
569
805
 
570
806
  pi.on("session_shutdown", async (_event, ctx) => {
571
- stopAdoptedPoller();
807
+ stopStalePoller();
572
808
  // Sweep old logs on the way out. Throttled via the .last-clean marker so
573
809
  // restart-heavy workflows don't sweep more than once per cleanupDays.
574
810
  try {
@@ -594,7 +830,7 @@ export default function (pi: ExtensionAPI) {
594
830
  "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
595
831
  "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
832
  "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 — 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.",
833
+ "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search or ctx_execute_file on the log path for whole-log analysis.",
598
834
  ],
599
835
  parameters: Type.Object({
600
836
  command: Type.String({
@@ -616,7 +852,11 @@ export default function (pi: ExtensionAPI) {
616
852
  }
617
853
  const name = sanitizeName(rawName);
618
854
 
619
- const jobsDir = resolveConfig(ctx).jobsDir;
855
+ const cfg = resolveConfig(ctx);
856
+ // Project-local logs are auto-ignored in .git/info/exclude (best-effort)
857
+ // so they never pollute `git status`. Absolute dirs are left untouched.
858
+ if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir);
859
+ const jobsDir = cfg.jobsDir;
620
860
  mkdirSync(jobsDir, { recursive: true });
621
861
 
622
862
  const slug = makeSlug(name ?? command);
@@ -768,7 +1008,8 @@ export default function (pi: ExtensionAPI) {
768
1008
  // Keeps bgtail output small enough that a "quick peek" never floods context:
769
1009
  // colored test output often carries 2-3x its text size in ANSI escapes, and
770
1010
  // one unbounded line (minified bundle, base64 blob) can blow the whole budget.
771
- const ANSI_RE = /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g;
1011
+ const ANSI_RE =
1012
+ /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g;
772
1013
  const LINE_CAP = 2000; // chars per line after stripping
773
1014
  const TOTAL_CAP = 8000; // chars for the whole bgtail result
774
1015
 
@@ -781,7 +1022,10 @@ export default function (pi: ExtensionAPI) {
781
1022
  let stripped = 0;
782
1023
  let cappedLines = 0;
783
1024
  const clean = lines.map((l) => {
784
- if (ANSI_RE.test(l)) { stripped++; l = l.replace(ANSI_RE, ""); }
1025
+ if (ANSI_RE.test(l)) {
1026
+ stripped++;
1027
+ l = l.replace(ANSI_RE, "");
1028
+ }
785
1029
  return l;
786
1030
  });
787
1031
  ANSI_RE.lastIndex = 0;
@@ -807,74 +1051,457 @@ export default function (pi: ExtensionAPI) {
807
1051
  }
808
1052
  total += line.length + 1;
809
1053
  if (total > TOTAL_CAP) {
810
- notes.push(`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`);
1054
+ notes.push(
1055
+ `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`,
1056
+ );
811
1057
  break;
812
1058
  }
813
1059
  out.push(line);
814
1060
  }
815
- if (stripped > 0) notes.push(`${stripped} ANSI escape sequence${stripped === 1 ? "" : "s"} stripped`);
816
- if (runs > 0) notes.push(`${runs} repeated-line run${runs === 1 ? "" : "s"} collapsed`);
817
- if (cappedLines > 0) notes.push(`${cappedLines} long line${cappedLines === 1 ? "" : "s"} truncated to ${LINE_CAP} chars`);
1061
+ if (stripped > 0)
1062
+ notes.push(
1063
+ `${stripped} ANSI escape sequence${stripped === 1 ? "" : "s"} stripped`,
1064
+ );
1065
+ if (runs > 0)
1066
+ notes.push(`${runs} repeated-line run${runs === 1 ? "" : "s"} collapsed`);
1067
+ if (cappedLines > 0)
1068
+ notes.push(
1069
+ `${cappedLines} long line${cappedLines === 1 ? "" : "s"} truncated to ${LINE_CAP} chars`,
1070
+ );
818
1071
  return { text: out.join("\n"), truncated: notes };
819
1072
  }
820
1073
 
821
- // ── bgtail: read last N lines of a job's log, condensed for context ────────
1074
+ // ── bgtail: read the newest lines of a job's log, condensed for context ────
1075
+ //
1076
+ // Delta tailing: each read bookmarks the total raw line count at read time
1077
+ // (the high-water mark of what the caller has had the opportunity to see).
1078
+ // The FIRST read for a job returns the full last-N tail; repeat reads return
1079
+ // only lines appended since, so polling a running job never re-pays context
1080
+ // for lines already seen. Deliberately-skipped prefix lines are never
1081
+ // replayed as "new". raw: true keeps the verbatim last-N window (no delta
1082
+ // header) but still advances the bookmark. A shrunken log (rotated/replaced)
1083
+ // resets to a full tail. Bookmarks are in-memory only — a session restart
1084
+ // starts fresh with a full tail.
1085
+
1086
+ const tailBookmarks = new Map<
1087
+ string,
1088
+ { lines: number; bytes: number; first: string }
1089
+ >();
1090
+
1091
+ // Shared by the bgtail tool (agent-facing) and the /bgtail slash command
1092
+ // (human-facing).
1093
+ async function bgtailCore(
1094
+ params: { id: string; lines?: number; raw?: boolean },
1095
+ ctx?: ExtensionContext,
1096
+ ): Promise<{
1097
+ content: { type: "text"; text: string }[];
1098
+ details: Record<string, unknown>;
1099
+ isError?: boolean;
1100
+ }> {
1101
+ const { id, lines: linesParam = 40, raw = false } = params;
1102
+ // Clamp defensively — direct callers (e.g. the slash command) bypass the
1103
+ // tool schema, and lines < 1 would corrupt slicing (slice(-0) = whole log).
1104
+ const lines = Math.max(1, Math.floor(linesParam));
1105
+ if (!id) throw new Error("bgtail: id is required");
1106
+ // Prefer this session's record: its logPath stays correct even if the
1107
+ // config (and thus the resolved jobs dir) changes mid-session — e.g. a
1108
+ // user switching to project-local logs right after upgrading.
1109
+ const logPath =
1110
+ jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`);
1111
+ try {
1112
+ const content = readFileSync(logPath, "utf8");
1113
+ // Content lines only: the exit marker and blanks are filtered BEFORE the
1114
+ // window is sliced, so "last N lines" means the last N content lines
1115
+ // (matching pre-delta behavior) and bookmarks count content lines.
1116
+ // /\r?\n/ keeps CRLF logs from leaving a stray \r on every line.
1117
+ const rawLines = content
1118
+ .split(/\r?\n/)
1119
+ .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
1120
+ const total = rawLines.length;
1121
+ const first = rawLines[0]?.slice(0, 200) ?? "";
1122
+ const prev = tailBookmarks.get(id);
1123
+ // Append-only logs never mutate earlier lines, so a changed first
1124
+ // content line means the log was replaced or rotated — reset to a full
1125
+ // tail. Catches same-size replacements the shrink checks cannot see.
1126
+ // (A previously-empty log growing content is growth, not replacement.)
1127
+ const replaced =
1128
+ prev !== undefined && prev.lines > 0 && prev.first !== first;
1129
+ const shrank =
1130
+ prev !== undefined &&
1131
+ (prev.lines > total || prev.bytes > content.length);
1132
+ let window: string[];
1133
+ let header: string | undefined;
1134
+ let newLines: number | undefined;
1135
+ if (raw || prev === undefined || shrank || replaced) {
1136
+ // Full tail: first read, raw mode, or a shrunken/replaced log (reset).
1137
+ window = rawLines.slice(-lines);
1138
+ if (!raw && (shrank || replaced)) {
1139
+ header = shrank
1140
+ ? "log shrank since last read — showing full tail"
1141
+ : "log was replaced since last read — showing full tail";
1142
+ }
1143
+ } else {
1144
+ const fresh = rawLines.slice(prev.lines);
1145
+ newLines = fresh.length;
1146
+ if (fresh.length === 0) {
1147
+ tailBookmarks.set(id, {
1148
+ lines: total,
1149
+ bytes: content.length,
1150
+ first,
1151
+ });
1152
+ return {
1153
+ content: [
1154
+ {
1155
+ type: "text",
1156
+ text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})`,
1157
+ },
1158
+ ],
1159
+ details: {
1160
+ id,
1161
+ linesShown: 0,
1162
+ logPath,
1163
+ notFound: false,
1164
+ condensed: true,
1165
+ newLines: 0,
1166
+ totalLines: total,
1167
+ },
1168
+ };
1169
+ }
1170
+ window = fresh.length > lines ? fresh.slice(-lines) : fresh;
1171
+ header =
1172
+ `+${fresh.length} new line${fresh.length === 1 ? "" : "s"} since last read — ` +
1173
+ `log at ${total} lines${fresh.length > lines ? ` (showing last ${lines})` : ""}`;
1174
+ }
1175
+ tailBookmarks.set(id, {
1176
+ lines: total,
1177
+ bytes: content.length,
1178
+ first,
1179
+ });
1180
+ const shown = window;
1181
+ const { text, truncated } = condenseLogLines(shown, { raw });
1182
+ // Delta reads early-return above, so an empty window here can only be
1183
+ // a first read of an empty log (full-tail path).
1184
+ const body = shown.length === 0 ? "(empty log)" : text;
1185
+ const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
1186
+ const head = header ? `${header}\n` : "";
1187
+ return {
1188
+ content: [{ type: "text", text: head + body + notes }],
1189
+ details: {
1190
+ id,
1191
+ linesShown: shown.length,
1192
+ logPath,
1193
+ notFound: false,
1194
+ condensed: !raw,
1195
+ ...(newLines === undefined ? {} : { newLines, totalLines: total }),
1196
+ ...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
1197
+ },
1198
+ };
1199
+ } catch {
1200
+ return {
1201
+ content: [
1202
+ { type: "text", text: `No log found for job ${id} at ${logPath}` },
1203
+ ],
1204
+ details: { id, linesShown: 0, logPath, notFound: true },
1205
+ isError: true,
1206
+ };
1207
+ }
1208
+ }
822
1209
 
823
1210
  pi.registerTool({
824
1211
  name: "bgtail",
825
1212
  label: "Tail Background Log",
826
1213
  description:
827
- "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.",
1214
+ "Read the newest lines of a background job's log, condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. The first read returns the last N lines (default 40); REPEAT reads return only lines appended since your last read (delta tailing) — polling a running job never re-pays for the same lines. raw: true returns the unprocessed last-N window. A shrunken or replaced log resets to a full tail. For pattern search use bggrep; for whole-log analysis, ctx_execute_file on the log path.",
828
1215
  promptSnippet: "Read the last N lines of a bgrun job's log",
829
1216
  parameters: Type.Object({
830
1217
  id: Type.String({
831
1218
  description: "Job id (from bgrun's 'started: <id>' response)",
832
1219
  }),
833
1220
  lines: Type.Optional(
834
- Type.Number({ description: "Number of lines to show (default 40)" }),
1221
+ Type.Number({
1222
+ description: "Number of lines to show (default 40)",
1223
+ minimum: 1,
1224
+ }),
835
1225
  ),
836
1226
  raw: Type.Optional(
837
1227
  Type.Boolean({
838
- description: "Skip condensing (ANSI strip, collapse, caps) and return raw text",
1228
+ description:
1229
+ "Skip condensing (ANSI strip, collapse, caps) and return raw text",
839
1230
  }),
840
1231
  ),
841
1232
  }),
842
1233
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
843
- const { id, lines = 40, raw = false } = params;
844
- if (!id) throw new Error("bgtail: id is required");
845
- const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
846
- try {
847
- const content = readFileSync(logPath, "utf8");
848
- const all = content
849
- .split("\n")
850
- .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
851
- const tail = all.slice(-lines);
852
- const { text, truncated } = condenseLogLines(tail, { raw });
853
- const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
1234
+ return bgtailCore(params, ctx);
1235
+ },
1236
+ });
1237
+
1238
+ // ── bggrep: pattern search over a job's log, capped for context ───────────
1239
+ //
1240
+ // The sandboxed whole-log path (ctx_execute_file) is confined to the
1241
+ // project root, which a global jobs dir sits outside of — bggrep runs
1242
+ // inside the extension with native fs access, so it works on any
1243
+ // configured jobs dir. Matches are line-numbered (grep -n style),
1244
+ // optionally with context lines, capped at MAX_GREP_MATCHES, and run
1245
+ // through the same condenser as bgtail so a search can never flood context.
1246
+
1247
+ const MAX_GREP_MATCHES = 50;
1248
+
1249
+ async function bggrepCore(
1250
+ params: { id: string; pattern?: string; context?: number },
1251
+ ctx?: ExtensionContext,
1252
+ ): Promise<{
1253
+ content: { type: "text"; text: string }[];
1254
+ details: Record<string, unknown>;
1255
+ isError?: boolean;
1256
+ }> {
1257
+ const { id, pattern, context: contextParam = 0 } = params;
1258
+ // Clamp defensively — negative context would exclude the match lines
1259
+ // themselves from the context windows (lo > hi no-ops the inner loop).
1260
+ const context = Math.max(0, Math.floor(contextParam));
1261
+ if (!id) throw new Error("bggrep: id is required");
1262
+ // Record-first, same as bgtail — correct across config changes.
1263
+ const logPath =
1264
+ jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`);
1265
+ const source = pattern ?? DEFAULT_GREP_PATTERN;
1266
+ let re: RegExp;
1267
+ try {
1268
+ re = new RegExp(source);
1269
+ } catch (err) {
1270
+ throw new Error(
1271
+ `bggrep: invalid pattern ${JSON.stringify(source)}: ${(err as Error).message}`,
1272
+ );
1273
+ }
1274
+ let rawLines: string[];
1275
+ try {
1276
+ const content = readFileSync(logPath, "utf8");
1277
+ // /\r?\n/ normalizes CRLF (a trailing \r would break $-anchored patterns
1278
+ // and leak into output); blank lines are KEPT so L<n> numbers match the
1279
+ // file. A trailing empty split element is dropped; "" yields zero lines.
1280
+ const split = content === "" ? [] : content.split(/\r?\n/);
1281
+ if (split.length > 0 && split[split.length - 1] === "") split.pop();
1282
+ rawLines = split.filter((l) => !l.startsWith(EXIT_MARKER));
1283
+ } catch {
1284
+ return {
1285
+ content: [
1286
+ { type: "text", text: `No log found for job ${id} at ${logPath}` },
1287
+ ],
1288
+ details: { id, matches: 0, logPath, notFound: true },
1289
+ isError: true,
1290
+ };
1291
+ }
1292
+ const matchIdx: number[] = [];
1293
+ for (let i = 0; i < rawLines.length; i++) {
1294
+ if (re.test(rawLines[i])) matchIdx.push(i);
1295
+ }
1296
+ const header =
1297
+ `${matchIdx.length} match${matchIdx.length === 1 ? "" : "es"} for /${source}/ ` +
1298
+ `in ${rawLines.length} line${rawLines.length === 1 ? "" : "s"}`;
1299
+ if (matchIdx.length === 0) {
1300
+ return {
1301
+ content: [{ type: "text", text: `${header} — none` }],
1302
+ details: {
1303
+ id,
1304
+ matches: 0,
1305
+ linesSearched: rawLines.length,
1306
+ logPath,
1307
+ notFound: false,
1308
+ },
1309
+ };
1310
+ }
1311
+ const capped = matchIdx.length > MAX_GREP_MATCHES;
1312
+ const shownIdx = capped ? matchIdx.slice(0, MAX_GREP_MATCHES) : matchIdx;
1313
+ // Context windows, merged where they overlap or touch (grep -C style).
1314
+ const include = new Set<number>();
1315
+ for (const i of shownIdx) {
1316
+ const lo = Math.max(0, i - context);
1317
+ const hi = Math.min(rawLines.length - 1, i + context);
1318
+ for (let j = lo; j <= hi; j++) include.add(j);
1319
+ }
1320
+ const sorted = [...include].sort((a, b) => a - b);
1321
+ const out: string[] = [];
1322
+ let prev = -2;
1323
+ for (const i of sorted) {
1324
+ if (prev >= 0 && i > prev + 1) {
1325
+ const gap = i - prev - 1;
1326
+ out.push(`…[${gap} line${gap === 1 ? "" : "s"} skipped]…`);
1327
+ }
1328
+ out.push(`L${i + 1}: ${rawLines[i]}`);
1329
+ prev = i;
1330
+ }
1331
+ const { text, truncated } = condenseLogLines(out);
1332
+ const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
1333
+ const capNote = capped
1334
+ ? ` — showing first ${MAX_GREP_MATCHES}; ${matchIdx.length - MAX_GREP_MATCHES} more not shown`
1335
+ : "";
1336
+ return {
1337
+ content: [{ type: "text", text: `${header}${capNote}\n${text}${notes}` }],
1338
+ details: {
1339
+ id,
1340
+ matches: matchIdx.length,
1341
+ linesSearched: rawLines.length,
1342
+ logPath,
1343
+ notFound: false,
1344
+ pattern: source,
1345
+ capped,
1346
+ },
1347
+ };
1348
+ }
1349
+
1350
+ pi.registerTool({
1351
+ name: "bggrep",
1352
+ label: "Grep Background Log",
1353
+ description:
1354
+ "Search a background job's log with a regex; returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. Runs inside the extension, so it works on any jobs dir — including global logs that project-sandboxed tools (ctx_execute_file) cannot reach. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).",
1355
+ promptSnippet: "Search a bgrun job's log for a pattern",
1356
+ promptGuidelines: [
1357
+ "Never search a bgrun log with the bash tool — uncapped output can flood context, and it needs manual log-path reconstruction and regex shell-quoting; bggrep is bounded by design.",
1358
+ "Prefer bggrep over bash grep or reading a bgrun log — matches are line-numbered, capped, and condensed.",
1359
+ "Pass an explicit pattern when you know the tool's output format; the default only catches common failure signatures.",
1360
+ ],
1361
+ parameters: Type.Object({
1362
+ id: Type.String({
1363
+ description: "Job id (from bgrun's 'started: <id>' response)",
1364
+ }),
1365
+ pattern: Type.Optional(
1366
+ Type.String({
1367
+ description:
1368
+ "Regex to search for. Default: generic failure signatures — override when you know the format.",
1369
+ }),
1370
+ ),
1371
+ context: Type.Optional(
1372
+ Type.Number({
1373
+ description:
1374
+ "Context lines around each match (default 0, grep -C style)",
1375
+ minimum: 0,
1376
+ }),
1377
+ ),
1378
+ }),
1379
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1380
+ return bggrepCore(params, ctx);
1381
+ },
1382
+ });
1383
+
1384
+ // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
1385
+
1386
+ // Shared by the bgstatus tool (agent-facing) and the /bgstatus slash command
1387
+ // (human-facing).
1388
+ async function bgstatusCore(
1389
+ params: { id?: string; includeDone?: boolean },
1390
+ ctx: ExtensionContext,
1391
+ ): Promise<{
1392
+ content: { type: "text"; text: string }[];
1393
+ details: BgStatusDetails;
1394
+ isError?: boolean;
1395
+ }> {
1396
+ const { id } = params;
1397
+ const cfg = resolveConfig(ctx);
1398
+ const jobsDir = cfg.jobsDir;
1399
+ if (id) {
1400
+ const rec = jobs.get(id);
1401
+ if (rec) {
1402
+ const state = rec.exitCode === undefined ? "running" : "done";
1403
+ const exit = rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
1404
+ const lines = [`${id}: ${state}${exit}`];
1405
+ if (rec.name) lines.push(` name: ${rec.name}`);
1406
+ lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
854
1407
  return {
855
- content: [{ type: "text", text: (text + notes) || "(empty log)" }],
1408
+ content: [{ type: "text", text: lines.join("\n") }],
856
1409
  details: {
857
1410
  id,
858
- linesShown: tail.length,
859
- logPath,
860
- notFound: false,
861
- condensed: !raw,
862
- ...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
1411
+ state,
1412
+ exitCode: rec.exitCode ?? undefined,
1413
+ cmd: rec.cmd,
1414
+ name: rec.name,
1415
+ recovered: false,
863
1416
  },
864
1417
  };
865
- } catch {
1418
+ }
1419
+ const logPath = join(jobsDir, `${id}.log`);
1420
+ try {
1421
+ const exit = parseExitFromLog(logPath);
1422
+ const state = exit === null ? "running" : "done";
866
1423
  return {
867
1424
  content: [
868
- { type: "text", text: `No log found for job ${id} at ${logPath}` },
1425
+ {
1426
+ type: "text",
1427
+ text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
1428
+ },
869
1429
  ],
870
- details: { id, linesShown: 0, logPath, notFound: true },
1430
+ details: {
1431
+ id,
1432
+ state,
1433
+ exitCode: exit ?? undefined,
1434
+ recovered: true,
1435
+ },
1436
+ };
1437
+ } catch {
1438
+ return {
1439
+ content: [{ type: "text", text: `No job found with id ${id}` }],
1440
+ details: { id, state: "unknown" },
871
1441
  isError: true,
872
1442
  };
873
1443
  }
874
- },
875
- });
876
-
877
- // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
1444
+ }
1445
+ // List: this session's jobs (running by default; finished only when
1446
+ // includeDone / showCompletedJobs is set), plus — when opted in — other
1447
+ // sessions' jobs from the shared jobs dir. Hidden disk logs get a
1448
+ // one-line count instead of spamming the listing.
1449
+ const showDone = params.includeDone ?? cfg.showCompletedJobs;
1450
+ revalidateStaleJobs();
1451
+ updateWidget(ctx);
1452
+ const lines: string[] = [];
1453
+ const seen = new Set<string>();
1454
+ for (const [jid, rec] of jobs) {
1455
+ seen.add(jid);
1456
+ if (rec.exitCode === undefined || showDone) {
1457
+ const state = rec.exitCode === undefined ? "running" : "done";
1458
+ const exit = rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
1459
+ const label = rec.name ? `${jid} — ${rec.name}` : jid;
1460
+ const from = rec.adopted ? " (adopted)" : "";
1461
+ lines.push(` ${label}: ${state}${exit}${from}`);
1462
+ }
1463
+ }
1464
+ let hiddenOnDisk = 0;
1465
+ try {
1466
+ for (const name of readdirSync(jobsDir)) {
1467
+ if (!name.endsWith(".log")) continue;
1468
+ const jid = name.slice(0, -".log".length);
1469
+ if (seen.has(jid)) continue;
1470
+ const logPath = join(jobsDir, name);
1471
+ const exit = parseExitFromLog(logPath);
1472
+ if (exit !== null) {
1473
+ // finished log on disk (other or older session)
1474
+ if (showDone) {
1475
+ lines.push(` ${jid}: done exit=${exit} (from log)`);
1476
+ } else {
1477
+ hiddenOnDisk++;
1478
+ }
1479
+ } else if (cfg.adoptForeignJobs) {
1480
+ // running foreign job — only surfaced when adoption is enabled
1481
+ lines.push(` ${jid}: running (from log)`);
1482
+ } else {
1483
+ hiddenOnDisk++;
1484
+ }
1485
+ }
1486
+ } catch {
1487
+ // jobs dir doesn't exist — nothing to scan.
1488
+ }
1489
+ if (hiddenOnDisk > 0) {
1490
+ lines.push(
1491
+ ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean all to prune)`,
1492
+ );
1493
+ }
1494
+ if (lines.length === 0) {
1495
+ return {
1496
+ content: [{ type: "text", text: "(no bgrun jobs)" }],
1497
+ details: { count: 0 },
1498
+ };
1499
+ }
1500
+ return {
1501
+ content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
1502
+ details: { count: lines.length },
1503
+ };
1504
+ }
878
1505
 
879
1506
  pi.registerTool({
880
1507
  name: "bgstatus",
@@ -895,168 +1522,153 @@ export default function (pi: ExtensionAPI) {
895
1522
  }),
896
1523
  ),
897
1524
  }),
898
- async execute(
899
- _toolCallId,
900
- params,
901
- _signal,
902
- _onUpdate,
903
- ctx,
904
- ): Promise<{
905
- content: { type: "text"; text: string }[];
906
- details: BgStatusDetails;
907
- isError?: boolean;
908
- }> {
909
- const { id } = params;
910
- const cfg = resolveConfig(ctx);
911
- const jobsDir = cfg.jobsDir;
912
- if (id) {
913
- const rec = jobs.get(id);
914
- if (rec) {
915
- const state = rec.exitCode === undefined ? "running" : "done";
916
- const exit =
917
- rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
918
- const lines = [`${id}: ${state}${exit}`];
919
- if (rec.name) lines.push(` name: ${rec.name}`);
920
- lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
921
- return {
922
- content: [{ type: "text", text: lines.join("\n") }],
923
- details: {
924
- id,
925
- state,
926
- exitCode: rec.exitCode ?? undefined,
927
- cmd: rec.cmd,
928
- name: rec.name,
929
- recovered: false,
930
- },
931
- };
932
- }
933
- const logPath = join(jobsDir, `${id}.log`);
934
- try {
935
- const exit = parseExitFromLog(logPath);
936
- const state = exit === null ? "running" : "done";
937
- return {
938
- content: [
939
- {
940
- type: "text",
941
- text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
942
- },
943
- ],
944
- details: {
945
- id,
946
- state,
947
- exitCode: exit ?? undefined,
948
- recovered: true,
949
- },
950
- };
951
- } catch {
952
- return {
953
- content: [{ type: "text", text: `No job found with id ${id}` }],
954
- details: { id, state: "unknown" },
955
- isError: true,
956
- };
957
- }
958
- }
959
- // List: this session's jobs (running by default; finished only when
960
- // includeDone / showCompletedJobs is set), plus — when opted in — other
961
- // sessions' jobs from the shared jobs dir. Hidden disk logs get a
962
- // one-line count instead of spamming the listing.
963
- const showDone = params.includeDone ?? cfg.showCompletedJobs;
964
- revalidateAdoptedJobs();
965
- updateWidget(ctx);
966
- const lines: string[] = [];
967
- const seen = new Set<string>();
968
- for (const [jid, rec] of jobs) {
969
- seen.add(jid);
970
- if (rec.exitCode === undefined || showDone) {
971
- const state = rec.exitCode === undefined ? "running" : "done";
972
- const exit =
973
- rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
974
- const label = rec.name ? `${jid} — ${rec.name}` : jid;
975
- const from = rec.adopted ? " (adopted)" : "";
976
- lines.push(` ${label}: ${state}${exit}${from}`);
977
- }
978
- }
979
- let hiddenOnDisk = 0;
980
- try {
981
- for (const name of readdirSync(jobsDir)) {
982
- if (!name.endsWith(".log")) continue;
983
- const jid = name.slice(0, -".log".length);
984
- if (seen.has(jid)) continue;
985
- const logPath = join(jobsDir, name);
986
- const exit = parseExitFromLog(logPath);
987
- if (exit !== null) {
988
- // finished log on disk (other or older session)
989
- if (showDone) {
990
- lines.push(` ${jid}: done exit=${exit} (from log)`);
991
- } else {
992
- hiddenOnDisk++;
993
- }
994
- } else if (cfg.adoptForeignJobs) {
995
- // running foreign job — only surfaced when adoption is enabled
996
- lines.push(` ${jid}: running (from log)`);
997
- } else {
998
- hiddenOnDisk++;
999
- }
1000
- }
1001
- } catch {
1002
- // jobs dir doesn't exist — nothing to scan.
1003
- }
1004
- if (hiddenOnDisk > 0) {
1005
- lines.push(
1006
- ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean to prune)`,
1007
- );
1008
- }
1009
- if (lines.length === 0) {
1010
- return {
1011
- content: [{ type: "text", text: "(no bgrun jobs)" }],
1012
- details: { count: 0 },
1013
- };
1014
- }
1015
- return {
1016
- content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
1017
- details: { count: lines.length },
1018
- };
1525
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1526
+ return bgstatusCore(params, ctx);
1019
1527
  },
1020
1528
  });
1021
1529
 
1022
1530
  // ── bgclean: remove old job logs ───────────────────────────────────────────
1023
1531
 
1532
+ // ── bgclean: remove old job logs ──────────────────────────────────────
1533
+
1534
+ // Shared by the bgclean tool (agent-facing) and the /bgclean slash command
1535
+ // (human-facing).
1536
+ async function bgcleanCore(
1537
+ params: { days?: number; all?: boolean },
1538
+ ctx?: ExtensionContext,
1539
+ ): Promise<{
1540
+ content: { type: "text"; text: string }[];
1541
+ details: { removed: number; kept: number; skippedRunning: number };
1542
+ }> {
1543
+ const cfg = resolveConfig(ctx);
1544
+ const { days = cfg.cleanupDays, all = false } = params;
1545
+ if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
1546
+ throw new Error(
1547
+ `bgclean: days must be a non-negative number, got ${days}`,
1548
+ );
1549
+ }
1550
+ let result;
1551
+ if (all) {
1552
+ result = cleanOldJobs(days, cfg.jobsDir, ctx);
1553
+ // A manual global clean refreshes the throttle marker so the next
1554
+ // auto-sweep doesn't immediately redo this work.
1555
+ try {
1556
+ mkdirSync(cfg.jobsDir, { recursive: true });
1557
+ writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
1558
+ } catch {
1559
+ // best-effort
1560
+ }
1561
+ } else {
1562
+ // Session-scoped by default: bg* commands apply to the current
1563
+ // session's jobs only.
1564
+ result = cleanSessionJobs(days, ctx);
1565
+ }
1566
+ const scope = all ? "all sessions" : "this session";
1567
+ const summary = `removed ${result.removed} job log(s) (${scope}), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
1568
+ return {
1569
+ content: [{ type: "text", text: summary }],
1570
+ details: result,
1571
+ };
1572
+ }
1573
+
1024
1574
  pi.registerTool({
1025
1575
  name: "bgclean",
1026
1576
  label: "Clean Old Background Jobs",
1027
1577
  description:
1028
- "Remove old background job logs from disk. Default: 7 days. Never removes a running job's log. " +
1029
- "Prints a summary of what was removed vs kept.",
1030
- promptSnippet: "Remove old bgrun job logs",
1578
+ "Remove old background job logs from disk. Default scope: THIS session's jobs only (other sessions' logs are " +
1579
+ "untouched). Pass all: true to sweep the whole shared jobs dir. Retention: cleanupDays config (default 7 days). " +
1580
+ "Never removes a running job's log. Prints a summary of what was removed vs kept.",
1581
+ promptSnippet:
1582
+ "Remove old bgrun job logs (this session by default; all: true for every session's)",
1031
1583
  parameters: Type.Object({
1032
1584
  days: Type.Optional(
1033
1585
  Type.Number({
1034
1586
  description: "Remove logs older than this many days (default 7)",
1035
1587
  }),
1036
1588
  ),
1589
+ all: Type.Optional(
1590
+ Type.Boolean({
1591
+ description:
1592
+ "Sweep the whole shared jobs dir (all sessions' logs), not just this session's (default false)",
1593
+ }),
1594
+ ),
1037
1595
  }),
1038
1596
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1039
- const cfg = resolveConfig(ctx);
1040
- const { days = cfg.cleanupDays } = params;
1041
- if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
1042
- throw new Error(
1043
- `bgclean: days must be a non-negative number, got ${days}`,
1044
- );
1597
+ return bgcleanCore(params, ctx);
1598
+ },
1599
+ });
1600
+
1601
+ // ── Slash commands: human-facing mirrors of the read/clean tools ───────────
1602
+ //
1603
+ // pi.registerTool registers AGENT tools; slash commands need a separate
1604
+ // pi.registerCommand registration. These let the human check jobs or prune
1605
+ // logs directly from the TUI without asking the agent. /bgrun is
1606
+ // deliberately NOT a command — starting jobs (and reacting to their wakes)
1607
+ // is the agent's workflow.
1608
+
1609
+ pi.registerCommand("bgstatus", {
1610
+ description: "Background jobs: status (/bgstatus [id] [done])",
1611
+ handler: async (args: string, ctx: ExtensionContext) => {
1612
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
1613
+ const includeDone = tokens.some((t) =>
1614
+ ["done", "all"].includes(t.toLowerCase()),
1615
+ );
1616
+ const id = tokens.find((t) => !["done", "all"].includes(t.toLowerCase()));
1617
+ const res = await bgstatusCore(
1618
+ { id, includeDone: includeDone || undefined },
1619
+ ctx,
1620
+ );
1621
+ if (ctx.hasUI) {
1622
+ ctx.ui.notify(res.content[0].text, res.isError ? "error" : "info");
1623
+ }
1624
+ },
1625
+ });
1626
+
1627
+ pi.registerCommand("bgtail", {
1628
+ description: "Background jobs: tail a log (/bgtail <id> [lines])",
1629
+ handler: async (args: string, ctx: ExtensionContext) => {
1630
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
1631
+ const id = tokens[0];
1632
+ if (!id) {
1633
+ if (ctx.hasUI) {
1634
+ ctx.ui.notify("Usage: /bgtail <job-id> [lines]", "error");
1635
+ }
1636
+ return;
1637
+ }
1638
+ const n = Number(tokens[1]);
1639
+ const res = await bgtailCore(
1640
+ { id, lines: Number.isFinite(n) && n > 0 ? n : undefined },
1641
+ ctx,
1642
+ );
1643
+ if (ctx.hasUI) {
1644
+ ctx.ui.notify(res.content[0].text, res.isError ? "error" : "info");
1045
1645
  }
1046
- const result = cleanOldJobs(days, cfg.jobsDir, ctx);
1047
- // Manual clean refreshes the throttle marker so the next auto-sweep
1048
- // doesn't immediately redo this work.
1646
+ },
1647
+ });
1648
+
1649
+ pi.registerCommand("bgclean", {
1650
+ description: "Background jobs: remove old logs (/bgclean [days] [all])",
1651
+ handler: async (args: string, ctx: ExtensionContext) => {
1652
+ const tokens = (args ?? "")
1653
+ .trim()
1654
+ .toLowerCase()
1655
+ .split(/\s+/)
1656
+ .filter(Boolean);
1657
+ const daysToken = Number(tokens.find((t) => /^\d+(\.\d+)?$/.test(t)));
1658
+ const all = tokens.includes("all");
1049
1659
  try {
1050
- mkdirSync(cfg.jobsDir, { recursive: true });
1051
- writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
1052
- } catch {
1053
- // best-effort
1660
+ const res = await bgcleanCore(
1661
+ { days: Number.isFinite(daysToken) ? daysToken : undefined, all },
1662
+ ctx,
1663
+ );
1664
+ if (ctx.hasUI) {
1665
+ ctx.ui.notify(res.content[0].text, "info");
1666
+ }
1667
+ } catch (err) {
1668
+ if (ctx.hasUI) {
1669
+ ctx.ui.notify(String(err), "error");
1670
+ }
1054
1671
  }
1055
- const summary = `removed ${result.removed} job log(s), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
1056
- return {
1057
- content: [{ type: "text", text: summary }],
1058
- details: result,
1059
- };
1060
1672
  },
1061
1673
  });
1062
1674
  }