@stablekernel/pi-background-run 0.5.0 → 0.6.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.
@@ -37,10 +37,13 @@ import {
37
37
  appendFileSync,
38
38
  closeSync,
39
39
  existsSync,
40
+ fstatSync,
41
+ readSync,
40
42
  mkdirSync,
41
43
  openSync,
42
44
  readFileSync,
43
45
  readdirSync,
46
+ realpathSync,
44
47
  renameSync,
45
48
  statSync,
46
49
  unlinkSync,
@@ -48,15 +51,287 @@ import {
48
51
  } from "node:fs";
49
52
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
50
53
  import { homedir } from "node:os";
54
+ import { createHash, randomBytes } from "node:crypto";
55
+ import {
56
+ DIGEST_PRESET_IDS,
57
+ digestNoMatchWarning,
58
+ selectDigestEntry,
59
+ type DigestEntry,
60
+ type DigestJobTarget,
61
+ type DigestMatch,
62
+ } from "./digestPresets.ts";
51
63
 
52
64
  // Exit marker appended to every log so the file is self-describing: the exit
53
65
  // code survives pi restarting. `;` (not `&&`) ensures the printf runs even when
54
66
  // the command fails. Never use `set -e` in the wrapper.
55
67
  const EXIT_MARKER = "__BGRUN_EXIT__=";
68
+ const JOBS_DIR_MARKER = ".bgrun-jobs";
69
+ // Tail-read caps — avoid whole-file readFileSync on runaway logs.
70
+ const LOG_TAIL_BYTES = 256 * 1024; // exit marker + last line
71
+ const LOG_READ_BYTES = 2 * 1024 * 1024; // bgtail / bggrep default window
72
+ const BGGREP_LINE_CAP = 10_000; // per-line match length cap
73
+
74
+ // Cap on the bytes a job may write to its log (stdout+stderr). Enforced inside
75
+ // the detached process tree, so it holds after pi exits. 0 = unlimited.
76
+ const DEFAULT_MAX_LOG_BYTES = 64 * 1024 * 1024;
77
+ // Above 2^53-1 a Number stringifies in exponential notation ("1e+21"), and the
78
+ // wrapper bakes the ceiling into the shell as a literal — `head -c 1e+21` fails
79
+ // and every byte of job output is discarded. A ceiling that large means
80
+ // "effectively unlimited", so it is clamped to the largest integral literal the
81
+ // shell can still parse.
82
+ const MAX_MAX_LOG_BYTES = Number.MAX_SAFE_INTEGER;
83
+ // Slack added when deriving read/count bounds from a ceiling: the wrapper writes
84
+ // its notice and exit marker PAST the capped bytes, so a capped log is slightly
85
+ // larger than the cap. A bound equal to the cap leaves the first bytes of every
86
+ // capped log unreadable and drops its line count.
87
+ const WRAPPER_OVERHEAD_BYTES = 4096;
88
+ // Machine-readable flags the wrapper appends to its exit marker. The marker is
89
+ // the one line a command cannot forge (only the LAST marker counts, so printing
90
+ // one is not evidence of completion) — carrying truncation there makes "the log
91
+ // was capped" unforgeable, unlike a printable notice line that job output can
92
+ // imitate.
93
+ const EXIT_MARKER_TRUNC_FLAG = " truncated=";
94
+ const EXIT_MARKER_NOCAP_FLAG = " nocap=1";
95
+ // Human-readable wrapper notices, in the reserved `__BGRUN_` namespace so they
96
+ // cannot collide with a command's own output. Readers filter them exactly like
97
+ // EXIT_MARKER: wrapper bookkeeping, not job output, so they are never counted as
98
+ // content lines or reported as the job's last line.
99
+ const TRUNC_NOTICE_PREFIX = "__BGRUN_TRUNC__ output truncated";
100
+ const CAPFAIL_NOTICE_PREFIX = "__BGRUN_NOCAP__ log ceiling unavailable";
101
+ // Line bound for a log scan. A byte window alone is not enough: a capped log of
102
+ // very short lines (the classic `yes ''` runaway) holds millions of lines in a
103
+ // few MiB, and materializing them as JS strings costs ~100 bytes each — measured
104
+ // at >3 GB of RSS for a 64 MiB window, i.e. an OOM on exactly the log class the
105
+ // ceiling exists for. 500k lines is ~40 MB of JS strings — a bounded cost that
106
+ // still dwarfs anything a real job prints into a window.
107
+ // ceiling exists for. Past this many lines only the tail is scanned, and the
108
+ // caveat says so.
109
+ const LOG_SCAN_LINES_MAX = 500_000;
56
110
 
57
111
  const DEFAULT_CLEANUP_DAYS = 7;
58
112
  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");
113
+ /** Default jobs dir inside a recognizable project root (`.git` or `.pi`). */
114
+ const PROJECT_LOCAL_JOBS_REL = ".pi-bgrun/jobs";
115
+
116
+ // Files a spawn stages in the jobs dir under one shared
117
+ // `.tmp-<slug>-<ts>-<hex>` stem: the log itself (renamed to `<id>.log` once the
118
+ // child pid is known) plus the wrapper's exit-code, fifo, liveness and
119
+ // truncation-flag files. Only these exact suffixes are ours — an unrelated
120
+ // `.tmp-*` is not.
121
+ const STAGING_SUFFIXES = [".log", ".ec", ".fifo", ".pid", ".trunc"];
122
+ // A staging file is only reclaimable once it is clearly nobody's business: the
123
+ // owner's liveness file says the wrapper is gone AND the file is older than this
124
+ // floor. Without the floor, an aggressive cleanup cutoff (a `bgclean` "clean
125
+ // everything" using a tiny positive `days`) can unlink the scratch files of a
126
+ // job that started milliseconds ago, before its liveness file exists.
127
+ const STAGING_MIN_AGE_MS = 60_000;
128
+
129
+ // Machine-global jobs dir. Resolved per call (not a module constant) so
130
+ // PI_BGRUN_GLOBAL_DIR can redirect it — used by tests to stay off the real
131
+ // ~/.pi-bgrun, and available for setups with a custom home or shared scratch.
132
+ function globalJobsDir(): string {
133
+ return expandTilde(
134
+ process.env.PI_BGRUN_GLOBAL_DIR || join(homeDir(), ".pi-bgrun", "jobs"),
135
+ );
136
+ }
137
+
138
+ // bggrep runs caller-supplied regexes. A pathological pattern (e.g. /^(a+)+$/)
139
+ // can backtrack catastrophically, and V8 has no regex step limit and cannot
140
+ // interrupt a regex running on the main thread — so the match loop runs in a
141
+ // worker with a wall-clock budget. On expiry the worker is terminated and a
142
+ // bounded error is returned instead of hanging the session. Bun's engine is
143
+ // more backtracking-resistant, but Node is the common case.
144
+ const BGGREP_DEFAULT_TIMEOUT_MS = 2_000;
145
+
146
+ // Executed inside the worker (eval'd). Uses require(): available in an eval
147
+ // worker on both Node and Bun, unlike a static import (the eval body is CJS).
148
+ const BGGREP_WORKER_SOURCE = `
149
+ const { parentPort, workerData } = require("node:worker_threads");
150
+ try {
151
+ const re = new RegExp(workerData.source);
152
+ const lines = workerData.lines;
153
+ const cap = workerData.cap;
154
+ const out = [];
155
+ for (let i = 0; i < lines.length; i++) {
156
+ let line = lines[i];
157
+ if (line.length > cap) line = line.slice(0, cap);
158
+ if (re.test(line)) out.push(i);
159
+ }
160
+ parentPort.postMessage({ ok: true, matches: out });
161
+ } catch (err) {
162
+ parentPort.postMessage({ ok: false, message: String((err && err.message) || err) });
163
+ }
164
+ `;
165
+
166
+ // Normalize a configured byte ceiling. 0 stays "unlimited"; a positive fraction
167
+ // (0.5) becomes 1 rather than flooring to 0, which would silently mean
168
+ // "unlimited"; anything above MAX_MAX_LOG_BYTES is clamped so the value always
169
+ // renders as a plain integer in the wrapper.
170
+ export function normalizeMaxLogBytes(value: unknown): number | undefined {
171
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
172
+ return undefined;
173
+ }
174
+ if (value === 0) return 0;
175
+ const floored = Math.floor(value);
176
+ if (floored < 1) return 1;
177
+ return Math.min(floored, MAX_MAX_LOG_BYTES);
178
+ }
179
+
180
+ // Upper bound for an explicit search window (bgtail/bggrep `bytes`): the ceiling
181
+ // in force plus the wrapper's overhead, so the widest window a caller can ask
182
+ // for can actually cover a log the cap produced. An equal-to-cap bound (the
183
+ // original constant) left the first bytes of every capped log unreadable while
184
+ // the caveat advertised itself as the remedy.
185
+ export function readWindowMax(): number {
186
+ let cap = DEFAULT_MAX_LOG_BYTES;
187
+ try {
188
+ const configured = resolveConfig().maxLogBytes;
189
+ if (configured > 0) cap = configured;
190
+ } catch {
191
+ // No resolvable config → the default ceiling.
192
+ }
193
+ return cap + WRAPPER_OVERHEAD_BYTES;
194
+ }
195
+
196
+ // Clamp a caller-supplied log search window: absent/garbage/non-positive →
197
+ // the default, anything wider than the bound above → the bound (searching past
198
+ // what a job could have written is pure cost, and the window is materialized).
199
+ export function clampReadWindow(bytes: unknown, max = readWindowMax()): number {
200
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
201
+ return LOG_READ_BYTES;
202
+ }
203
+ return Math.min(Math.floor(bytes), max);
204
+ }
205
+
206
+ // Trim a scan window to its last LOG_SCAN_LINES_MAX lines without splitting the
207
+ // whole window first: walking the newline positions backwards costs one pass
208
+ // over the window and never materializes millions of short strings. Returns the
209
+ // text to scan plus whether the line bound (rather than the byte window) decided
210
+ // the view, so callers can say so instead of implying the whole window was read.
211
+ export function boundScanLines(content: string): {
212
+ content: string;
213
+ lineBoundHit: boolean;
214
+ } {
215
+ let end = content.length;
216
+ let seen = 0;
217
+ while (seen < LOG_SCAN_LINES_MAX) {
218
+ const nl = content.lastIndexOf("\n", end - 1);
219
+ if (nl === -1) break;
220
+ end = nl;
221
+ seen++;
222
+ }
223
+ // "Hit" only when the limit is what stopped the walk AND bytes were actually
224
+ // trimmed off the front — running out of newlines means the whole window fits.
225
+ const hit = seen === LOG_SCAN_LINES_MAX && end > 0;
226
+ return hit
227
+ ? { content: content.slice(end + 1), lineBoundHit: true }
228
+ : { content, lineBoundHit: false };
229
+ }
230
+
231
+ // Read at call time so tests (and users) can lower the budget; a non-positive
232
+ // or non-numeric value falls back to the default.
233
+ export function bggrepTimeoutMs(): number {
234
+ const raw = Number(process.env.PI_BGRUN_GREP_TIMEOUT_MS);
235
+ return Number.isFinite(raw) && raw > 0 ? raw : BGGREP_DEFAULT_TIMEOUT_MS;
236
+ }
237
+
238
+ type GrepMatchOutcome =
239
+ | { kind: "ok"; matchIdx: number[] }
240
+ | { kind: "timeout" }
241
+ | { kind: "invalid"; message: string };
242
+
243
+ // Bounded between lines only — a single pathological line can still stall.
244
+ // Used solely when worker_threads is unavailable (never on Node or Bun).
245
+ export function matchLinesSyncBounded(
246
+ source: string,
247
+ lines: string[],
248
+ cap: number,
249
+ budgetMs: number,
250
+ ): GrepMatchOutcome {
251
+ let re: RegExp;
252
+ try {
253
+ re = new RegExp(source);
254
+ } catch (err) {
255
+ return { kind: "invalid", message: (err as Error).message };
256
+ }
257
+ const out: number[] = [];
258
+ const start = Date.now();
259
+ for (let i = 0; i < lines.length; i++) {
260
+ if ((i & 0x3ff) === 0 && Date.now() - start > budgetMs) {
261
+ return { kind: "timeout" };
262
+ }
263
+ const line = lines[i].length > cap ? lines[i].slice(0, cap) : lines[i];
264
+ if (re.test(line)) out.push(i);
265
+ }
266
+ return { kind: "ok", matchIdx: out };
267
+ }
268
+
269
+ // Exported, and workerSource-injectable, so a test can prove the ABORT path on
270
+ // any engine: pass a worker body that never returns and the budget must still
271
+ // yield `{kind: "timeout"}`. Input-driven catastrophic patterns cannot test it
272
+ // — engines differ (V8 backtracks exponentially where JSC does not), so the
273
+ // only portable assertion is that termination works.
274
+ export async function matchLinesWithBudget(
275
+ source: string,
276
+ lines: string[],
277
+ cap: number,
278
+ budgetMs: number,
279
+ workerSource: string = BGGREP_WORKER_SOURCE,
280
+ ): Promise<GrepMatchOutcome> {
281
+ let WorkerCtor: typeof import("node:worker_threads").Worker;
282
+ try {
283
+ ({ Worker: WorkerCtor } = await import("node:worker_threads"));
284
+ } catch {
285
+ return matchLinesSyncBounded(source, lines, cap, budgetMs);
286
+ }
287
+ let worker: import("node:worker_threads").Worker;
288
+ try {
289
+ worker = new WorkerCtor(workerSource, {
290
+ eval: true,
291
+ workerData: { source, lines, cap },
292
+ });
293
+ } catch {
294
+ return matchLinesSyncBounded(source, lines, cap, budgetMs);
295
+ }
296
+ return new Promise<GrepMatchOutcome>((resolve) => {
297
+ let settled = false;
298
+ const finish = (outcome: GrepMatchOutcome) => {
299
+ if (settled) return;
300
+ settled = true;
301
+ clearTimeout(timer);
302
+ worker.removeAllListeners();
303
+ // Swallow a late 'error' emitted after listeners are dropped, or it
304
+ // becomes an unhandled emitter throw on the way to terminate().
305
+ worker.on("error", () => {});
306
+ void worker.terminate();
307
+ resolve(outcome);
308
+ };
309
+ const timer = setTimeout(() => finish({ kind: "timeout" }), budgetMs);
310
+ worker.on(
311
+ "message",
312
+ (msg: { ok: boolean; matches?: number[]; message?: string }) => {
313
+ finish(
314
+ msg.ok
315
+ ? { kind: "ok", matchIdx: msg.matches ?? [] }
316
+ : { kind: "invalid", message: msg.message ?? "invalid pattern" },
317
+ );
318
+ },
319
+ );
320
+ worker.on("error", (err) =>
321
+ finish({ kind: "invalid", message: err.message }),
322
+ );
323
+ worker.on("exit", (code) => {
324
+ // Any exit before a message is a failure — including exit 0, which would
325
+ // otherwise linger until the budget and be misreported as a timeout.
326
+ if (!settled) {
327
+ finish({
328
+ kind: "invalid",
329
+ message: `grep worker exited with code ${code} before a result`,
330
+ });
331
+ }
332
+ });
333
+ });
334
+ }
60
335
 
61
336
  // Default regex for bggrep when the caller passes no pattern: common failure
62
337
  // signatures across test runners and build tools. ONLY a convenience default —
@@ -65,6 +340,302 @@ const GLOBAL_JOBS_DIR = join(homedir(), ".pi-bgrun", "jobs");
65
340
  export const DEFAULT_GREP_PATTERN =
66
341
  "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖";
67
342
 
343
+ function readLogSlice(
344
+ logPath: string,
345
+ maxBytes: number,
346
+ ): { content: string; truncated: boolean; size: number } | null {
347
+ try {
348
+ const st = statSync(logPath);
349
+ const size = st.size;
350
+ if (size === 0) return { content: "", truncated: false, size: 0 };
351
+ const readLen = Math.min(size, maxBytes);
352
+ const fd = openSync(logPath, "r");
353
+ try {
354
+ const buf = Buffer.alloc(readLen);
355
+ // Honor the byte count: a short read (file rotated/truncated between stat
356
+ // and read) would otherwise leave the buffer's tail zero-filled and leak
357
+ // NUL bytes into bgtail/bggrep output.
358
+ const n = readSync(fd, buf, 0, readLen, size - readLen);
359
+ return {
360
+ content: (n < readLen ? buf.subarray(0, n) : buf).toString("utf8"),
361
+ truncated: readLen < size,
362
+ size,
363
+ };
364
+ } finally {
365
+ closeSync(fd);
366
+ }
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+
372
+ // The wrapper writes __BGRUN_EXIT__=N as the FINAL line of the log. A marker
373
+ // that is NOT the last non-empty line is just job output that happened to
374
+ // contain the string (e.g. a command that greps a bgrun log) and is NOT
375
+ // evidence of completion. Position matters both ways: trusting any marker would
376
+ // let cleanup delete a running job's log; trusting none would let a finished
377
+ // log whose pid was later reused live forever.
378
+ function parseExitFromContent(content: string): number | null {
379
+ const lines = content.split("\n");
380
+ for (let i = lines.length - 1; i >= 0; i--) {
381
+ if (lines[i].trim().length === 0) continue;
382
+ const match = lines[i].match(/^__BGRUN_EXIT__=(-?\d+)/);
383
+ return match ? parseInt(match[1], 10) : null;
384
+ }
385
+ return null;
386
+ }
387
+
388
+ export function parseExitFromLogPath(logPath: string): number | null {
389
+ const slice = readLogSlice(logPath, LOG_TAIL_BYTES);
390
+ if (!slice) return null;
391
+ return parseExitFromContent(slice.content);
392
+ }
393
+
394
+ // Wrapper bookkeeping lines — never job output. Every reader filters them, so a
395
+ // capped log's last line, line count, tail window and grep results still
396
+ // describe the COMMAND's output rather than the wrapper's own bookkeeping.
397
+ // The whole `__BGRUN_` namespace is reserved (exit marker + notices), which is
398
+ // also what makes a command printing those lines a deliberate forgery rather
399
+ // than an accident.
400
+ export function isWrapperLine(line: string): boolean {
401
+ return line.startsWith("__BGRUN_");
402
+ }
403
+
404
+ // What the wrapper recorded about the ceiling, read from the exit marker — the
405
+ // last non-empty line, and the only line a command cannot forge: only the LAST
406
+ // marker counts, so printing one is not evidence of completion. The flag rides
407
+ // on that marker ("__BGRUN_EXIT__=0 truncated=1000"), which is why a command's
408
+ // own output can no longer make a healthy log look capped (it used to be read
409
+ // from the notice line, whose position and text a command controls).
410
+ export type CapStatus =
411
+ | { kind: "truncated"; bytes: number }
412
+ | { kind: "ceiling-failed" }
413
+ | null;
414
+
415
+ export function parseCapStatusFromContent(content: string): CapStatus {
416
+ const lines = content.split("\n");
417
+ let i = lines.length - 1;
418
+ while (i >= 0 && lines[i].trim().length === 0) i--;
419
+ if (i < 0) return null;
420
+ const marker = lines[i];
421
+ if (!marker.startsWith(EXIT_MARKER)) return null;
422
+ const truncated = marker.match(/ truncated=(\d+)/);
423
+ if (truncated) return { kind: "truncated", bytes: parseInt(truncated[1], 10) };
424
+ if (marker.includes(EXIT_MARKER_NOCAP_FLAG)) return { kind: "ceiling-failed" };
425
+ return null;
426
+ }
427
+
428
+ // The byte ceiling a log hit, or null when it was not capped. Thin accessor over
429
+ // the marker parse, kept because most callers only care about the number.
430
+ export function parseTruncationFromContent(content: string): number | null {
431
+ const status = parseCapStatusFromContent(content);
432
+ return status?.kind === "truncated" ? status.bytes : null;
433
+ }
434
+
435
+ // The marker is written within the last few hundred bytes of the log, so the
436
+ // standard tail slice decides this — no full read, even at the ceiling.
437
+ function readCapStatus(logPath: string): CapStatus {
438
+ const slice = readLogSlice(logPath, LOG_TAIL_BYTES);
439
+ if (!slice) return null;
440
+ return parseCapStatusFromContent(slice.content);
441
+ }
442
+
443
+ // Compact byte size for the wake and reader notes ("64 MiB", "1.5 KiB",
444
+ // "900 bytes"). One decimal is enough: this labels a ceiling, not a quantity.
445
+ export function formatBytes(n: number): string {
446
+ if (n >= 1024 * 1024) return `${Math.round((n / (1024 * 1024)) * 10) / 10} MiB`;
447
+ if (n >= 1024) return `${Math.round((n / 1024) * 10) / 10} KiB`;
448
+ return `${n} bytes`;
449
+ }
450
+
451
+ function readLastLogLine(logPath: string, maxLen = 200): string | null {
452
+ const slice = readLogSlice(logPath, LOG_TAIL_BYTES);
453
+ if (!slice) return null;
454
+ return readLastLineFromContent(slice.content, maxLen);
455
+ }
456
+
457
+ function readLastLineFromContent(content: string, maxLen = 200): string | null {
458
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
459
+ if (lines.length === 0) return null;
460
+ const real = lines.filter((l) => !isWrapperLine(l));
461
+ // No content lines (a marker-only log) → nothing to show. Never fall back to
462
+ // the exit-marker line — that leaks "__BGRUN_EXIT__=N" into the wake.
463
+ if (real.length === 0) return null;
464
+ const last = real[real.length - 1];
465
+ return last.length > maxLen ? last.slice(0, maxLen) + "…" : last;
466
+ }
467
+
468
+ function validateJobId(id: string, tool: string): void {
469
+ if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) {
470
+ throw new Error(`${tool}: invalid job id ${JSON.stringify(id)}`);
471
+ }
472
+ }
473
+
474
+ function isRunningPid(pid: number): boolean {
475
+ if (pid <= 0) return false;
476
+ try {
477
+ process.kill(pid, 0);
478
+ return true;
479
+ } catch (err) {
480
+ const code = (err as NodeJS.ErrnoException).code;
481
+ // EPERM means the process exists but we can't signal it — treat as alive.
482
+ return code === "EPERM";
483
+ }
484
+ }
485
+
486
+ function pidFromId(id: string): number | null {
487
+ const parts = id.split("-");
488
+ const pid = parseInt(parts[parts.length - 1], 10);
489
+ return Number.isFinite(pid) ? pid : null;
490
+ }
491
+
492
+ // One entry per *.log in a jobs dir, with the derived state every caller needs
493
+ // (finish marker, owning pid + liveness, timestamps). This is the single scan
494
+ // used by cleanup, foreign-job adoption, and bgstatus — they used to each
495
+ // re-implement the readdir/filter/parse/pid dance and drifted apart.
496
+ interface ScannedLogFile {
497
+ id: string;
498
+ logPath: string;
499
+ pid: number | null; // pid encoded in the id's last segment
500
+ alive: boolean; // pid > 0 and signalable (or EPERM)
501
+ mtimeMs: number;
502
+ birthtimeMs: number;
503
+ }
504
+
505
+ interface ScannedLog extends ScannedLogFile {
506
+ exit: number | null; // parsed __BGRUN_EXIT__ marker, null while running
507
+ }
508
+
509
+ // The scan WITHOUT the exit-marker read. Exit parsing needs a tail read of the
510
+ // file, so callers that can filter by mtime first (cleanup) use this and pay
511
+ // for the read only on files they may actually act on.
512
+ function scanLogFiles(jobsDir: string): ScannedLogFile[] {
513
+ let names: string[];
514
+ try {
515
+ names = readdirSync(jobsDir);
516
+ } catch {
517
+ return []; // jobs dir doesn't exist — nothing to scan
518
+ }
519
+ const out: ScannedLogFile[] = [];
520
+ for (const name of names) {
521
+ if (!name.endsWith(".log")) continue;
522
+ // .tmp-*.log is the pre-rename staging file (see the spawn path). It is
523
+ // never a job — a crashed spawn can leave one behind; sweepStaleMarkers
524
+ // reclaims it.
525
+ if (name.startsWith(".tmp-")) continue;
526
+ const logPath = join(jobsDir, name);
527
+ let st: ReturnType<typeof statSync>;
528
+ try {
529
+ st = statSync(logPath);
530
+ } catch {
531
+ continue; // vanished between readdir and stat
532
+ }
533
+ const pid = pidFromId(name.slice(0, -".log".length));
534
+ out.push({
535
+ id: name.slice(0, -".log".length),
536
+ logPath,
537
+ pid,
538
+ alive: pid !== null && pid > 0 && isRunningPid(pid),
539
+ mtimeMs: st.mtimeMs,
540
+ birthtimeMs: st.birthtimeMs,
541
+ });
542
+ }
543
+ return out;
544
+ }
545
+
546
+ // The full scan (exit marker resolved) for callers that need finished/running
547
+ // state for every entry.
548
+ function scanJobsDir(jobsDir: string): ScannedLog[] {
549
+ return scanLogFiles(jobsDir).map((e) => ({
550
+ ...e,
551
+ exit: parseExitFromLogPath(e.logPath),
552
+ }));
553
+ }
554
+
555
+ // Redact obvious credential values before they reach a filename, widget, or
556
+ // status line. The raw command still appears in the wake message (needed for
557
+ // context), but the persisted job id / slug is a much longer-lived leak
558
+ // channel (it survives in filenames and `bgstatus` output for cleanupDays).
559
+ // ── Secret redaction for slugs ─────────────────────────────────────────────
560
+ // A job id becomes a filename, and filenames get listed, shared, and scraped.
561
+ // Commands routinely embed credentials, so redact values BEFORE they reach a
562
+ // slug. This deliberately errs toward over-redaction: a mangled slug is
563
+ // cosmetic, a leaked token is not.
564
+
565
+ // Secret-ish key words, matched as a substring of a longer key (GH_TOKEN,
566
+ // AWS_SECRET_ACCESS_KEY, DB_PASSWORD) with a trailing non-letter guard so
567
+ // "author"/"designer" are not mistaken for "auth"/"sig".
568
+ const SECRET_KEY_WORDS =
569
+ "authorization|pass(?:word|wd|phrase)?|passw(?:or)?d|secret|token|" +
570
+ "api[-_]?key|apikey|access[-_]?key|private[-_]?key|client[-_]?secret|" +
571
+ "credential(?:s)?|session[-_]?id|signature|pwd|bearer|auth";
572
+ // A key: optional surrounding word chars/dots/dashes, then a secret word.
573
+ const SECRET_KEY = String.raw`[A-Za-z0-9_.-]*(?:${SECRET_KEY_WORDS})(?![A-Za-z])`;
574
+ // A value: a quoted string, a `scheme credential` pair ("Bearer abc"), or a
575
+ // bare token. The scheme form is tried first so the credential after it is
576
+ // consumed too — otherwise "Authorization: Bearer abc" redacts only "Bearer".
577
+ const SECRET_VALUE = String.raw`(?:'[^']*'|"[^"]*"|(?:bearer|basic|token|digest)\s+\S+|\S+)`;
578
+ const SECRET_ASSIGN_RE = new RegExp(
579
+ String.raw`(${SECRET_KEY})["']?\s*[:=]\s*["']?${SECRET_VALUE}`,
580
+ "gi",
581
+ );
582
+ const SECRET_FLAG_RE = new RegExp(
583
+ String.raw`(^|\s)(-{1,2}${SECRET_KEY})(\s*[:=]\s*|\s+)["']?${SECRET_VALUE}`,
584
+ "gi",
585
+ );
586
+
587
+ export function redactForSlug(command: string): string {
588
+ return (
589
+ command
590
+ // Header arguments: -H stays CASE-SENSITIVE (so a lower-case `-h`/help
591
+ // flag is never mangled); --header is case-insensitive. The whole
592
+ // argument is consumed — any header can carry a token.
593
+ .replace(/(^|\s)-H(=|\s+)('[^']*'|"[^"]*"|\S+)/g, "$1-H$2-REDACTED")
594
+ .replace(
595
+ /(^|\s)--header(=|\s+)('[^']*'|"[^"]*"|\S+)/gi,
596
+ "$1--header$2-REDACTED",
597
+ )
598
+ // curl -u user:pass / --user user:pass (only when it looks like a pair,
599
+ // so unrelated flags like `sort -u` are left alone).
600
+ .replace(/(^|\s)-u(\s+)([^\s:]+:[^\s]+)/g, "$1-u$2-REDACTED")
601
+ .replace(/(^|\s)--user(\s+)([^\s:]+:[^\s]+)/g, "$1--user$2-REDACTED")
602
+ // URL userinfo: scheme://user:pass@host.
603
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+)@/gi, "$1-REDACTED@")
604
+ // KEY=value / KEY: value, including quoted JSON ("password":"x").
605
+ .replace(SECRET_ASSIGN_RE, "$1-REDACTED")
606
+ // --flag value / --flag=value / --flag: value.
607
+ .replace(SECRET_FLAG_RE, "$1$2-REDACTED")
608
+ );
609
+ }
610
+
611
+ function resolveGitCommonDir(gitDir: string): string {
612
+ const commonFile = join(gitDir, "commondir");
613
+ if (!existsSync(commonFile)) return gitDir;
614
+ try {
615
+ const rel = readFileSync(commonFile, "utf8").trim();
616
+ return isAbsolute(rel) ? rel : join(gitDir, rel);
617
+ } catch {
618
+ return gitDir;
619
+ }
620
+ }
621
+
622
+ function ensureJobsDirMarker(jobsDir: string): void {
623
+ try {
624
+ mkdirSync(jobsDir, { recursive: true });
625
+ const marker = join(jobsDir, JOBS_DIR_MARKER);
626
+ if (!existsSync(marker)) writeFileSync(marker, "");
627
+ } catch {
628
+ // best-effort
629
+ }
630
+ }
631
+
632
+ function logReadError(id: string, logPath: string): string {
633
+ if (existsSync(logPath)) {
634
+ return `Log for job ${id} at ${logPath} exists but could not be read (file may be too large or unreadable)`;
635
+ }
636
+ return `No log found for job ${id} at ${logPath}`;
637
+ }
638
+
68
639
  // ── Configuration ───────────────────────────────────────────────────────────
69
640
  //
70
641
  // Layered: defaults ← user config file ← project config file (trusted projects
@@ -76,9 +647,10 @@ export const DEFAULT_GREP_PATTERN =
76
647
 
77
648
  interface BgrunConfig {
78
649
  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.
650
+ // True when jobsDir resolves inside the project root (the default in a
651
+ // recognizable project, or an explicit RELATIVE path). Only then does bgrun
652
+ // auto-ignore the dir in .git/info/exclude — an absolute dir is the user's
653
+ // explicit choice.
82
654
  jobsDirProjectLocal: boolean;
83
655
  // Adopt other sessions' running jobs (found in the shared jobs dir) into
84
656
  // this session's widget and job list. Default false — most sessions don't
@@ -89,14 +661,32 @@ interface BgrunConfig {
89
661
  showCompletedJobs: boolean;
90
662
  // Log retention for cleanup (auto-sweeps and the bgclean default).
91
663
  cleanupDays: number;
92
- // Auto-sweep the WHOLE shared jobs dir at session boundaries for orphans
664
+ // Byte ceiling for a job's log (stdout+stderr). A runaway job (`yes`, a spew
665
+ // loop) would otherwise fill the disk. The cap keeps the FIRST maxLogBytes
666
+ // bytes and appends a truncation notice; the job itself runs to completion
667
+ // with its real exit code. 0 = unlimited. Read per job at spawn time.
668
+ maxLogBytes: number;
669
+ // Auto-sweep the shared jobs dirs at session boundaries for orphans —
93
670
  // finished (exit marker or dead pid) logs older than cleanupDays from
94
- // sessions that crashed or are never resumed again. Running jobs are always
671
+ // sessions that crashed or are never resumed again. Both the machine-global
672
+ // dir and the current project's dir are swept (see sharedJobsDirs), so
673
+ // pre-project-local logs are still reclaimed. Running jobs are always
95
674
  // pid-protected. Throttled to once per cleanupDays via a .last-clean marker.
96
675
  // Default true — without it, orphaned logs accumulate forever. Set false to
97
676
  // keep every sweep session-scoped (then only `bgclean all` touches foreign
98
677
  // logs).
99
678
  globalAutoClean: boolean;
679
+ // Opt-in digest scorecards, normalized to an ordered list of entries (or
680
+ // undefined when unconfigured or fully invalid — an empty array is normalized
681
+ // to undefined so the session_start nudge still sees "not configured"). The
682
+ // object form is normalized to a single entry with no matchers. Each entry
683
+ // carries an optional `match` (globs against the job name / command line),
684
+ // an optional wake `label`, and a `preset` or custom `command`. At wake time
685
+ // the FIRST matching entry wins. Presets are shipped sh commands (see
686
+ // digestPresets.ts); command receives the job's log path as $1. Resolved from
687
+ // trusted project config only — never runs pattern matching unless the
688
+ // project opted in.
689
+ digest?: DigestEntry[];
100
690
  }
101
691
 
102
692
  interface BgrunConfigFile {
@@ -104,7 +694,9 @@ interface BgrunConfigFile {
104
694
  adoptForeignJobs?: unknown;
105
695
  showCompletedJobs?: unknown;
106
696
  cleanupDays?: unknown;
697
+ maxLogBytes?: unknown;
107
698
  globalAutoClean?: unknown;
699
+ digest?: unknown;
108
700
  }
109
701
 
110
702
  function parseBoolEnv(v: string | undefined): boolean | undefined {
@@ -116,27 +708,154 @@ function parseBoolEnv(v: string | undefined): boolean | undefined {
116
708
  }
117
709
 
118
710
  function readConfigFile(path: string): BgrunConfigFile {
711
+ let text: string;
712
+ try {
713
+ text = readFileSync(path, "utf8");
714
+ } catch {
715
+ return {}; // missing — normal, not an error
716
+ }
119
717
  try {
120
- const raw = JSON.parse(readFileSync(path, "utf8"));
718
+ const raw = JSON.parse(text);
121
719
  if (raw && typeof raw === "object" && !Array.isArray(raw))
122
720
  return raw as BgrunConfigFile;
123
- } catch {
124
- // missing or malformedtreat as empty
721
+ console.error(
722
+ `[pi-bgrun] config ${path} is not a JSON object ignoring its contents`,
723
+ );
724
+ } catch (err) {
725
+ console.error(
726
+ `[pi-bgrun] config ${path} is malformed JSON (${(err as Error).message}) — ignoring its contents`,
727
+ );
125
728
  }
126
729
  return {};
127
730
  }
128
731
 
732
+ // A byte-budget copier that writes as it reads: keep the first $cap bytes of
733
+ // stdin on stdout, flag (O_EXCL, 0600) if anything was left over, and drain the
734
+ // rest so the producer never gets SIGPIPE. Used as the drain when perl is
735
+ // available: `dd` and `head` are the portable choices, but both buffer their
736
+ // output — measured: nothing on disk until 4-8 KiB accumulated, so a running
737
+ // capped job's log looks stalled for a slow producer, breaking the live-tail
738
+ // workflow bgtail documents. perl's sysread/syswrite has no stdio buffering, so
739
+ // the first byte lands immediately. The program avoids quotes so it can be
740
+ // single-quoted in the wrapper.
741
+ const PERL_CAP_COPIER = [
742
+ `use Fcntl;`,
743
+ `my $cap = $ARGV[0]; my $flag = $ARGV[1]; my $left = $cap; my $over = 0; my $flagged = 0; my $buf;`,
744
+ `while (1) {`,
745
+ ` my $n = sysread(STDIN, $buf, 65536);`,
746
+ ` last if !defined($n) || $n == 0;`,
747
+ ` if ($left > 0) {`,
748
+ ` my $take = $n < $left ? $n : $left;`,
749
+ ` my $off = 0;`,
750
+ ` while ($off < $take) { my $w = syswrite(STDOUT, $buf, $take - $off, $off); last if !defined($w) || $w <= 0; $off += $w; }`,
751
+ ` $left -= $off;`,
752
+ ` $over = 1 if $n > $take;`,
753
+ ` } else { $over = 1; }`,
754
+ ` if ($over && !$flagged) { my $fh; if (sysopen($fh, $flag, O_WRONLY | O_CREAT | O_EXCL, 0600)) { close($fh); } $flagged = 1; }`,
755
+ `}`,
756
+ ].join("\n");
757
+
758
+ // ── Job wrapper ─────────────────────────────────────────────────────────────
759
+ //
760
+ // Every job runs inside a detached `sh -c` tree, so the log ceiling has to live
761
+ // there too — it must hold after pi exits. The command is passed as argv ($1),
762
+ // never interpolated, or `#`, quotes and heredocs would break.
763
+ //
764
+ // Capped shape: the command runs as its OWN background job writing into a fifo;
765
+ // a drain copies at most `cap` bytes of that into the log and then reports
766
+ // whether anything was left over. Two properties drive that structure:
767
+ //
768
+ // - Completion must follow the COMMAND, not the data flow. As a pipeline stage,
769
+ // `wait` would return only when every holder of the pipe's write end closes
770
+ // it — and a child the command backgrounded (`server &`, a watcher, a
771
+ // daemonized tool) inherited that fd, so the job would never wake while the
772
+ // child lived. `wait "$prod"` returns when `sh -c` is reaped; the strays keep
773
+ // running, they just stop being logged (which is the point of a ceiling).
774
+ // - The drain may outlive the command, so truncation is reported through a flag
775
+ // FILE, and the wrapper's exit marker carries the machine-readable flag. A
776
+ // notice line in the log is not evidence: a command can print the same text,
777
+ // and only the LAST marker counts, so the marker is the one line a command
778
+ // cannot forge.
779
+ //
780
+ // The drain's byte budget is exact only with `dd iflag=fullblock` (each block is
781
+ // filled before it counts): plain `dd` counts READS, so a slow writer would
782
+ // exhaust the budget without filling the cap. Without `iflag` (most non-GNU
783
+ // systems) `head -c` is used instead — exact, but block-buffered, so a running
784
+ // job's log lags by up to 8 KiB until the job exits.
785
+ //
786
+ // If `mkfifo` fails, fall back to the uncapped path: losing output is worse than
787
+ // losing the ceiling — but say so, in the log and in the marker.
788
+ //
789
+ // argv: $1 command, $2 ecfile, $3 fifo, $4 pidfile, $5 truncation flag.
790
+ export function cappedWrapper(maxBytes: number): string {
791
+ const cap = String(maxBytes);
792
+ return [
793
+ `flag=`,
794
+ // Staging names are ours: clear any leftover or planted entry first — rm
795
+ // unlinks the name and never follows a link — and every write below runs
796
+ // under `set -C` (noclobber) so a path that reappears is refused rather than
797
+ // written through. umask is scoped to those writes: the command must keep
798
+ // its own.
799
+ `rm -f "$2" "$3" "$4" "$5" 2>/dev/null`,
800
+ `if mkfifo -m 600 "$3" 2>/dev/null && [ -p "$3" ]; then`,
801
+ ` ( umask 077; set -C; printf '%d' "$$" >"$4" ) 2>/dev/null || :`,
802
+ ` { sh -c "$1" 2>&1; ec=$?; ( umask 077; set -C; printf '%d' "$ec" >"$2" ) 2>/dev/null; } >"$3" &`,
803
+ ` prod=$!`,
804
+ ` { if command -v perl >/dev/null 2>&1; then`,
805
+ // One process: cap + flag + drain, no stdio buffering (see PERL_CAP_COPIER).
806
+ ` perl -e '${PERL_CAP_COPIER}' ${cap} "$5"`,
807
+ ` elif dd iflag=fullblock bs=1 count=0 </dev/null >/dev/null 2>&1; then`,
808
+ // Exact, but block-buffered: the log lags by up to one block while the job
809
+ // runs. (Plain `dd` is worse: it counts READS, so a slow writer exhausts the
810
+ // budget without filling the cap and later output is dropped.)
811
+ ` { dd iflag=fullblock bs=4096 count=$(( ${cap} / 4096 )) 2>/dev/null; dd iflag=fullblock bs=1 count=$(( ${cap} % 4096 )) 2>/dev/null; }`,
812
+ ` if [ "$(dd bs=1 count=1 2>/dev/null | wc -c)" -gt 0 ]; then ( umask 077; set -C; : >"$5" ) 2>/dev/null || :; fi`,
813
+ ` cat >/dev/null`,
814
+ ` else`,
815
+ ` head -c ${cap}`,
816
+ ` if [ "$(dd bs=1 count=1 2>/dev/null | wc -c)" -gt 0 ]; then ( umask 077; set -C; : >"$5" ) 2>/dev/null || :; fi`,
817
+ ` cat >/dev/null`,
818
+ ` fi; } <"$3" &`,
819
+ ` drain=$!`,
820
+ ` wait "$prod"`,
821
+ // The command is done. The drain copies unbuffered, so there is nothing to
822
+ // flush — this short bounded wait only gives it the moment it needs to
823
+ // notice EOF. A child the command backgrounded and did not wait for can hold
824
+ // the fifo open indefinitely; the job must complete anyway (and that stray's
825
+ // output simply stops being logged, which is what a ceiling is for). Note
826
+ // that after the byte budget is spent only the discard stage remains, so
827
+ // nothing can be written to the log after these notices.
828
+ ` j=0`,
829
+ ` while kill -0 "$drain" 2>/dev/null && [ "$j" -lt 5 ]; do sleep 0.02; j=$((j + 1)); done`,
830
+ ` ec=$(if [ -f "$2" ]; then cat "$2" 2>/dev/null; fi)`,
831
+ ` if [ -e "$5" ]; then`,
832
+ ` printf '\\n${TRUNC_NOTICE_PREFIX}: kept the first %s bytes\\n' ${cap}`,
833
+ ` flag="${EXIT_MARKER_TRUNC_FLAG}${cap}"`,
834
+ ` fi`,
835
+ `else`,
836
+ ` sh -c "$1" 2>&1`,
837
+ ` ec=$?`,
838
+ ` printf '\\n${CAPFAIL_NOTICE_PREFIX} (mkfifo failed, so this job ran uncapped)\\n'`,
839
+ ` flag="${EXIT_MARKER_NOCAP_FLAG}"`,
840
+ `fi`,
841
+ `rm -f "$2" "$3" "$4" "$5" 2>/dev/null`,
842
+ `[ -n "$ec" ] || ec=-1`,
843
+ `printf '\\n%s%d%s\\n' "${EXIT_MARKER}" "$ec" "\${flag}"`,
844
+ `exit "$ec"`,
845
+ ].join("\n");
846
+ }
847
+
129
848
  // ── Project-local jobs dir ──────────────────────────────────────────────────
130
849
  //
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.
850
+ // By default, when the session cwd is inside a recognizable project root
851
+ // (`.git` or `.pi`), logs land at `<project>/.pi-bgrun/jobs`. The root is found
852
+ // by walking up from the cwd, so a session started in a subdirectory still
853
+ // resolves project-locally. With no project root the default falls back to the
854
+ // machine-global `~/.pi-bgrun/jobs`. An explicit RELATIVE `jobsDir` (from any
855
+ // config layer, or PI_BGRUN_DIR) resolves the same way; an absolute path is
856
+ // used as-is (migration-safe). Project-local logs stay inside the workspace
857
+ // sandbox so analysis tools confined to the project root (e.g. context-mode's
858
+ // ctx_execute_file/ctx_index) can process whole logs without flooding context.
140
859
 
141
860
  function isProjectRootLike(dir: string): boolean {
142
861
  // Cheap heuristic: a directory holding .git or pi's config dir is a project.
@@ -145,17 +864,84 @@ function isProjectRootLike(dir: string): boolean {
145
864
  );
146
865
  }
147
866
 
867
+ // realpath that never throws: a non-existent or unreadable path falls back to
868
+ // the literal path so callers can compare paths without guarding every step.
869
+ function safeRealpath(p: string): string {
870
+ try {
871
+ return realpathSync(p);
872
+ } catch {
873
+ return p;
874
+ }
875
+ }
876
+
877
+ // Nearest ancestor of `start` (inclusive) that looks like a project root.
878
+ // The user's home directory is never treated as a project root: pi's global
879
+ // agent dir (~/.pi/agent) would otherwise make every cwd under $HOME resolve
880
+ // to $HOME. Paths are canonicalized so a symlinked $HOME is still recognized.
881
+ function findProjectRoot(
882
+ start: string,
883
+ home: string = homeDir(),
884
+ ): string | undefined {
885
+ const homeReal = safeRealpath(home);
886
+ let cur = start;
887
+ for (;;) {
888
+ if (safeRealpath(cur) !== homeReal && isProjectRootLike(cur)) return cur;
889
+ const parent = dirname(cur);
890
+ if (parent === cur || safeRealpath(cur) === homeReal) return undefined;
891
+ cur = parent;
892
+ }
893
+ }
894
+
895
+ // The user's home directory, HOME-first. Node's os.homedir() already resolves
896
+ // HOME before falling back to the passwd entry, but Bun's ignores HOME — so
897
+ // deriving it here keeps `~`, the machine-global jobs dir and the project-root
898
+ // exclusion identical under both runtimes (and lets tests pin HOME).
899
+ function homeDir(): string {
900
+ return process.env.HOME || homedir();
901
+ }
902
+
903
+ // Expand a leading `~` (bare or `~/...`) to the user's home directory so a
904
+ // config/env path like `~/.pi-bgrun/jobs` is absolute rather than a relative
905
+ // path interpreted project-locally.
906
+ function expandTilde(p: string): string {
907
+ if (p === "~") return homeDir();
908
+ if (p.startsWith("~/")) return join(homeDir(), p.slice(2));
909
+ return p;
910
+ }
911
+
912
+ // Project/worktree root for identity keys — the enclosing project root when
913
+ // there is one, else the directory itself (so a non-project cwd still gets a
914
+ // stable key). Matches the root resolveJobsDirPath uses for project-local
915
+ // logs, so two cwds in the same checkout share one digest-nudge key.
916
+ function projectRootFor(dir: string): string {
917
+ return findProjectRoot(dir) ?? dir;
918
+ }
919
+
148
920
  export function resolveJobsDirPath(
149
921
  raw: string | undefined,
150
- ctx?: { cwd?: string },
922
+ ctx?: { cwd?: string; home?: string },
151
923
  ): { 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 };
924
+ const p = raw ? expandTilde(raw) : raw;
925
+ // Absolute paths are the user's explicit choice: used as-is, never flagged
926
+ // project-local, and no ancestor walk needed.
927
+ if (p && isAbsolute(p)) return { dir: p, projectLocal: false };
928
+ const cwd = ctx?.cwd ?? process.cwd();
929
+ const root = cwd ? findProjectRoot(cwd, ctx?.home) : undefined;
930
+ if (!p) {
931
+ return root
932
+ ? { dir: join(root, PROJECT_LOCAL_JOBS_REL), projectLocal: true }
933
+ : { dir: globalJobsDir(), projectLocal: false };
157
934
  }
158
- return { dir: join(root, raw), projectLocal: true };
935
+ if (!root) return { dir: globalJobsDir(), projectLocal: false };
936
+ // A relative path can escape the project root ("../outside"); only flag it
937
+ // project-local when the joined dir actually stays inside the root, so git
938
+ // exclusion and the untrusted-repo guard apply to the right thing.
939
+ const joined = join(root, p);
940
+ const rel = relative(root, joined);
941
+ // `..foo` is a sibling, not an escape — only `..` itself or `../` escapes.
942
+ const inside =
943
+ rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
944
+ return { dir: joined, projectLocal: inside };
159
945
  }
160
946
 
161
947
  // Auto-ignore a project-local jobs dir in git so logs never pollute
@@ -209,11 +995,13 @@ function appendExcludePattern(
209
995
  if (!m) return false; // unparseable .git file — retry later
210
996
  gitDir = m[1].trim();
211
997
  }
998
+ gitDir = resolveGitCommonDir(gitDir);
212
999
  const rel = relative(repoRoot, jobsDir);
213
1000
  // Defense-in-depth: the walk-up guarantees jobsDir sits under repoRoot, but
214
1001
  // a future caller or symlinked path could break that — ../-prefixed
215
1002
  // patterns are silently useless in gitignore semantics, so skip them.
216
- if (rel.startsWith("..") || isAbsolute(rel)) return true;
1003
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel))
1004
+ return true;
217
1005
  const pattern = rel.split(sep).join("/") + "/";
218
1006
  const excludePath = join(gitDir, "info", "exclude");
219
1007
  let existing = "";
@@ -231,18 +1019,222 @@ function appendExcludePattern(
231
1019
  return true;
232
1020
  }
233
1021
 
1022
+ // Digest config validation: invalid values are dropped from the resolved
1023
+ // config (best-effort — a malformed digest section must never break a wake or
1024
+ // the whole config), but the human gets one console.error per distinct invalid
1025
+ // field so typos are discoverable without flooding the log. The field set is a
1026
+ // fixed, code-defined list (preset / command / match / type / ...), so the
1027
+ // dedupe set is naturally bounded.
1028
+ const digestWarned = new Set<string>();
1029
+ function warnDigestInvalid(field: string, value: unknown): void {
1030
+ if (digestWarned.has(field)) return;
1031
+ digestWarned.add(field);
1032
+ const hint =
1033
+ field === "preset"
1034
+ ? ` — valid presets: ${DIGEST_PRESET_IDS.join(", ")}`
1035
+ : "";
1036
+ // Point at the array form too: the same `digest` key accepts an ordered
1037
+ // list of { type, match, label, preset, command } entries.
1038
+ const shapeHint =
1039
+ " — digest takes an object or an array of { type, match, label, preset, command } entries";
1040
+ // field "" means the whole `digest` section was unusable (wrong shape).
1041
+ const where = field ? `digest.${field}` : "digest";
1042
+ console.error(
1043
+ `[pi-bgrun] ignoring invalid ${where} in pi-bgrun.json: ${JSON.stringify(value)}${hint}${shapeHint}`,
1044
+ );
1045
+ }
1046
+
1047
+ function projectHash(projectDir: string): string {
1048
+ return createHash("sha256").update(projectDir).digest("hex").slice(0, 16);
1049
+ }
1050
+
1051
+ /**
1052
+ * Per-project "this project has run a bgrun job" marker in the jobs dir,
1053
+ * keyed by the project/worktree root (the enclosing root found by walking up
1054
+ * from cwd, falling back to cwd itself). Written (best-effort) at spawn and
1055
+ * read at session_start by the digest nudge, so evidence of use stays
1056
+ * project-scoped even when the jobs dir is shared (an absolute/global
1057
+ * `jobsDir`); project-local dirs get the same per-project key harmlessly.
1058
+ */
1059
+ function projectMarkerPath(
1060
+ jobsDir: string,
1061
+ projectDir: string,
1062
+ prefix: string,
1063
+ ): string {
1064
+ return join(jobsDir, `${prefix}${projectHash(projectDir)}`);
1065
+ }
1066
+
1067
+ export function jobUsageMarkerPath(
1068
+ jobsDir: string,
1069
+ projectDir: string,
1070
+ ): string {
1071
+ return projectMarkerPath(jobsDir, projectDir, ".bgrun-used-");
1072
+ }
1073
+
1074
+ /**
1075
+ * Per-project marker path for the one-shot digest nudge. When the jobs dir is
1076
+ * shared (an absolute/global `jobsDir`), a bare `.digest-nudge-done` marker
1077
+ * would silence the nudge for every other project after the first to earn it;
1078
+ * keying by the project/worktree root gives each project its own one-shot.
1079
+ * Under the project-local default the dir is already per-project, so the key
1080
+ * is redundant but harmless.
1081
+ */
1082
+ export function digestNudgeMarkerPath(
1083
+ jobsDir: string,
1084
+ projectDir: string,
1085
+ ): string {
1086
+ return projectMarkerPath(jobsDir, projectDir, ".digest-nudge-");
1087
+ }
1088
+
1089
+ /**
1090
+ * One-shot session_start toast for a trusted project with no digest
1091
+ * configured (see maybeNudgeDigest). Exported so tests assert the real string.
1092
+ */
1093
+ export const DIGEST_NUDGE_TEXT =
1094
+ "pi-bgrun: no digest configured for this project — use the digest-config skill to set one up.";
1095
+
1096
+ // Job and config `type` values are short routing tokens. Both sides cap at the
1097
+ // same length; if only the job side truncated, a >MAX_TYPE_LEN config type
1098
+ // would silently never match the job's truncated type.
1099
+ const MAX_TYPE_LEN = 40;
1100
+ const MAX_LABEL_LEN = 60;
1101
+
1102
+ /**
1103
+ * Trim, lowercase, and cap a job or config `type`. Non-string or blank →
1104
+ * undefined. Both the bgrun param and the digest config go through here so
1105
+ * their truncation can never drift apart.
1106
+ */
1107
+ function normalizeType(raw: unknown): string | undefined {
1108
+ if (typeof raw !== "string") return undefined;
1109
+ const trimmed = raw.trim().toLowerCase();
1110
+ if (!trimmed) return undefined;
1111
+ return trimmed.slice(0, MAX_TYPE_LEN);
1112
+ }
1113
+
1114
+ // Normalize one digest entry from the object-or-array config. Best-effort:
1115
+ // anything unusable is dropped (never throws). An entry without a usable
1116
+ // preset or command contributes nothing; a non-string `match` field drops the
1117
+ // whole entry (the human gets the one-time warning).
1118
+ function normalizeDigestEntry(raw: unknown): DigestEntry | undefined {
1119
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
1120
+ const entry = raw as {
1121
+ type?: unknown;
1122
+ match?: unknown;
1123
+ label?: unknown;
1124
+ preset?: unknown;
1125
+ command?: unknown;
1126
+ };
1127
+
1128
+ // `type` and `match` compose (AND): both are kept and both must match at
1129
+ // selection time. An invalid `type` (present but not a non-empty string)
1130
+ // drops the whole entry (same best-effort policy as an invalid `match`).
1131
+ // normalizeType() applies the same trim/lowercase/cap the job side uses, so
1132
+ // a long config type still matches the (truncated) long job type.
1133
+ let type: string | undefined;
1134
+ if (entry.type !== undefined) {
1135
+ type = normalizeType(entry.type);
1136
+ if (!type) {
1137
+ warnDigestInvalid("type", entry.type);
1138
+ return undefined;
1139
+ }
1140
+ }
1141
+
1142
+ let match: DigestMatch | undefined;
1143
+ if (entry.match !== undefined) {
1144
+ if (
1145
+ !entry.match ||
1146
+ typeof entry.match !== "object" ||
1147
+ Array.isArray(entry.match)
1148
+ ) {
1149
+ warnDigestInvalid("match", entry.match);
1150
+ return undefined;
1151
+ }
1152
+ const rawMatch = entry.match as { name?: unknown; command?: unknown };
1153
+ const normalized: DigestMatch = {};
1154
+ // A glob pattern is any string; a blank one is treated as absent so it
1155
+ // doesn't constrain matching. A non-string is invalid → drop the entry.
1156
+ if (rawMatch.name !== undefined) {
1157
+ if (typeof rawMatch.name !== "string") {
1158
+ warnDigestInvalid("match.name", rawMatch.name);
1159
+ return undefined;
1160
+ }
1161
+ if (rawMatch.name.trim()) normalized.name = rawMatch.name;
1162
+ }
1163
+ if (rawMatch.command !== undefined) {
1164
+ if (typeof rawMatch.command !== "string") {
1165
+ warnDigestInvalid("match.command", rawMatch.command);
1166
+ return undefined;
1167
+ }
1168
+ if (rawMatch.command.trim()) normalized.command = rawMatch.command;
1169
+ }
1170
+ if (normalized.name !== undefined || normalized.command !== undefined) {
1171
+ match = normalized;
1172
+ }
1173
+ }
1174
+
1175
+ let preset: string | undefined;
1176
+ if (entry.preset !== undefined) {
1177
+ if (
1178
+ typeof entry.preset === "string" &&
1179
+ DIGEST_PRESET_IDS.includes(entry.preset)
1180
+ ) {
1181
+ preset = entry.preset;
1182
+ } else {
1183
+ warnDigestInvalid("preset", entry.preset);
1184
+ }
1185
+ }
1186
+
1187
+ let command: string | undefined;
1188
+ if (entry.command !== undefined) {
1189
+ if (typeof entry.command === "string" && entry.command.trim()) {
1190
+ command = entry.command;
1191
+ } else {
1192
+ warnDigestInvalid("command", entry.command);
1193
+ }
1194
+ }
1195
+
1196
+ // Neither preset nor command → nothing this entry can score. Drop it.
1197
+ if (!preset && !command) return undefined;
1198
+
1199
+ const out: DigestEntry = {};
1200
+ if (type) out.type = type;
1201
+ if (match) out.match = match;
1202
+ if (typeof entry.label === "string" && entry.label.trim()) {
1203
+ out.label = entry.label.trim().slice(0, MAX_LABEL_LEN);
1204
+ }
1205
+ if (preset) out.preset = preset;
1206
+ if (command) out.command = command;
1207
+ return out;
1208
+ }
1209
+
234
1210
  // Resolved per call (cheap: at most two small file reads) so env/config
235
1211
  // changes are picked up without module reloads — and tests can isolate.
236
- function resolveConfig(ctx?: {
1212
+ // Exported for tests, like formatSince.
1213
+ export function resolveConfig(ctx?: {
237
1214
  cwd?: string;
238
1215
  isProjectTrusted?: () => boolean;
1216
+ // Test seam: an explicit path, immutable for the process. Tests may also
1217
+ // simply pin HOME — homeDir() honors it under every runtime, unlike Bun's
1218
+ // os.homedir().
1219
+ userConfigPath?: string;
239
1220
  }): BgrunConfig {
240
- const user = readConfigFile(join(homedir(), ".pi", "agent", "pi-bgrun.json"));
1221
+ // User config: $HOME/.pi/agent/pi-bgrun.json. Overridable by an explicit
1222
+ // test seam (ctx.userConfigPath) and by PI_BGRUN_USER_CONFIG (mirrors the
1223
+ // PI_BGRUN_DIR escape hatch).
1224
+ const user = readConfigFile(
1225
+ ctx?.userConfigPath ??
1226
+ process.env.PI_BGRUN_USER_CONFIG ??
1227
+ join(homeDir(), ".pi", "agent", "pi-bgrun.json"),
1228
+ );
241
1229
  let project: BgrunConfigFile = {};
242
1230
  try {
243
1231
  if (ctx?.isProjectTrusted?.()) {
1232
+ // Read the project config from the same root resolveJobsDirPath uses, so
1233
+ // a session started in a subdirectory still picks up <root>/.pi config.
1234
+ const cwd = ctx.cwd ?? process.cwd();
1235
+ const projectRoot = projectRootFor(cwd);
244
1236
  project = readConfigFile(
245
- join(ctx.cwd ?? process.cwd(), CONFIG_DIR_NAME, "pi-bgrun.json"),
1237
+ join(projectRoot, CONFIG_DIR_NAME, "pi-bgrun.json"),
246
1238
  );
247
1239
  }
248
1240
  } catch {
@@ -273,8 +1265,56 @@ function resolveConfig(ctx?: {
273
1265
  : undefined;
274
1266
  const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
275
1267
  const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined;
1268
+ // Byte ceiling: unlike cleanupDays, 0 is meaningful ("unlimited"), so it is
1269
+ // accepted — but a BLANK env var is not, or an empty
1270
+ // PI_BGRUN_MAX_LOG_BYTES= would silently disable the cap. Normalization also
1271
+ // keeps the value in a range the wrapper can express: a positive fraction
1272
+ // becomes 1 (flooring it to 0 would silently mean "unlimited"), and an
1273
+ // enormous value is clamped instead of stringifying to "1e+21", which the
1274
+ // shell's `head -c`/`dd` reject — discarding every byte of job output.
1275
+ const maxBytesFile = normalizeMaxLogBytes(merged.maxLogBytes);
1276
+ const maxBytesRaw = process.env.PI_BGRUN_MAX_LOG_BYTES;
1277
+ const maxBytesEnvValue =
1278
+ maxBytesRaw === undefined || maxBytesRaw.trim() === ""
1279
+ ? NaN
1280
+ : Number(maxBytesRaw);
1281
+ const maxBytesEnv = normalizeMaxLogBytes(maxBytesEnvValue);
276
1282
  const { dir: jobsDir, projectLocal: jobsDirProjectLocal } =
277
1283
  resolveJobsDirPath(process.env.PI_BGRUN_DIR || dirFile, ctx);
1284
+ // Digest section: accept either the legacy single-object form (normalized to
1285
+ // one entry with no matchers) or an ordered array of entries. Invalid inputs
1286
+ // are dropped best-effort (warnDigestInvalid logs once per distinct field) —
1287
+ // including a present-but-unusable section (a string/number, or a list that
1288
+ // empties out). When both preset and command are valid within an entry, both
1289
+ // are kept here — resolveDigest() gives the preset precedence. An empty or
1290
+ // all-invalid section normalizes to undefined so `cfg.digest` truthiness
1291
+ // still means "configured" (the session_start nudge relies on that).
1292
+ let digest: BgrunConfig["digest"];
1293
+ if (Array.isArray(merged.digest)) {
1294
+ const entries = merged.digest
1295
+ .map((raw) => normalizeDigestEntry(raw))
1296
+ .filter((e): e is DigestEntry => e !== undefined);
1297
+ if (entries.length) {
1298
+ digest = entries;
1299
+ } else if (merged.digest.length) {
1300
+ // A non-empty list that normalized to nothing: every entry was invalid.
1301
+ warnDigestInvalid("", merged.digest);
1302
+ }
1303
+ } else if (merged.digest !== undefined && merged.digest !== null) {
1304
+ // A present non-null value that isn't a list. `null` is treated as absent.
1305
+ if (typeof merged.digest === "object") {
1306
+ const entry = normalizeDigestEntry(merged.digest);
1307
+ if (entry) {
1308
+ digest = [entry];
1309
+ } else {
1310
+ warnDigestInvalid("", merged.digest);
1311
+ }
1312
+ } else {
1313
+ // Present but not an object/list — a likely mistake like
1314
+ // `"digest": "go-test"`, which would otherwise be silently unconfigured.
1315
+ warnDigestInvalid("", merged.digest);
1316
+ }
1317
+ }
278
1318
  return {
279
1319
  jobsDir,
280
1320
  jobsDirProjectLocal,
@@ -285,10 +1325,12 @@ function resolveConfig(ctx?: {
285
1325
  completedFile ??
286
1326
  false,
287
1327
  cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
1328
+ maxLogBytes: maxBytesEnv ?? maxBytesFile ?? DEFAULT_MAX_LOG_BYTES,
288
1329
  globalAutoClean:
289
1330
  parseBoolEnv(process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN) ??
290
1331
  globalCleanFile ??
291
1332
  true,
1333
+ digest,
292
1334
  };
293
1335
  }
294
1336
 
@@ -317,15 +1359,28 @@ export function formatSince(started: number, now: number = Date.now()): string {
317
1359
  return `${ymd} ${time}`;
318
1360
  }
319
1361
 
1362
+ // Universal-stats duration formatting for the wake message's Stats line: one
1363
+ // decimal in seconds under a minute ("42.3s"), m:ss above ("5:07").
1364
+ // Exported for tests, like formatSince.
1365
+ export function formatDuration(ms: number): string {
1366
+ const s = Math.max(0, ms) / 1000;
1367
+ if (s < 60) return `${s.toFixed(1)}s`;
1368
+ const m = Math.floor(s / 60) + (Math.round(s % 60) === 60 ? 1 : 0);
1369
+ const rem = Math.round(s % 60) % 60;
1370
+ return `${m}:${String(rem).padStart(2, "0")}`;
1371
+ }
1372
+
320
1373
  interface JobRecord {
321
1374
  id: string;
322
1375
  pid: number;
323
1376
  cmd: string;
324
1377
  name?: string; // optional human-readable label
1378
+ type?: string; // optional job type used for digest scorecard selection
325
1379
  started: number;
326
1380
  logPath: string;
327
1381
  exitedAt?: number;
328
1382
  exitCode?: number;
1383
+ donePersisted?: boolean; // done entry already appended to the transcript
329
1384
  child?: ReturnType<typeof spawn>; // absent for adopted (fs-discovered) jobs
330
1385
  ctx: ExtensionContext; // captured at tool-call time for isIdle() in the exit handler
331
1386
  adopted?: boolean; // true when discovered from the jobs dir (another session's job)
@@ -338,6 +1393,7 @@ interface BgrunJobEntryData {
338
1393
  pid: number;
339
1394
  cmd: string;
340
1395
  name?: string;
1396
+ type?: string;
341
1397
  started: number;
342
1398
  logPath: string;
343
1399
  state: "running" | "done";
@@ -351,21 +1407,30 @@ interface BgStatusDetails {
351
1407
  exitCode?: number;
352
1408
  cmd?: string;
353
1409
  name?: string;
1410
+ type?: string;
354
1411
  count?: number;
355
1412
  recovered?: boolean;
356
1413
  }
357
1414
 
358
- function isRunningPid(pid: number): boolean {
359
- try {
360
- process.kill(pid, 0);
361
- return true;
362
- } catch {
363
- return false;
364
- }
365
- }
366
-
367
1415
  export default function (pi: ExtensionAPI) {
368
1416
  const jobs = new Map<string, JobRecord>();
1417
+ // bgtail's delta-tailing bookmarks: one entry per job id ever tailed, holding
1418
+ // the high-water mark of what the caller has already had the opportunity to
1419
+ // see. Declared here, ahead of the cleanup helpers, so removing a log can
1420
+ // evict its bookmark. TAIL_BOOKMARK_CAP bounds the rest — cleanup only evicts
1421
+ // jobs whose log it removed, and a long session that tails many job ids
1422
+ // (foreign ones are never cleaned here) would otherwise grow it forever.
1423
+ const TAIL_BOOKMARK_CAP = 1_000;
1424
+ // A bookmark is the high-water mark of what the caller has seen PLUS the
1425
+ // search window it was seen through: the same log read through a wider window
1426
+ // is a different view, not newly appended output.
1427
+ type TailBookmark = {
1428
+ lines: number;
1429
+ bytes: number;
1430
+ first: string;
1431
+ window: number;
1432
+ };
1433
+ const tailBookmarks = new Map<string, TailBookmark>();
369
1434
  // Poller for stale job records — anything running with no live ChildProcess
370
1435
  // handle (adopted foreign jobs + jobs reconstructed from transcript entries
371
1436
  // after a restart). No exit event exists for those, so their logs/pids are
@@ -375,7 +1440,7 @@ export default function (pi: ExtensionAPI) {
375
1440
  // ── Helpers ───────────────────────────────────────────────────────────────
376
1441
 
377
1442
  function makeSlug(command: string): string {
378
- const raw = command
1443
+ const raw = redactForSlug(command)
379
1444
  .toLowerCase()
380
1445
  .replace(/[/\\.-]+/g, " ")
381
1446
  .trim();
@@ -386,52 +1451,117 @@ export default function (pi: ExtensionAPI) {
386
1451
  return slug || "job";
387
1452
  }
388
1453
 
389
- // Normalize an optional human-readable name: trim, drop blank, cap length.
1454
+ // Normalize an optional human-readable name: strip control characters
1455
+ // (newlines, tabs, escape/ANSI bytes) so a name can never forge extra lines
1456
+ // in the wake, widget, toast, or transcript; collapse whitespace; cap length.
390
1457
  function sanitizeName(name: string | undefined): string | undefined {
391
- const trimmed = (name ?? "").trim();
1458
+ const trimmed = (name ?? "")
1459
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
1460
+ .replace(/\s+/g, " ")
1461
+ .trim();
392
1462
  if (!trimmed) return undefined;
393
1463
  return trimmed.slice(0, 80);
394
1464
  }
395
1465
 
396
- function readLastLogLine(logPath: string, maxLen = 200): string | null {
1466
+ // Normalize an optional job type via the shared normalizeType(), so the
1467
+ // bgrun param and the config `type` truncate identically (see MAX_TYPE_LEN).
1468
+ function sanitizeType(type: string | undefined): string | undefined {
1469
+ return normalizeType(type);
1470
+ }
1471
+
1472
+ // Count the log's total lines with a bounded-memory streaming scan (one
1473
+ // fixed-size buffer, no full-file read). Missing/unreadable file → null:
1474
+ // the Stats line then just omits the line count — best-effort, never
1475
+ // breaks a wake.
1476
+ function countLogLines(logPath: string): number | null {
1477
+ let fd: number;
397
1478
  try {
398
- const content = readFileSync(logPath, "utf8");
399
- const lines = content.split("\n").filter((l) => l.trim().length > 0);
400
- if (lines.length === 0) return null;
401
- const real = lines.filter((l) => !l.startsWith(EXIT_MARKER));
402
- const last = real[real.length - 1] ?? lines[lines.length - 1];
403
- return last.length > maxLen ? last.slice(0, maxLen) + "…" : last;
1479
+ fd = openSync(logPath, "r");
404
1480
  } catch {
405
1481
  return null;
406
1482
  }
407
- }
408
-
409
- function parseExitFromLog(logPath: string): number | null {
410
1483
  try {
411
- const content = readFileSync(logPath, "utf8");
412
- const lines = content
413
- .split("\n")
414
- .filter((l) => l.startsWith(EXIT_MARKER));
415
- if (lines.length === 0) return null;
416
- const match = lines[lines.length - 1].match(/^__BGRUN_EXIT__=(\d+)/);
417
- return match ? parseInt(match[1], 10) : null;
1484
+ // fstat, not the scan's own progress: the tail pread below is positioned
1485
+ // by the REAL file size, so bounding the scan can never misplace it.
1486
+ const size = fstatSync(fd).size;
1487
+ if (size === 0) return 0;
1488
+ // readWindowMax, not the cap: a capped log is cap + notice + marker, so a
1489
+ // bound EQUAL to the cap would omit the line count for every capped job —
1490
+ // exactly where magnitude matters most.
1491
+ if (size > readWindowMax()) return null;
1492
+ const buf = Buffer.alloc(64 * 1024);
1493
+ let newlines = 0;
1494
+ let seen = 0;
1495
+ let bytesRead = 0;
1496
+ do {
1497
+ bytesRead = readSync(fd, buf, 0, buf.length, null);
1498
+ if (bytesRead <= 0) break;
1499
+ seen += bytesRead;
1500
+ for (let i = 0; i < bytesRead; i++) {
1501
+ if (buf[i] === 0x0a) newlines++;
1502
+ }
1503
+ } while (bytesRead === buf.length);
1504
+ // A log that changed size mid-scan (rotated, or appended by a resumed
1505
+ // job) would produce a count that matches neither state.
1506
+ if (seen !== size) return null;
1507
+ // One bounded pread of the tail for the final-byte + exit-marker check.
1508
+ const tailLen = Math.min(size, 512);
1509
+ const tail = Buffer.alloc(tailLen);
1510
+ readSync(fd, tail, 0, tailLen, size - tailLen);
1511
+ const tailText = tail.toString("latin1");
1512
+ const endsWithNewline = tailText.charCodeAt(tailText.length - 1) === 0x0a;
1513
+ let count = newlines + (endsWithNewline ? 0 : 1);
1514
+ // The wrapper appends "\n<EXIT_MARKER><ec><flags>\n" — and, when it had to
1515
+ // drop output or could not install the ceiling, "\n<NOTICE>\n" before
1516
+ // that. Those newlines are not command output, so drop the whole trailing
1517
+ // wrapper block, including its leading separator when the output already
1518
+ // ended in a newline. WHICH notice precedes the marker is decided by the
1519
+ // marker's own flags, not by matching notice text: a command that prints
1520
+ // the phrase must not have its line discounted as wrapper bookkeeping.
1521
+ const markerAt = tailText.lastIndexOf("\n" + EXIT_MARKER);
1522
+ if (markerAt === 0) {
1523
+ // The file is only the wrapper's "\n<marker>\n" — no command output.
1524
+ return 0;
1525
+ }
1526
+ if (markerAt !== -1) {
1527
+ const afterMarker = tailText.slice(markerAt + 1);
1528
+ const markerEnd = afterMarker.indexOf("\n");
1529
+ const markerLine =
1530
+ markerEnd === -1 ? afterMarker : afterMarker.slice(0, markerEnd);
1531
+ const noticePrefix = markerLine.includes(EXIT_MARKER_NOCAP_FLAG)
1532
+ ? CAPFAIL_NOTICE_PREFIX
1533
+ : markerLine.includes(EXIT_MARKER_TRUNC_FLAG)
1534
+ ? TRUNC_NOTICE_PREFIX
1535
+ : null;
1536
+ const noticeAt = noticePrefix
1537
+ ? tailText.lastIndexOf("\n" + noticePrefix)
1538
+ : -1;
1539
+ const blockStart =
1540
+ noticeAt !== -1 && noticeAt < markerAt ? noticeAt : markerAt;
1541
+ let extra = 0;
1542
+ for (let i = blockStart + 1; i < tailText.length; i++) {
1543
+ if (tailText.charCodeAt(i) === 0x0a) extra++;
1544
+ }
1545
+ if (blockStart > 0 && tailText.charCodeAt(blockStart - 1) === 0x0a)
1546
+ extra++;
1547
+ count = Math.max(0, count - extra);
1548
+ }
1549
+ return count;
418
1550
  } catch {
419
1551
  return null;
1552
+ } finally {
1553
+ closeSync(fd);
420
1554
  }
421
1555
  }
422
1556
 
423
- function pidFromId(id: string): number | null {
424
- // id format: <slug>-<ts>-<pid>
425
- const parts = id.split("-");
426
- const pid = parseInt(parts[parts.length - 1], 10);
427
- return Number.isFinite(pid) ? pid : null;
428
- }
429
-
430
1557
  // ── Live status widget ────────────────────────────────────────────────────
431
1558
 
432
- function updateWidget(ctx: ExtensionContext): void {
1559
+ function updateWidget(
1560
+ ctx: ExtensionContext,
1561
+ opts: { persistRevalidate?: boolean } = {},
1562
+ ): void {
433
1563
  if (!ctx.hasUI) return;
434
- revalidateStaleJobs();
1564
+ revalidateStaleJobs({ persist: opts.persistRevalidate ?? true });
435
1565
  const running: JobRecord[] = [];
436
1566
  for (const rec of jobs.values()) {
437
1567
  if (rec.exitCode === undefined) running.push(rec);
@@ -444,65 +1574,121 @@ export default function (pi: ExtensionAPI) {
444
1574
  for (const rec of running) {
445
1575
  const startedAt = formatSince(rec.started);
446
1576
  const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
447
- const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
1577
+ const label = rec.name ? `${rec.name} · ${cmd}` : cmd;
448
1578
  const tag = rec.adopted ? " (adopted)" : "";
449
- lines.push(
450
- ` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`,
451
- );
1579
+ // Full id (not truncated) so it can be copied straight into /bgtail <id>.
1580
+ lines.push(` ${rec.id} ${label} (since ${startedAt})${tag}`);
452
1581
  }
453
1582
  ctx.ui.setWidget("bgrun", lines);
454
1583
  }
455
1584
 
456
1585
  // ── Cleanup ───────────────────────────────────────────────────────────────
457
1586
 
1587
+ // Is the wrapper that owns this staging stem still running? It records its
1588
+ // own pid next to its scratch files, so liveness is exact for a job started by
1589
+ // ANY session sharing this jobs dir — unlike a log, whose protection needs the
1590
+ // pid in the file name.
1591
+ function stagingOwnerAlive(pidPath: string): boolean {
1592
+ try {
1593
+ const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
1594
+ return Number.isFinite(pid) && isRunningPid(pid);
1595
+ } catch {
1596
+ // No liveness record (or unreadable) → treat as an orphan's leftovers.
1597
+ return false;
1598
+ }
1599
+ }
1600
+
1601
+ // Sweep stale per-project digest markers (.bgrun-used-*, .digest-nudge-*) and
1602
+ // orphaned staging files. Markers aren't session-scoped, so they'd otherwise
1603
+ // accumulate one per project forever; a project that runs bgrun again
1604
+ // re-writes its usage marker at spawn, so removing a stale one can at most
1605
+ // re-enable one future nudge.
1606
+ function sweepStaleMarkers(jobsDir: string, cutoff: number): void {
1607
+ let names: string[];
1608
+ try {
1609
+ names = readdirSync(jobsDir);
1610
+ } catch {
1611
+ return;
1612
+ }
1613
+ // Staging files are only reclaimable when nobody owns them. Age alone used
1614
+ // to delete a RUNNING job's fifo/flag — the wrapper's scratch files live for
1615
+ // the whole job, so any aggressive cutoff killed them mid-run, silently
1616
+ // removing the truncation notice and injecting a shell error into the log.
1617
+ const stagingCutoff = Math.max(cutoff, Date.now() - STAGING_MIN_AGE_MS);
1618
+ for (const name of names) {
1619
+ const isStaging =
1620
+ name.startsWith(".tmp-") &&
1621
+ STAGING_SUFFIXES.some((suffix) => name.endsWith(suffix));
1622
+ if (
1623
+ !isStaging &&
1624
+ !name.startsWith(".bgrun-used-") &&
1625
+ !name.startsWith(".digest-nudge-")
1626
+ )
1627
+ continue;
1628
+ try {
1629
+ const markerPath = join(jobsDir, name);
1630
+ const mtimeMs = statSync(markerPath).mtimeMs;
1631
+ if (isStaging) {
1632
+ if (mtimeMs > stagingCutoff) continue;
1633
+ const stem = name.replace(/\.[a-z]+$/, "");
1634
+ if (stagingOwnerAlive(join(jobsDir, `${stem}.pid`))) continue;
1635
+ } else if (mtimeMs > cutoff) {
1636
+ continue;
1637
+ }
1638
+ unlinkSync(markerPath);
1639
+ } catch {
1640
+ // ignore
1641
+ }
1642
+ }
1643
+ }
1644
+
458
1645
  function cleanOldJobs(
459
1646
  days: number,
460
1647
  jobsDir: string,
461
1648
  ctx?: ExtensionContext,
1649
+ // The ownership marker protects the AUTOMATIC global sweep from deleting
1650
+ // logs in an unrelated dir (a stray PI_BGRUN_DIR). An explicit
1651
+ // `bgclean all` is the user's direct intent, so it bypasses the gate.
1652
+ opts: { requireOwnership?: boolean } = {},
462
1653
  ): { removed: number; kept: number; skippedRunning: number } {
463
1654
  const result = { removed: 0, kept: 0, skippedRunning: 0 };
464
- let entries: string[];
465
- try {
466
- entries = readdirSync(jobsDir);
467
- } catch {
1655
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
1656
+ if (
1657
+ opts.requireOwnership !== false &&
1658
+ !existsSync(join(jobsDir, JOBS_DIR_MARKER))
1659
+ ) {
1660
+ // Not recognizably ours — touch NOTHING, marker files included. The gate
1661
+ // exists so a stray PI_BGRUN_DIR is never emptied.
468
1662
  return result;
469
1663
  }
470
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
471
- for (const name of entries) {
472
- if (!name.endsWith(".log")) continue;
473
- const logPath = join(jobsDir, name);
474
- let st;
475
- try {
476
- st = statSync(logPath);
477
- } catch {
478
- continue;
479
- }
480
- // mtime check
481
- if (st.mtimeMs > cutoff) {
1664
+ // Past the gate: sweep our own stale per-project marker files.
1665
+ sweepStaleMarkers(jobsDir, cutoff);
1666
+ for (const entry of scanLogFiles(jobsDir)) {
1667
+ // mtime check FIRST — young files are never candidates, so skip early.
1668
+ if (entry.mtimeMs > cutoff) {
482
1669
  result.kept++;
483
1670
  continue;
484
1671
  }
485
- const id = name.slice(0, -".log".length);
486
- // Exit marker is the authoritative finished signal check it BEFORE pid
487
- // liveness, so completed jobs are never mistaken for running (pid reuse
488
- // and shared pids made the old order keep stale jobs forever).
489
- const finished = parseExitFromLog(logPath) !== null;
1672
+ // A TERMINAL marker means the wrapper finished writing — trust it even
1673
+ // when the pid looks alive (that is a reused pid; otherwise the log would
1674
+ // never be reclaimed). A non-terminal marker is not completion evidence,
1675
+ // so fall through to pid liveness, which protects a job that merely
1676
+ // printed the string.
1677
+ const finished = parseExitFromLogPath(entry.logPath) !== null;
490
1678
  if (!finished) {
491
- // No marker yet — running only if the pid is alive.
492
- const rec = jobs.get(id);
493
- if (rec && rec.exitCode === undefined) {
494
- result.skippedRunning++;
495
- continue;
496
- }
497
- const pid = pidFromId(id);
498
- if (pid !== null && pid > 0 && isRunningPid(pid)) {
1679
+ const rec = jobs.get(entry.id);
1680
+ if (entry.alive && rec?.exitCode === undefined) {
499
1681
  result.skippedRunning++;
500
1682
  continue;
501
1683
  }
502
1684
  }
1685
+ // finished, dead pid, or our record says done → safe to remove.
503
1686
  try {
504
- unlinkSync(logPath);
1687
+ unlinkSync(entry.logPath);
505
1688
  result.removed++;
1689
+ // The log is gone: its delta bookmark would otherwise pin a stale
1690
+ // high-water mark (and a Map slot) for the life of the session.
1691
+ tailBookmarks.delete(entry.id);
506
1692
  } catch {
507
1693
  // ignore
508
1694
  }
@@ -525,15 +1711,26 @@ export default function (pi: ExtensionAPI) {
525
1711
  const result = { removed: 0, kept: 0, skippedRunning: 0 };
526
1712
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
527
1713
  for (const rec of jobs.values()) {
1714
+ // Adopted foreign jobs belong to another session — this session neither
1715
+ // owns nor reports on them (counting them as "skipped running" was
1716
+ // misleading).
1717
+ if (rec.adopted) continue;
528
1718
  if (rec.exitCode === undefined) {
529
1719
  result.skippedRunning++;
530
1720
  continue;
531
1721
  }
532
- let st;
1722
+ let st: ReturnType<typeof statSync>;
533
1723
  try {
534
1724
  st = statSync(rec.logPath);
535
1725
  } catch {
536
- continue; // already gone
1726
+ // Log already gone (cleaned by a global sweep). Drop the in-memory
1727
+ // record once it's past retention so finished jobs can't pin the Map
1728
+ // (and its ExtensionContext) for the life of the process.
1729
+ if (rec.exitedAt !== undefined && rec.exitedAt < cutoff) {
1730
+ jobs.delete(rec.id);
1731
+ tailBookmarks.delete(rec.id);
1732
+ }
1733
+ continue;
537
1734
  }
538
1735
  if (st.mtimeMs > cutoff) {
539
1736
  result.kept++;
@@ -542,49 +1739,98 @@ export default function (pi: ExtensionAPI) {
542
1739
  try {
543
1740
  unlinkSync(rec.logPath);
544
1741
  result.removed++;
1742
+ jobs.delete(rec.id);
1743
+ tailBookmarks.delete(rec.id);
545
1744
  } catch {
546
1745
  // ignore
547
1746
  }
548
1747
  }
1748
+ // Markers aren't session data, so a session-scoped sweep may still drop
1749
+ // stale ones from this jobs dir — except in an untrusted project-local dir,
1750
+ // where deletion would mutate a repo the user has not trusted (the same
1751
+ // boundary autoCleanJobs enforces below).
1752
+ const cfg = resolveConfig(ctx);
1753
+ if (!(cfg.jobsDirProjectLocal && ctx?.isProjectTrusted?.() !== true)) {
1754
+ sweepStaleMarkers(cfg.jobsDir, cutoff);
1755
+ }
549
1756
  if (result.removed > 0 && ctx?.hasUI) {
550
1757
  ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info");
551
1758
  }
552
1759
  return result;
553
1760
  }
554
1761
 
1762
+ // Every shared jobs dir the orphan sweep / `bgclean all` should touch. The
1763
+ // machine-global dir is included only for the project-local default (so
1764
+ // pre-project-local logs are still reclaimed); an explicit absolute jobsDir
1765
+ // is treated as fully isolated and swept alone.
1766
+ function sharedJobsDirs(projectDir: string, projectLocal: boolean): string[] {
1767
+ if (!projectLocal) return [projectDir];
1768
+ const global = globalJobsDir();
1769
+ // Dedup aliased paths (equal strings, or symlinks to the same dir) so the
1770
+ // sweep never counts/removes the same log twice.
1771
+ return safeRealpath(projectDir) === safeRealpath(global)
1772
+ ? [global]
1773
+ : [global, projectDir];
1774
+ }
1775
+
555
1776
  // Auto-clean at session boundaries. Two parts:
556
1777
  // 1. Session-scoped sweep — this session's old logs only; cheap,
557
1778
  // 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.
1779
+ // 2. Orphan sweep (default on; disable via globalAutoClean: false /
1780
+ // PI_BGRUN_GLOBAL_AUTO_CLEAN=0) — every shared jobs dir (see
1781
+ // sharedJobsDirs), removing FINISHED logs (exit marker, or dead pid)
1782
+ // older than cleanupDays. This is what keeps orphans from crashed /
1783
+ // never-resumed sessions from accumulating: a week-old finished log is
1784
+ // garbage under the same retention the owning session would apply
1785
+ // itself, and running jobs are always pid-protected. Throttled to one
1786
+ // sweep per cleanupDays via a .last-clean marker in each dir so
1787
+ // restart-heavy workflows don't re-sweep on every launch.
567
1788
  function autoCleanJobs(ctx: ExtensionContext): void {
568
1789
  const cfg = resolveConfig(ctx);
1790
+ // Trust boundary: session start / shutdown must not write into a repo the
1791
+ // user has not trusted. For an untrusted project we skip both the
1792
+ // .git/info/exclude edit and the project-local dir sweep below (which would
1793
+ // create the dir for its .last-clean marker). The bgrun tool still ensures
1794
+ // exclusion at job-creation time — that is an explicit agent action, not an
1795
+ // incidental side effect of opening a session.
1796
+ const trusted = ctx?.isProjectTrusted?.() === true;
1797
+ if (cfg.jobsDirProjectLocal && trusted) ensureGitExcluded(cfg.jobsDir);
569
1798
  cleanSessionJobs(cfg.cleanupDays, ctx);
570
1799
  if (!cfg.globalAutoClean) return;
571
- const markerPath = join(cfg.jobsDir, ".last-clean");
572
- try {
573
- const last = Number(readFileSync(markerPath, "utf8").trim());
1800
+ for (const dir of sharedJobsDirs(cfg.jobsDir, cfg.jobsDirProjectLocal)) {
574
1801
  if (
575
- Number.isFinite(last) &&
576
- Date.now() - last < cfg.cleanupDays * 24 * 60 * 60 * 1000
1802
+ cfg.jobsDirProjectLocal &&
1803
+ !trusted &&
1804
+ safeRealpath(dir) === safeRealpath(cfg.jobsDir)
577
1805
  )
578
- return;
579
- } catch {
580
- // no marker yet — run the sweep
581
- }
582
- cleanOldJobs(cfg.cleanupDays, cfg.jobsDir, ctx);
583
- try {
584
- mkdirSync(cfg.jobsDir, { recursive: true });
585
- writeFileSync(markerPath, String(Date.now()));
586
- } catch {
587
- // best-effort
1806
+ continue;
1807
+ const markerPath = join(dir, ".last-clean");
1808
+ try {
1809
+ const last = Number(readFileSync(markerPath, "utf8").trim());
1810
+ if (
1811
+ Number.isFinite(last) &&
1812
+ Date.now() - last < cfg.cleanupDays * 24 * 60 * 60 * 1000
1813
+ )
1814
+ continue;
1815
+ } catch {
1816
+ // no marker yet — run the sweep
1817
+ }
1818
+ // The known machine-global dir is ours even without a .bgrun-jobs marker
1819
+ // (the project-local default never writes one there), so bypass the
1820
+ // ownership gate for it only; the project-local dir stays gated.
1821
+ // The DEFAULT machine-global dir is ours even without a .bgrun-jobs
1822
+ // marker (the project-local default never writes one there). A custom
1823
+ // PI_BGRUN_GLOBAL_DIR is gated like any other dir, per the README.
1824
+ const isGlobal =
1825
+ !process.env.PI_BGRUN_GLOBAL_DIR &&
1826
+ safeRealpath(dir) === safeRealpath(globalJobsDir());
1827
+ cleanOldJobs(cfg.cleanupDays, dir, ctx, { requireOwnership: !isGlobal });
1828
+ try {
1829
+ mkdirSync(dir, { recursive: true });
1830
+ writeFileSync(markerPath, String(Date.now()));
1831
+ } catch {
1832
+ // best-effort
1833
+ }
588
1834
  }
589
1835
  }
590
1836
 
@@ -598,10 +1844,43 @@ export default function (pi: ExtensionAPI) {
598
1844
  // session's history; the log on disk still covers id lookup + cleanup).
599
1845
  // - Reconstructed jobs ARE this session's history: mark them done and
600
1846
  // append a done entry so future resumes reconstruct them as done too.
601
- function revalidateStaleJobs(): void {
1847
+ // `persist: false` is for read-only callers (bgstatus): they still need an
1848
+ // accurate view, but asking for status must not append transcript cards.
1849
+ // The stale poller / session_start re-run with persistence and reconcile.
1850
+ function persistDoneEntry(rec: JobRecord, exit: number): void {
1851
+ rec.donePersisted = true;
1852
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
1853
+ id: rec.id,
1854
+ pid: rec.pid,
1855
+ cmd: rec.cmd,
1856
+ name: rec.name,
1857
+ type: rec.type,
1858
+ started: rec.started,
1859
+ logPath: rec.logPath,
1860
+ state: "done",
1861
+ exitCode: exit >= 0 ? exit : undefined,
1862
+ exitedAt: rec.exitedAt,
1863
+ });
1864
+ }
1865
+
1866
+ function revalidateStaleJobs(opts: { persist?: boolean } = {}): void {
1867
+ const persist = opts.persist ?? true;
602
1868
  for (const [id, rec] of jobs) {
603
- if (rec.child || rec.exitCode !== undefined) continue;
604
- let exit = parseExitFromLog(rec.logPath);
1869
+ if (rec.child) continue;
1870
+ if (rec.exitCode !== undefined) {
1871
+ // Already reconciled. A read-only pass (bgstatus, persist:false) sets
1872
+ // exitCode WITHOUT persisting, so a later persisting pass must still
1873
+ // write the done entry — otherwise the transcript card stays "running"
1874
+ // for the rest of the session.
1875
+ if (persist && !rec.donePersisted && !rec.adopted) {
1876
+ persistDoneEntry(rec, rec.exitCode);
1877
+ }
1878
+ continue;
1879
+ }
1880
+ let exit = parseExitFromLogPath(rec.logPath);
1881
+ if (exit === null && rec.pid <= 0) {
1882
+ exit = -1;
1883
+ }
605
1884
  if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
606
1885
  // pid gone with no marker — killed/crashed before the wrapper could write it,
607
1886
  // or the log was already cleaned up
@@ -613,17 +1892,7 @@ export default function (pi: ExtensionAPI) {
613
1892
  } else {
614
1893
  rec.exitCode = exit;
615
1894
  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
- });
1895
+ if (persist) persistDoneEntry(rec, exit);
627
1896
  }
628
1897
  }
629
1898
  }
@@ -737,10 +2006,14 @@ export default function (pi: ExtensionAPI) {
737
2006
  pid: d.pid,
738
2007
  cmd: d.cmd,
739
2008
  name: d.name,
2009
+ type: d.type,
740
2010
  started: d.started,
741
2011
  logPath: d.logPath,
742
2012
  exitedAt: d.exitedAt,
743
2013
  exitCode: isDone ? (d.exitCode ?? -1) : undefined,
2014
+ // Mark the done entry as already persisted, or revalidateStaleJobs
2015
+ // appends a duplicate done card on every resume.
2016
+ donePersisted: isDone,
744
2017
  ctx,
745
2018
  });
746
2019
  }
@@ -758,39 +2031,28 @@ export default function (pi: ExtensionAPI) {
758
2031
  // have no ChildProcess handle — no exit event, so a poller re-checks their
759
2032
  // logs and pids instead, and they leave the widget once finished.
760
2033
  const cfg = resolveConfig(ctx);
761
- const jobsDir = cfg.jobsDir;
762
- if (cfg.adoptForeignJobs) {
763
- try {
764
- for (const name of readdirSync(jobsDir)) {
765
- if (!name.endsWith(".log")) continue;
766
- const id = name.slice(0, -".log".length);
767
- if (jobs.has(id)) continue;
768
- const logPath = join(jobsDir, name);
769
- const exit = parseExitFromLog(logPath);
770
- if (exit !== null) continue; // finished — nothing to show in the widget
771
- const pid = pidFromId(id);
772
- if (pid === null || pid <= 0 || !isRunningPid(pid)) continue; // dead pid, marker just not written yet
773
- let started = Date.now();
774
- try {
775
- started = statSync(logPath).birthtimeMs;
776
- } catch {
777
- // keep fallback
778
- }
779
- jobs.set(id, {
780
- id,
781
- pid,
782
- cmd: "(started by another session)",
783
- started,
784
- logPath,
785
- ctx,
786
- adopted: true,
787
- });
788
- }
789
- } catch {
790
- // jobs dir doesn't exist — nothing to adopt.
2034
+ const jobsDir = cfg.jobsDir;
2035
+ if (cfg.adoptForeignJobs) {
2036
+ for (const entry of scanJobsDir(jobsDir)) {
2037
+ if (jobs.has(entry.id)) continue;
2038
+ if (entry.exit !== null) continue; // finished — nothing to show in the widget
2039
+ if (!entry.alive) continue; // dead pid, marker just not written yet
2040
+ jobs.set(entry.id, {
2041
+ id: entry.id,
2042
+ pid: entry.pid ?? -1,
2043
+ cmd: "(started by another session)",
2044
+ started: entry.birthtimeMs || Date.now(),
2045
+ logPath: entry.logPath,
2046
+ ctx,
2047
+ adopted: true,
2048
+ });
791
2049
  }
792
2050
  }
793
2051
 
2052
+ // One-shot digest nudge (toast only, never the LLM context). All of its
2053
+ // failure modes are swallowed inside — it must never break session_start.
2054
+ maybeNudgeDigest(ctx);
2055
+
794
2056
  // Show the widget if anything is now running. revalidateStaleJobs()
795
2057
  // inside clears zombies — reconstructed jobs that finished while pi was
796
2058
  // down — before they ever render. Then start the stale poller for
@@ -823,12 +2085,14 @@ export default function (pi: ExtensionAPI) {
823
2085
  "Run a long shell command detached in the background. Returns 'started: <job-id>' immediately. " +
824
2086
  "You will be woken automatically when the job finishes. Use this instead of bash for any command " +
825
2087
  "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " +
826
- "short human-readable label used in the job id, status output, and wake messages.",
2088
+ "short human-readable label used in the job id, status output, and wake messages, and `type` to " +
2089
+ "select the project's digest scorecard.",
827
2090
  promptSnippet:
828
2091
  "Run a long command detached in the background; get woken on completion",
829
2092
  promptGuidelines: [
830
2093
  "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
831
2094
  "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.",
2095
+ "When the project's digest config defines `type` entries, pass the matching `type` (e.g. type: 'test') so the wake selects the right scorecard; the vocabulary comes from the project's `.pi/pi-bgrun.json` digest entries.",
832
2096
  "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.",
833
2097
  "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.",
834
2098
  ],
@@ -844,13 +2108,22 @@ export default function (pi: ExtensionAPI) {
844
2108
  "Used in the job id, status output, the status widget, and wake messages.",
845
2109
  }),
846
2110
  ),
2111
+ type: Type.Optional(
2112
+ Type.String({
2113
+ description:
2114
+ "Optional job type used to select the project's digest scorecard (e.g. 'test', 'build', 'lint'). " +
2115
+ "The vocabulary comes from the `type` fields in the project's `digest` config entries in " +
2116
+ "`.pi/pi-bgrun.json`; when the project's digest config defines types, prefer passing the matching one.",
2117
+ }),
2118
+ ),
847
2119
  }),
848
2120
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
849
- const { command, name: rawName } = params;
2121
+ const { command, name: rawName, type: rawType } = params;
850
2122
  if (!command || !command.trim()) {
851
2123
  throw new Error("bgrun: command is required");
852
2124
  }
853
2125
  const name = sanitizeName(rawName);
2126
+ const type = sanitizeType(rawType);
854
2127
 
855
2128
  const cfg = resolveConfig(ctx);
856
2129
  // Project-local logs are auto-ignored in .git/info/exclude (best-effort)
@@ -858,149 +2131,341 @@ export default function (pi: ExtensionAPI) {
858
2131
  if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir);
859
2132
  const jobsDir = cfg.jobsDir;
860
2133
  mkdirSync(jobsDir, { recursive: true });
2134
+ ensureJobsDirMarker(jobsDir);
2135
+ // Evidence-of-use marker (best-effort): lets the digest nudge tell that
2136
+ // THIS project has run bgrun, without scanning the shared jobs dir.
2137
+ try {
2138
+ writeFileSync(
2139
+ jobUsageMarkerPath(jobsDir, projectRootFor(ctx.cwd ?? process.cwd())),
2140
+ String(Date.now()),
2141
+ );
2142
+ } catch {
2143
+ // a marker write must never block a spawn
2144
+ }
861
2145
 
862
2146
  const slug = makeSlug(name ?? command);
863
2147
  const ts = Math.floor(Date.now() / 1000);
864
2148
  // The id must carry the CHILD's pid (liveness checks depend on it), but the
865
2149
  // log fd must exist before spawn. Create at a temp path, rename after spawn.
866
- const tmpPath = join(
867
- jobsDir,
868
- `.tmp-${slug}-${ts}-${Math.random().toString(36).slice(2, 8)}.log`,
869
- );
870
- let logFd: number;
2150
+ // randomBytes (not Math.random) plus O_EXCL: the temp name is not
2151
+ // guessable and a pre-planted symlink cannot be truncated through.
2152
+ const stem = `.tmp-${slug}-${ts}-${randomBytes(4).toString("hex")}`;
2153
+ const tmpPath = join(jobsDir, `${stem}.log`);
2154
+ let logFd: number | undefined;
2155
+ let logPath = tmpPath;
871
2156
  try {
872
- logFd = openSync(tmpPath, "w");
2157
+ // 0600: job logs can contain secrets pulled from the environment.
2158
+ logFd = openSync(tmpPath, "wx", 0o600);
873
2159
  } catch (err) {
874
2160
  throw new Error(
875
2161
  `bgrun: cannot create log file: ${(err as Error).message}`,
876
2162
  );
877
2163
  }
878
- const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`;
879
-
880
- const child = spawn("sh", ["-c", wrapped], {
881
- stdio: ["ignore", logFd, logFd],
882
- detached: true,
883
- });
884
- child.unref();
885
-
886
- const childPid = child.pid ?? -1;
887
- const id = `${slug}-${ts}-${childPid}`;
888
- const logPath = join(jobsDir, `${id}.log`);
889
2164
  try {
890
- renameSync(tmpPath, logPath);
891
- } catch (err) {
892
- console.error(
893
- `[pi-bgrun] rename to final log path failed:`,
894
- (err as Error).message,
2165
+ // Pass command as argv — interpolation breaks on #, quotes, heredocs.
2166
+ // maxLogBytes 0 means "unlimited": keep the pre-ceiling wrapper exactly
2167
+ // (a zero-byte ceiling is meaningless, so it cannot be routed through
2168
+ // the capped path).
2169
+ const capped = cfg.maxLogBytes > 0;
2170
+ const wrapper = capped
2171
+ ? cappedWrapper(cfg.maxLogBytes)
2172
+ : `sh -c "$1"; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit "$ec"`;
2173
+ const child = spawn(
2174
+ "sh",
2175
+ capped
2176
+ ? [
2177
+ "-c",
2178
+ wrapper,
2179
+ "bgrun",
2180
+ command,
2181
+ join(jobsDir, `${stem}.ec`),
2182
+ join(jobsDir, `${stem}.fifo`),
2183
+ // Liveness, so a cleanup sweep can tell a running job's scratch
2184
+ // files from an orphan's instead of judging them by age alone.
2185
+ join(jobsDir, `${stem}.pid`),
2186
+ // Truncation flag: written by the drain when bytes were left
2187
+ // over. A file, not the drain's exit status, because the drain
2188
+ // may still be running when the wrapper prints.
2189
+ join(jobsDir, `${stem}.trunc`),
2190
+ ]
2191
+ : ["-c", wrapper, "bgrun", command],
2192
+ {
2193
+ stdio: ["ignore", logFd, logFd],
2194
+ detached: true,
2195
+ },
895
2196
  );
896
- }
897
-
898
- const record: JobRecord = {
899
- id,
900
- pid: childPid,
901
- cmd: command,
902
- name,
903
- started: Date.now(),
904
- logPath,
905
- child,
906
- ctx,
907
- };
908
- jobs.set(id, record);
909
-
910
- // Persist a bgrun-job entry (running state) — transcript card + restart recovery.
911
- pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
912
- id,
913
- pid: childPid,
914
- cmd: command,
915
- name,
916
- started: Date.now(),
917
- logPath,
918
- state: "running",
919
- });
920
-
921
- closeSync(logFd);
922
-
923
- updateWidget(ctx);
2197
+ child.unref();
924
2198
 
925
- // ── exit handler: record exit, persist done entry, wake, notify, widget ─
926
- child.on("exit", (code, signal) => {
927
- const rec = jobs.get(id);
928
- if (!rec) return;
929
- rec.exitedAt = Date.now();
930
- rec.exitCode = code ?? -1;
931
- delete rec.child; // release the handle reference
2199
+ const childPid = child.pid ?? -1;
2200
+ const id = `${slug}-${ts}-${childPid}`;
2201
+ const finalLogPath = join(jobsDir, `${id}.log`);
2202
+ try {
2203
+ renameSync(tmpPath, finalLogPath);
2204
+ logPath = finalLogPath;
2205
+ } catch (err) {
2206
+ console.error(
2207
+ `[pi-bgrun] rename to final log path failed:`,
2208
+ (err as Error).message,
2209
+ );
2210
+ }
932
2211
 
933
- const exitCode = code ?? parseExitFromLog(logPath) ?? -1;
934
- const exitStr =
935
- exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
936
- const exitEmoji = exitCode === 0 ? "✅" : "❌";
937
- const lastLine = readLastLogLine(logPath);
2212
+ const record: JobRecord = {
2213
+ id,
2214
+ pid: childPid,
2215
+ cmd: command,
2216
+ name,
2217
+ type,
2218
+ started: Date.now(),
2219
+ logPath,
2220
+ child,
2221
+ ctx,
2222
+ };
2223
+ jobs.set(id, record);
938
2224
 
939
- // Persist the done-state entry.
2225
+ // Persist a bgrun-job entry (running state) — transcript card + restart recovery.
940
2226
  pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
941
2227
  id,
942
- pid: rec.pid,
943
- cmd: rec.cmd,
944
- name: rec.name,
945
- started: rec.started,
2228
+ pid: childPid,
2229
+ cmd: command,
2230
+ name,
2231
+ type,
2232
+ started: Date.now(),
946
2233
  logPath,
947
- state: "done",
948
- exitCode: exitCode >= 0 ? exitCode : undefined,
949
- exitedAt: rec.exitedAt,
2234
+ state: "running",
950
2235
  });
951
2236
 
952
- // Wake the agent.
953
- const namePrefix = rec.name ? `"${rec.name}" ` : "";
954
- let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`;
955
- wake += `Command: ${command}\n`;
956
- if (lastLine) wake += `Last output: ${lastLine}\n`;
957
- wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`;
958
- try {
959
- if (rec.ctx.isIdle()) {
960
- pi.sendUserMessage(wake);
961
- } else {
962
- pi.sendUserMessage(wake, { deliverAs: "followUp" });
2237
+ updateWidget(ctx);
2238
+
2239
+ const finishSpawnFailure = (err: Error) => {
2240
+ const rec = jobs.get(id);
2241
+ if (!rec || rec.exitCode !== undefined) return;
2242
+ rec.exitedAt = Date.now();
2243
+ rec.exitCode = -1;
2244
+ rec.donePersisted = true;
2245
+ delete rec.child;
2246
+ try {
2247
+ appendFileSync(
2248
+ rec.logPath,
2249
+ `\n[pi-bgrun] spawn failed: ${err.message}\n${EXIT_MARKER}-1\n`,
2250
+ );
2251
+ } catch {
2252
+ // best-effort
963
2253
  }
964
- } catch {
2254
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
2255
+ id,
2256
+ pid: rec.pid,
2257
+ cmd: rec.cmd,
2258
+ name: rec.name,
2259
+ started: rec.started,
2260
+ logPath: rec.logPath,
2261
+ state: "done",
2262
+ exitCode: -1,
2263
+ exitedAt: rec.exitedAt,
2264
+ });
2265
+ const namePrefix = rec.name ? `"${rec.name}" ` : "";
2266
+ const wake =
2267
+ `❌ Background job ${namePrefix}\`${id}\` failed to start: ${err.message}\n` +
2268
+ `Command: ${command}`;
2269
+ try {
2270
+ if (rec.ctx.isIdle()) pi.sendUserMessage(wake);
2271
+ else pi.sendUserMessage(wake, { deliverAs: "followUp" });
2272
+ } catch {
2273
+ try {
2274
+ pi.sendUserMessage(wake, { deliverAs: "followUp" });
2275
+ } catch (e2) {
2276
+ console.error(
2277
+ `[pi-bgrun] wake failed for job ${id}:`,
2278
+ (e2 as Error).message,
2279
+ );
2280
+ }
2281
+ }
2282
+ if (rec.ctx.hasUI) {
2283
+ rec.ctx.ui.notify(
2284
+ `❌ ${(rec.name ?? command).slice(0, 50)} → spawn failed`,
2285
+ "error",
2286
+ );
2287
+ }
2288
+ updateWidget(rec.ctx);
2289
+ };
2290
+
2291
+ // ── exit handler: record exit, persist done entry, wake, notify, widget ─
2292
+ child.on("exit", async (code, signal) => {
2293
+ const rec = jobs.get(id);
2294
+ if (!rec) return;
2295
+ // A spawn that emitted 'error' first already finalized this job; a
2296
+ // follow-up 'exit' must not append a second done entry or wake.
2297
+ if (rec.exitCode !== undefined) return;
2298
+ rec.exitedAt = Date.now();
2299
+ rec.exitCode = code ?? -1;
2300
+ // Set BEFORE the digest await: without it a read-only bgstatus in
2301
+ // that window could append a second done entry.
2302
+ rec.donePersisted = true;
2303
+ delete rec.child; // release the handle reference
2304
+
2305
+ const exitCode = code ?? parseExitFromLogPath(logPath) ?? -1;
2306
+ const exitStr =
2307
+ exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
2308
+ const exitEmoji = exitCode === 0 ? "✅" : "❌";
2309
+ const lastLine = readLastLogLine(logPath);
2310
+
2311
+ // Universal stats — duration + log line count. Non-heuristic, always
2312
+ // present, never pattern-based. A missing log contributes no line
2313
+ // count (duration is always known).
2314
+ const logLines = countLogLines(logPath);
2315
+ const statsParts = [formatDuration(rec.exitedAt - rec.started)];
2316
+ if (logLines !== null)
2317
+ statsParts.push(`${logLines.toLocaleString("en-US")} lines`);
2318
+ // The cap is the one fact that changes what the others MEAN: the line
2319
+ // count, the last line and any digest describe only the bytes that
2320
+ // were kept. Say so in the line the agent reads first. A ceiling that
2321
+ // could not be installed is the opposite case — the log is complete
2322
+ // but unbounded — and that must not be silent either.
2323
+ const capStatus = readCapStatus(logPath);
2324
+ const truncatedAt =
2325
+ capStatus?.kind === "truncated" ? capStatus.bytes : null;
2326
+ if (truncatedAt !== null)
2327
+ statsParts.push(`log truncated at ${formatBytes(truncatedAt)}`);
2328
+ else if (capStatus?.kind === "ceiling-failed")
2329
+ statsParts.push("no log ceiling (command ran uncapped)");
2330
+
2331
+ // Persist the done-state entry.
2332
+ pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
2333
+ id,
2334
+ pid: rec.pid,
2335
+ cmd: rec.cmd,
2336
+ name: rec.name,
2337
+ type: rec.type,
2338
+ started: rec.started,
2339
+ logPath,
2340
+ state: "done",
2341
+ exitCode: exitCode >= 0 ? exitCode : undefined,
2342
+ exitedAt: rec.exitedAt,
2343
+ });
2344
+
2345
+ // Opt-in project-config digest (best-effort, silent-fail). rec.ctx is
2346
+ // the ExtensionContext captured at tool-call time and retains
2347
+ // everything resolveConfig needs (cwd + isProjectTrusted), so the
2348
+ // digest config is resolved here at exit — config edits made while the
2349
+ // job ran are picked up, and trust is evaluated against the same
2350
+ // session that spawned the job. No spawn-time capture needed. When a
2351
+ // digest is configured, the wake is sent only after this bounded
2352
+ // attempt (≤ ~5.25s: 5s timeout + 250ms kill grace) completes; a digest
2353
+ // that fails, times out, or prints
2354
+ // nothing appends nothing, and the exit code / universal part above are
2355
+ // never affected.
2356
+ let digestBlock: { label: string; text: string } | undefined;
965
2357
  try {
966
- pi.sendUserMessage(wake, { deliverAs: "followUp" });
967
- } catch (e2) {
2358
+ // First matching entry wins, in config order. The label defaults to
2359
+ // the entry's label, the entry's type, a matched `match.name`, then
2360
+ // the entry's preset id (or "command").
2361
+ const digestEntries = resolveConfig(rec.ctx).digest;
2362
+ const digestTarget: DigestJobTarget = {
2363
+ name: rec.name,
2364
+ type: rec.type,
2365
+ command: rec.cmd,
2366
+ };
2367
+ const selected = selectDigestEntry(digestEntries, digestTarget);
2368
+ if (selected) {
2369
+ if (truncatedAt !== null) {
2370
+ // A scorecard reads the log's END (summary lines, failure
2371
+ // lists) — exactly what a head cap drops. Its numbers would be
2372
+ // confidently wrong, so report why it was skipped instead.
2373
+ digestBlock = {
2374
+ label: selected.label,
2375
+ text:
2376
+ `skipped — the log was truncated at ${formatBytes(truncatedAt)} ` +
2377
+ "and this scorecard reads the log's end, which the cap dropped",
2378
+ };
2379
+ } else {
2380
+ const raw = await runDigestCommand(selected.command, logPath);
2381
+ const text = raw === undefined ? undefined : capDigestOutput(raw);
2382
+ if (text) digestBlock = { label: selected.label, text };
2383
+ }
2384
+ } else if (digestEntries?.length) {
2385
+ // Configured but nothing selected — otherwise silent. Surface the
2386
+ // job's type/name plus the configured types, once per distinct
2387
+ // diagnostic (capped), so a type mismatch or dead glob is visible.
2388
+ const warning = digestNoMatchWarning(digestTarget, digestEntries);
2389
+ if (!digestNoMatchWarned.has(warning)) {
2390
+ if (digestNoMatchWarned.size < DIGEST_NO_MATCH_WARN_CAP) {
2391
+ digestNoMatchWarned.add(warning);
2392
+ console.error(warning);
2393
+ } else if (!digestNoMatchSuppressed) {
2394
+ // Don't silently drop further distinct mismatches.
2395
+ digestNoMatchSuppressed = true;
2396
+ console.error(
2397
+ `[pi-bgrun] further digest no-match diagnostics suppressed (cap ${DIGEST_NO_MATCH_WARN_CAP})`,
2398
+ );
2399
+ }
2400
+ }
2401
+ }
2402
+ } catch (e) {
2403
+ // Silent-fail: a broken digest never breaks a wake (ground rule 3).
968
2404
  console.error(
969
- `[pi-bgrun] wake failed for job ${id}:`,
970
- (e2 as Error).message,
2405
+ `[pi-bgrun] digest failed for job ${id}:`,
2406
+ (e as Error).message,
971
2407
  );
972
2408
  }
973
- }
974
2409
 
975
- // Toast for the human.
976
- if (rec.ctx.hasUI) {
977
- const toastLabel = (rec.name ?? command).slice(0, 50);
978
- rec.ctx.ui.notify(
979
- `${exitEmoji} ${toastLabel} exit ${exitStr}`,
980
- exitCode === 0 ? "info" : "error",
981
- );
982
- }
2410
+ // Wake the agent.
2411
+ const namePrefix = rec.name ? `"${rec.name}" ` : "";
2412
+ let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`;
2413
+ wake += `Command: ${command}\n`;
2414
+ wake += `Stats: ${statsParts.join(", ")}\n`;
2415
+ if (lastLine) wake += `Last output: ${lastLine}\n`;
2416
+ if (digestBlock) {
2417
+ wake += `digest (${digestBlock.label}): ${digestBlock.text}\n`;
2418
+ }
2419
+ wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`;
2420
+ try {
2421
+ if (rec.ctx.isIdle()) {
2422
+ pi.sendUserMessage(wake);
2423
+ } else {
2424
+ pi.sendUserMessage(wake, { deliverAs: "followUp" });
2425
+ }
2426
+ } catch {
2427
+ try {
2428
+ pi.sendUserMessage(wake, { deliverAs: "followUp" });
2429
+ } catch (e2) {
2430
+ console.error(
2431
+ `[pi-bgrun] wake failed for job ${id}:`,
2432
+ (e2 as Error).message,
2433
+ );
2434
+ }
2435
+ }
983
2436
 
984
- // Update/clear the widget.
985
- updateWidget(rec.ctx);
986
- });
2437
+ // Toast for the human.
2438
+ if (rec.ctx.hasUI) {
2439
+ const toastLabel = (rec.name ?? command).slice(0, 50);
2440
+ rec.ctx.ui.notify(
2441
+ `${exitEmoji} ${toastLabel} → exit ${exitStr}`,
2442
+ exitCode === 0 ? "info" : "error",
2443
+ );
2444
+ }
987
2445
 
988
- child.on("error", (err) => {
989
- console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message);
990
- jobs.delete(id);
991
- updateWidget(ctx);
992
- });
2446
+ // Update/clear the widget.
2447
+ updateWidget(rec.ctx);
2448
+ });
993
2449
 
994
- const startedLines = [`started: ${id}`];
995
- if (name) startedLines.push(` name: ${name}`);
996
- startedLines.push(
997
- ` log: ${logPath}`,
998
- ` You'll be woken automatically when it finishes.`,
999
- );
1000
- return {
1001
- content: [{ type: "text", text: startedLines.join("\n") }],
1002
- details: { id, name, logPath, pid: childPid },
1003
- };
2450
+ child.on("error", (err) => {
2451
+ console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message);
2452
+ finishSpawnFailure(err);
2453
+ });
2454
+
2455
+ const startedLines = [`started: ${id}`];
2456
+ if (name) startedLines.push(` name: ${name}`);
2457
+ if (type) startedLines.push(` type: ${type}`);
2458
+ startedLines.push(
2459
+ ` log: ${logPath}`,
2460
+ ` You'll be woken automatically when it finishes.`,
2461
+ );
2462
+ return {
2463
+ content: [{ type: "text", text: startedLines.join("\n") }],
2464
+ details: { id, name, type, logPath, pid: childPid },
2465
+ };
2466
+ } finally {
2467
+ if (logFd !== undefined) closeSync(logFd);
2468
+ }
1004
2469
  },
1005
2470
  });
1006
2471
 
@@ -1013,6 +2478,162 @@ export default function (pi: ExtensionAPI) {
1013
2478
  const LINE_CAP = 2000; // chars per line after stripping
1014
2479
  const TOTAL_CAP = 8000; // chars for the whole bgtail result
1015
2480
 
2481
+ // ── Digest: opt-in project-config scorecard appended to the wake ──────────
2482
+ // Runs only when a trusted project (or the user file) configures a `digest`
2483
+ // section. Best-effort, silent-fail: errors, timeouts, and empty output all
2484
+ // contribute nothing, and the digest never affects the exit code, ordering,
2485
+ // or the wake's universal part (ground rules 2-3).
2486
+ const DIGEST_TIMEOUT_MS = 5000; // hard bound on added wake latency
2487
+ const DIGEST_KILL_GRACE_MS = 250; // SIGTERM → SIGKILL grace
2488
+ const DIGEST_TOTAL_CAP = 500; // chars appended to the wake, first lines win
2489
+ const DIGEST_LINE_CAP = 200; // per-line cap, consistent with the condenser
2490
+
2491
+ // Run a digest command (log path arrives as $1) and collect stdout.
2492
+ // Resolves undefined on spawn error, non-timeout failure semantics are the
2493
+ // caller's concern (empty output is dropped when capping). A timed-out
2494
+ // command contributes NOTHING — after SIGKILL we resolve immediately with
2495
+ // undefined so the wake is never delayed past DIGEST_TIMEOUT_MS + grace.
2496
+ function runDigestCommand(
2497
+ cmd: string,
2498
+ logPath: string,
2499
+ ): Promise<string | undefined> {
2500
+ return new Promise((resolve) => {
2501
+ let settled = false;
2502
+ let timedOut = false;
2503
+ const finish = (out: string | undefined) => {
2504
+ if (settled) return;
2505
+ settled = true;
2506
+ // Stop accepting output. A grandchild can keep the pipe's write end
2507
+ // open after `sh` exits (pipelines, `cmd &`), and a live `data`
2508
+ // listener would otherwise grow this buffer forever.
2509
+ child?.stdout?.removeAllListeners("data");
2510
+ resolve(out);
2511
+ };
2512
+ let child: ReturnType<typeof spawn>;
2513
+ try {
2514
+ child = spawn("sh", ["-c", cmd, "--", logPath], {
2515
+ stdio: ["ignore", "pipe", "ignore"],
2516
+ // Own process group so a timeout can kill the whole pipeline (sh AND
2517
+ // its children), not just `sh`. Without this, grandchildren survive
2518
+ // and keep the pipe open.
2519
+ detached: true,
2520
+ });
2521
+ } catch {
2522
+ finish(undefined);
2523
+ return;
2524
+ }
2525
+ let stdout = "";
2526
+ child.stdout?.on("data", (chunk: Buffer) => {
2527
+ // Bounded collection: once past the wake cap, stop buffering but keep
2528
+ // the listener attached so the child is never blocked on a full pipe.
2529
+ // capDigestOutput() trims the overshoot to DIGEST_TOTAL_CAP.
2530
+ if (stdout.length > DIGEST_TOTAL_CAP) return;
2531
+ stdout += chunk.toString();
2532
+ });
2533
+ // Kill the whole process group (see `detached` above). Process groups
2534
+ // are POSIX-only; the `child.kill` fallback covers platforms where the
2535
+ // negative-pid kill fails. `childExited` guards against signalling a
2536
+ // group whose pid may already have been recycled after the child exits.
2537
+ let childExited = false;
2538
+ const killGroup = (signal: NodeJS.Signals) => {
2539
+ if (childExited) return;
2540
+ const pid = child.pid;
2541
+ try {
2542
+ if (pid === undefined) throw new Error("no pid");
2543
+ process.kill(-pid, signal);
2544
+ } catch {
2545
+ try {
2546
+ child.kill(signal);
2547
+ } catch {
2548
+ // already gone
2549
+ }
2550
+ }
2551
+ };
2552
+ // Hard timeout: SIGTERM first, SIGKILL after a short grace.
2553
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
2554
+ const killTimer = setTimeout(() => {
2555
+ timedOut = true;
2556
+ killGroup("SIGTERM");
2557
+ graceTimer = setTimeout(() => {
2558
+ killGroup("SIGKILL");
2559
+ finish(undefined);
2560
+ }, DIGEST_KILL_GRACE_MS);
2561
+ }, DIGEST_TIMEOUT_MS);
2562
+ const stopTimers = () => {
2563
+ clearTimeout(killTimer);
2564
+ if (graceTimer) clearTimeout(graceTimer);
2565
+ };
2566
+ child.on("error", () => {
2567
+ childExited = true;
2568
+ stopTimers();
2569
+ finish(undefined);
2570
+ });
2571
+ child.on("exit", (code) => {
2572
+ childExited = true;
2573
+ stopTimers();
2574
+ // Contract: a digest that ERRORS contributes nothing. Gate on the exit
2575
+ // code so partial output from a failed command never reaches the wake.
2576
+ // Shipped presets all end in `head`, which exits 0.
2577
+ finish(timedOut || code !== 0 ? undefined : stdout);
2578
+ });
2579
+ });
2580
+ }
2581
+
2582
+ // Cap digest output for the wake: first lines win. ANSI stripped (reusing
2583
+ // the condenser's regex), per-line cap for consistency, blank lines
2584
+ // dropped, ~500 chars total. Nothing usable → undefined (nothing appended).
2585
+ function capDigestOutput(raw: string): string | undefined {
2586
+ const lines = raw
2587
+ .replace(ANSI_RE, "")
2588
+ .split("\n")
2589
+ .map((l) =>
2590
+ l.length > DIGEST_LINE_CAP ? l.slice(0, DIGEST_LINE_CAP) : l,
2591
+ )
2592
+ .filter((l) => l.trim().length > 0);
2593
+ if (lines.length === 0) return undefined;
2594
+ const joined = lines.join("\n");
2595
+ const capped =
2596
+ joined.length > DIGEST_TOTAL_CAP
2597
+ ? joined.slice(0, DIGEST_TOTAL_CAP)
2598
+ : joined;
2599
+ return capped.trim() || undefined;
2600
+ }
2601
+
2602
+ // No-match diagnostics seen this process (capped) — keyed by the full
2603
+ // warning string so a type mismatch and a dead regex each surface once.
2604
+ const digestNoMatchWarned = new Set<string>();
2605
+ let digestNoMatchSuppressed = false;
2606
+ const DIGEST_NO_MATCH_WARN_CAP = 3;
2607
+
2608
+ // ── Digest nudge: one-shot session_start toast for digest-less projects ────
2609
+ // When a trusted project has actually used bgrun (≥1 finished job log in the
2610
+ // jobs dir) but never configured a digest, point the human at the
2611
+ // digest-config skill once. Toast only — never sendUserMessage, so it costs
2612
+ // zero LLM context. Dismissal is a per-project marker file in the jobs dir;
2613
+ // the user's config files are never written.
2614
+ function maybeNudgeDigest(ctx: ExtensionContext): void {
2615
+ try {
2616
+ if (!ctx.isProjectTrusted?.()) return;
2617
+ const cfg = resolveConfig(ctx);
2618
+ if (cfg.digest) return; // already configured — nothing to nudge
2619
+ if (!ctx.hasUI) return; // toast-only feature; no UI → nothing to do
2620
+ const projectDir = projectRootFor(ctx.cwd ?? process.cwd());
2621
+ // Project-scoped evidence of use (written at spawn) — never the shared
2622
+ // jobs dir as a whole, which would toast every project on the machine.
2623
+ if (!existsSync(jobUsageMarkerPath(cfg.jobsDir, projectDir))) return;
2624
+ const markerPath = digestNudgeMarkerPath(cfg.jobsDir, projectDir);
2625
+ if (existsSync(markerPath)) return; // already nudged once — stay silent
2626
+ ctx.ui.notify(DIGEST_NUDGE_TEXT, "info");
2627
+ try {
2628
+ writeFileSync(markerPath, String(Date.now()));
2629
+ } catch {
2630
+ // best-effort — a marker write failure must never break session_start
2631
+ }
2632
+ } catch (err) {
2633
+ console.error("[pi-bgrun] digest nudge failed:", (err as Error).message);
2634
+ }
2635
+ }
2636
+
1016
2637
  function condenseLogLines(
1017
2638
  lines: string[],
1018
2639
  opts: { raw?: boolean } = {},
@@ -1080,18 +2701,49 @@ export default function (pi: ExtensionAPI) {
1080
2701
  // for lines already seen. Deliberately-skipped prefix lines are never
1081
2702
  // replayed as "new". raw: true keeps the verbatim last-N window (no delta
1082
2703
  // 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.
2704
+ // resets to a full tail. Bookmarks are in-memory only (see tailBookmarks
2705
+ // above) — a session restart starts fresh with a full tail.
2706
+
2707
+ // Record a bookmark, evicting the oldest entry once the map is full. Cleanup
2708
+ // drops bookmarks when it removes a log; this bounds the rest.
2709
+ function rememberTail(id: string, bookmark: TailBookmark): void {
2710
+ tailBookmarks.set(id, bookmark);
2711
+ if (tailBookmarks.size <= TAIL_BOOKMARK_CAP) return;
2712
+ const oldest = tailBookmarks.keys().next().value;
2713
+ if (oldest !== undefined && oldest !== id) tailBookmarks.delete(oldest);
2714
+ }
1085
2715
 
1086
- const tailBookmarks = new Map<
1087
- string,
1088
- { lines: number; bytes: number; first: string }
1089
- >();
2716
+ // Resolve a job's log path and read its bounded slice, single-sourcing the
2717
+ // "in-memory record first, then the configured jobs dir" rule shared by
2718
+ // bgtail and bggrep. The record's logPath stays correct even if the config
2719
+ // (and thus the resolved jobs dir) changes mid-session. On failure the caller
2720
+ // renders tool-specific error details.
2721
+ function resolveLogForJob(
2722
+ id: string,
2723
+ tool: string,
2724
+ ctx: ExtensionContext | undefined,
2725
+ window: number,
2726
+ ):
2727
+ | { logPath: string; content: string; size: number }
2728
+ | { logPath: string; errorText: string; notFound: boolean } {
2729
+ validateJobId(id, tool);
2730
+ const logPath =
2731
+ jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`);
2732
+ const slice = readLogSlice(logPath, window);
2733
+ if (!slice) {
2734
+ return {
2735
+ logPath,
2736
+ errorText: logReadError(id, logPath),
2737
+ notFound: !existsSync(logPath),
2738
+ };
2739
+ }
2740
+ return { logPath, content: slice.content, size: slice.size };
2741
+ }
1090
2742
 
1091
2743
  // Shared by the bgtail tool (agent-facing) and the /bgtail slash command
1092
2744
  // (human-facing).
1093
2745
  async function bgtailCore(
1094
- params: { id: string; lines?: number; raw?: boolean },
2746
+ params: { id: string; lines?: number; raw?: boolean; bytes?: number },
1095
2747
  ctx?: ExtensionContext,
1096
2748
  ): Promise<{
1097
2749
  content: { type: "text"; text: string }[];
@@ -1103,115 +2755,164 @@ export default function (pi: ExtensionAPI) {
1103
2755
  // tool schema, and lines < 1 would corrupt slicing (slice(-0) = whole log).
1104
2756
  const lines = Math.max(1, Math.floor(linesParam));
1105
2757
  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` : "";
2758
+ const readWindow = clampReadWindow(params.bytes);
2759
+ const resolved = resolveLogForJob(id, "bgtail", ctx, readWindow);
2760
+ if ("errorText" in resolved) {
1187
2761
  return {
1188
- content: [{ type: "text", text: head + body + notes }],
2762
+ content: [{ type: "text", text: resolved.errorText }],
1189
2763
  details: {
1190
2764
  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 } : {}),
2765
+ logPath: resolved.logPath,
2766
+ notFound: resolved.notFound,
1197
2767
  },
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
2768
  isError: true,
1206
2769
  };
1207
2770
  }
2771
+ const { logPath, content, size } = resolved;
2772
+ // The cap dropped bytes off the END, so every "last N lines" view below is
2773
+ // the end of what was KEPT. Flag it in the output, or a mid-run line reads
2774
+ // as the job's final word — and in the details, for callers that parse them.
2775
+ const truncatedAt = parseTruncationFromContent(content);
2776
+ const capNote =
2777
+ truncatedAt === null
2778
+ ? ""
2779
+ : `\n\n(log truncated at ${formatBytes(truncatedAt)} — lines past the cap were never written, so this is not the run's real end)`;
2780
+ const truncDetails =
2781
+ truncatedAt === null ? {} : { truncatedAtBytes: truncatedAt };
2782
+ // Tail reads are bounded at LOG_READ_BYTES, so on a log past that window
2783
+ // every view above is the end of a slice — say so, or "not in the output"
2784
+ // reads as "not in the log". The advertised maximum is the ceiling actually
2785
+ // in force plus the wrapper's overhead, so it can cover a capped log
2786
+ // (a max equal to the cap left its first bytes permanently unreadable).
2787
+ const windowNote =
2788
+ size > readWindow
2789
+ ? `\n\n(searched the last ${formatBytes(readWindow)} of ${formatBytes(size)} — the earlier bytes were not searched; pass a larger \`bytes\` (max ${formatBytes(readWindowMax())}) or use ctx_execute_file on the log path)`
2790
+ : "";
2791
+ // Content lines only: wrapper bookkeeping (exit marker, truncation notice)
2792
+ // and blanks are filtered BEFORE the window is sliced, so "last N lines"
2793
+ // means the last N content lines (matching pre-delta behavior) and
2794
+ // bookmarks count content lines.
2795
+ // boundScanLines first: a window of very short lines (the `yes ''` runaway
2796
+ // the ceiling exists for) is millions of lines in a few MiB, and splitting
2797
+ // it all costs gigabytes of RSS on the host's main thread.
2798
+ // /\r?\n/ keeps CRLF logs from leaving a stray \r on every line.
2799
+ const scan = boundScanLines(content);
2800
+ const lineNote = scan.lineBoundHit
2801
+ ? `\n\n(and only the last ${LOG_SCAN_LINES_MAX.toLocaleString("en-US")} lines of that window were scanned)`
2802
+ : "";
2803
+ const rawLines = scan.content
2804
+ .split(/\r?\n/)
2805
+ .filter((l) => !isWrapperLine(l) && l.trim().length > 0);
2806
+ const total = rawLines.length;
2807
+ const first = rawLines[0]?.slice(0, 200) ?? "";
2808
+ const prev = tailBookmarks.get(id);
2809
+ // Same log, different window: a wider view would look like pages of "new"
2810
+ // lines that were only never looked at before, so reset the delta. It also
2811
+ // moves the window's first line, so it must be tested BEFORE the
2812
+ // replacement heuristic below — otherwise a widened read misreports the log
2813
+ // as replaced.
2814
+ const windowChanged = prev !== undefined && prev.window !== readWindow;
2815
+ // Append-only logs never mutate earlier lines, so a changed first
2816
+ // content line means the log was replaced or rotated — reset to a full
2817
+ // tail. Catches same-size replacements the shrink checks cannot see.
2818
+ // (A previously-empty log growing content is growth, not replacement.)
2819
+ const replaced =
2820
+ prev !== undefined &&
2821
+ !windowChanged &&
2822
+ prev.lines > 0 &&
2823
+ prev.first !== first;
2824
+ const shrank =
2825
+ prev !== undefined && (prev.lines > total || prev.bytes > size);
2826
+ let window: string[];
2827
+ let header: string | undefined;
2828
+ let newLines: number | undefined;
2829
+ if (raw || prev === undefined || shrank || replaced || windowChanged) {
2830
+ // Full tail: first read, raw mode, a shrunken/replaced log, or a changed
2831
+ // search window (all resets).
2832
+ window = rawLines.slice(-lines);
2833
+ if (!raw) {
2834
+ header = shrank
2835
+ ? "log shrank since last read — showing full tail"
2836
+ : replaced
2837
+ ? "log was replaced since last read — showing full tail"
2838
+ : windowChanged
2839
+ ? "search window changed since last read — showing full tail"
2840
+ : undefined;
2841
+ }
2842
+ } else {
2843
+ const fresh = rawLines.slice(prev.lines);
2844
+ newLines = fresh.length;
2845
+ if (fresh.length === 0) {
2846
+ rememberTail(id, {
2847
+ lines: total,
2848
+ bytes: size,
2849
+ first,
2850
+ window: readWindow,
2851
+ });
2852
+ return {
2853
+ content: [
2854
+ {
2855
+ type: "text",
2856
+ text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})${capNote}${windowNote}${lineNote}`,
2857
+ },
2858
+ ],
2859
+ details: {
2860
+ id,
2861
+ linesShown: 0,
2862
+ logPath,
2863
+ notFound: false,
2864
+ condensed: true,
2865
+ newLines: 0,
2866
+ totalLines: total,
2867
+ windowBytes: readWindow,
2868
+ ...truncDetails,
2869
+ },
2870
+ };
2871
+ }
2872
+ window = fresh.length > lines ? fresh.slice(-lines) : fresh;
2873
+ header =
2874
+ `+${fresh.length} new line${fresh.length === 1 ? "" : "s"} since last read — ` +
2875
+ `log at ${total} lines${fresh.length > lines ? ` (showing last ${lines})` : ""}`;
2876
+ }
2877
+ rememberTail(id, {
2878
+ lines: total,
2879
+ bytes: size,
2880
+ first,
2881
+ window: readWindow,
2882
+ });
2883
+ const shown = window;
2884
+ const { text, truncated } = condenseLogLines(shown, { raw });
2885
+ // Delta reads early-return above, so an empty window here can only be
2886
+ // a first read of an empty log (full-tail path).
2887
+ const body = shown.length === 0 ? "(empty log)" : text;
2888
+ const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
2889
+ const head = header ? `${header}\n` : "";
2890
+ return {
2891
+ content: [
2892
+ {
2893
+ type: "text",
2894
+ text: head + body + notes + capNote + windowNote + lineNote,
2895
+ },
2896
+ ],
2897
+ details: {
2898
+ id,
2899
+ linesShown: shown.length,
2900
+ logPath,
2901
+ notFound: false,
2902
+ condensed: !raw,
2903
+ ...(newLines === undefined ? {} : { newLines, totalLines: total }),
2904
+ ...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
2905
+ windowBytes: readWindow,
2906
+ ...truncDetails,
2907
+ },
2908
+ };
1208
2909
  }
1209
2910
 
1210
2911
  pi.registerTool({
1211
2912
  name: "bgtail",
1212
2913
  label: "Tail Background Log",
1213
2914
  description:
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.",
2915
+ "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, replaced, or differently-windowed log resets to a full tail. Reads only the log's last 2 MiB by default (`bytes` widens it, max 64 MiB) and says so when the log is bigger. For pattern search use bggrep; for whole-log analysis, ctx_execute_file on the log path.",
1215
2916
  promptSnippet: "Read the last N lines of a bgrun job's log",
1216
2917
  parameters: Type.Object({
1217
2918
  id: Type.String({
@@ -1229,6 +2930,13 @@ export default function (pi: ExtensionAPI) {
1229
2930
  "Skip condensing (ANSI strip, collapse, caps) and return raw text",
1230
2931
  }),
1231
2932
  ),
2933
+ bytes: Type.Optional(
2934
+ Type.Number({
2935
+ description:
2936
+ "Search window in bytes (default 2097152 = 2 MiB; capped at the configured log ceiling plus the wrapper's overhead — 67108864 = 64 MiB by default). Widening affects how much is SCANNED (and what the scan costs in CPU and memory) — the returned text stays capped.",
2937
+ minimum: 1,
2938
+ }),
2939
+ ),
1232
2940
  }),
1233
2941
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1234
2942
  return bgtailCore(params, ctx);
@@ -1237,18 +2945,19 @@ export default function (pi: ExtensionAPI) {
1237
2945
 
1238
2946
  // ── bggrep: pattern search over a job's log, capped for context ───────────
1239
2947
  //
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),
2948
+ // bggrep runs inside the extension, so it resolves the job id to the
2949
+ // configured jobs dir itself (no path to reconstruct) and needs no shell
2950
+ // quoting for the regex; ctx_execute_file can read the same file, but you
2951
+ // must hand it the absolute path. Matches are line-numbered (grep -n style),
1244
2952
  // optionally with context lines, capped at MAX_GREP_MATCHES, and run
1245
2953
  // through the same condenser as bgtail so a search can never flood context.
1246
2954
 
1247
2955
  const MAX_GREP_MATCHES = 50;
1248
2956
 
1249
2957
  async function bggrepCore(
1250
- params: { id: string; pattern?: string; context?: number },
2958
+ params: { id: string; pattern?: string; context?: number; bytes?: number },
1251
2959
  ctx?: ExtensionContext,
2960
+
1252
2961
  ): Promise<{
1253
2962
  content: { type: "text"; text: string }[];
1254
2963
  details: Record<string, unknown>;
@@ -1259,52 +2968,121 @@ export default function (pi: ExtensionAPI) {
1259
2968
  // themselves from the context windows (lo > hi no-ops the inner loop).
1260
2969
  const context = Math.max(0, Math.floor(contextParam));
1261
2970
  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`);
2971
+ // resolveLogForJob() below validates the id; no need to double-check.
1265
2972
  const source = pattern ?? DEFAULT_GREP_PATTERN;
1266
- let re: RegExp;
1267
2973
  try {
1268
- re = new RegExp(source);
2974
+ // Validate up front so a bad pattern fails immediately, without a worker.
2975
+ void new RegExp(source);
1269
2976
  } catch (err) {
1270
2977
  throw new Error(
1271
2978
  `bggrep: invalid pattern ${JSON.stringify(source)}: ${(err as Error).message}`,
1272
2979
  );
1273
2980
  }
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 {
2981
+ // Record-first, same as bgtail — correct across config changes.
2982
+ const readWindow = clampReadWindow(params.bytes);
2983
+ const resolved = resolveLogForJob(id, "bggrep", ctx, readWindow);
2984
+ if ("errorText" in resolved) {
2985
+ return {
2986
+ content: [{ type: "text", text: resolved.errorText }],
2987
+ details: {
2988
+ id,
2989
+ matches: 0,
2990
+ logPath: resolved.logPath,
2991
+ notFound: resolved.notFound,
2992
+ },
2993
+ isError: true,
2994
+ };
2995
+ }
2996
+ const { logPath, content, size } = resolved;
2997
+ // A capped log is missing its END, and "no matches" is exactly what a
2998
+ // failure pattern looks like when the failures were past the cap — so the
2999
+ // note belongs next to the count, not just in the details.
3000
+ const truncatedAt = parseTruncationFromContent(content);
3001
+ const truncNote =
3002
+ truncatedAt === null
3003
+ ? ""
3004
+ : `\n\n(log truncated at ${formatBytes(truncatedAt)} — output past the cap was never written and was not searched)`;
3005
+ const truncDetails =
3006
+ truncatedAt === null ? {} : { truncatedAtBytes: truncatedAt };
3007
+ // Same window caveat as bgtail: the search covers only the last `window`
3008
+ // bytes, so a miss on a bigger log means "not in the searched slice". The
3009
+ // advertised maximum is the ceiling in force plus the wrapper's overhead, so
3010
+ // it can cover a capped log.
3011
+ const windowNote =
3012
+ size > readWindow
3013
+ ? `\n\n(searched the last ${formatBytes(readWindow)} of ${formatBytes(size)} — the earlier bytes were not searched; pass a larger \`bytes\` (max ${formatBytes(readWindowMax())}) or use ctx_execute_file on the log path)`
3014
+ : "";
3015
+ // Bound the LINE count before splitting: a window of very short lines is
3016
+ // millions of lines in a few MiB, and materializing them costs ~100 bytes
3017
+ // each (>3 GB measured for a 64 MiB window) — on the main thread, in the
3018
+ // very log class the ceiling exists for. The caveat says when it bit.
3019
+ const scan = boundScanLines(content);
3020
+ const lineNote = scan.lineBoundHit
3021
+ ? `\n\n(and only the last ${LOG_SCAN_LINES_MAX.toLocaleString("en-US")} lines of that window were searched)`
3022
+ : "";
3023
+ // /\r?\n/ normalizes CRLF (a trailing \r would break $-anchored patterns
3024
+ // and leak into output); blank lines are KEPT so L<n> numbers match the
3025
+ // file. A trailing empty split element is dropped; "" yields zero lines.
3026
+ const split = scan.content === "" ? [] : scan.content.split(/\r?\n/);
3027
+ if (split.length > 0 && split[split.length - 1] === "") split.pop();
3028
+ const rawLines = split.filter((l) => !isWrapperLine(l));
3029
+ // Match under a wall-clock budget in a worker: a caller-supplied regex can
3030
+ // backtrack catastrophically and would otherwise hang the main thread with
3031
+ // no way to interrupt it.
3032
+ const budgetMs = bggrepTimeoutMs();
3033
+ const outcome = await matchLinesWithBudget(
3034
+ source,
3035
+ rawLines,
3036
+ BGGREP_LINE_CAP,
3037
+ budgetMs,
3038
+ );
3039
+ if (outcome.kind === "invalid") {
3040
+ throw new Error(
3041
+ `bggrep: invalid pattern ${JSON.stringify(source)}: ${outcome.message}`,
3042
+ );
3043
+ }
3044
+ if (outcome.kind === "timeout") {
1284
3045
  return {
1285
3046
  content: [
1286
- { type: "text", text: `No log found for job ${id} at ${logPath}` },
3047
+ {
3048
+ type: "text",
3049
+ text:
3050
+ `bggrep: /${source}/ exceeded the ${budgetMs}ms match budget across ` +
3051
+ `${rawLines.length} line${rawLines.length === 1 ? "" : "s"} — likely ` +
3052
+ `catastrophic backtracking; no results computed.`,
3053
+ },
1287
3054
  ],
1288
- details: { id, matches: 0, logPath, notFound: true },
3055
+ details: {
3056
+ id,
3057
+ matches: 0,
3058
+ linesSearched: rawLines.length,
3059
+ logPath,
3060
+ notFound: false,
3061
+ pattern: source,
3062
+ timedOut: true,
3063
+ windowBytes: readWindow,
3064
+ ...truncDetails,
3065
+ },
1289
3066
  isError: true,
1290
3067
  };
1291
3068
  }
1292
- const matchIdx: number[] = [];
1293
- for (let i = 0; i < rawLines.length; i++) {
1294
- if (re.test(rawLines[i])) matchIdx.push(i);
1295
- }
3069
+ const matchIdx = outcome.matchIdx;
1296
3070
  const header =
1297
3071
  `${matchIdx.length} match${matchIdx.length === 1 ? "" : "es"} for /${source}/ ` +
1298
3072
  `in ${rawLines.length} line${rawLines.length === 1 ? "" : "s"}`;
1299
3073
  if (matchIdx.length === 0) {
1300
3074
  return {
1301
- content: [{ type: "text", text: `${header} — none` }],
3075
+ content: [
3076
+ { type: "text", text: `${header} — none${truncNote}${windowNote}${lineNote}` },
3077
+ ],
1302
3078
  details: {
1303
3079
  id,
1304
3080
  matches: 0,
1305
3081
  linesSearched: rawLines.length,
1306
3082
  logPath,
1307
3083
  notFound: false,
3084
+ windowBytes: readWindow,
3085
+ ...truncDetails,
1308
3086
  },
1309
3087
  };
1310
3088
  }
@@ -1334,7 +3112,12 @@ export default function (pi: ExtensionAPI) {
1334
3112
  ? ` — showing first ${MAX_GREP_MATCHES}; ${matchIdx.length - MAX_GREP_MATCHES} more not shown`
1335
3113
  : "";
1336
3114
  return {
1337
- content: [{ type: "text", text: `${header}${capNote}\n${text}${notes}` }],
3115
+ content: [
3116
+ {
3117
+ type: "text",
3118
+ text: `${header}${capNote}\n${text}${notes}${truncNote}${windowNote}${lineNote}`,
3119
+ },
3120
+ ],
1338
3121
  details: {
1339
3122
  id,
1340
3123
  matches: matchIdx.length,
@@ -1343,6 +3126,8 @@ export default function (pi: ExtensionAPI) {
1343
3126
  notFound: false,
1344
3127
  pattern: source,
1345
3128
  capped,
3129
+ windowBytes: readWindow,
3130
+ ...truncDetails,
1346
3131
  },
1347
3132
  };
1348
3133
  }
@@ -1351,7 +3136,7 @@ export default function (pi: ExtensionAPI) {
1351
3136
  name: "bggrep",
1352
3137
  label: "Grep Background Log",
1353
3138
  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).",
3139
+ "Search the tail of a background job's log with a regex — the last 2 MiB by default, widen with `bytes` (each line is pre-truncated to 10k chars before matching); returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. Matching runs under a wall-clock budget (default 2s, PI_BGRUN_GREP_TIMEOUT_MS), so a runaway regex fails instead of hanging. Resolves the job id to the configured jobs dir itself, so there is no log path to reconstruct; ctx_execute_file can read the same file, but needs the absolute path. 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
3140
  promptSnippet: "Search a bgrun job's log for a pattern",
1356
3141
  promptGuidelines: [
1357
3142
  "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.",
@@ -1375,6 +3160,13 @@ export default function (pi: ExtensionAPI) {
1375
3160
  minimum: 0,
1376
3161
  }),
1377
3162
  ),
3163
+ bytes: Type.Optional(
3164
+ Type.Number({
3165
+ description:
3166
+ "Search window in bytes (default 2097152 = 2 MiB; capped at the configured log ceiling plus the wrapper's overhead — 67108864 = 64 MiB by default). Widening affects how much is SCANNED (and what the scan costs in CPU and memory) — the returned matches stay capped (~50 matches, ~8KB).",
3167
+ minimum: 1,
3168
+ }),
3169
+ ),
1378
3170
  }),
1379
3171
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1380
3172
  return bggrepCore(params, ctx);
@@ -1397,58 +3189,81 @@ export default function (pi: ExtensionAPI) {
1397
3189
  const cfg = resolveConfig(ctx);
1398
3190
  const jobsDir = cfg.jobsDir;
1399
3191
  if (id) {
3192
+ validateJobId(id, "bgstatus");
1400
3193
  const rec = jobs.get(id);
1401
3194
  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}`];
3195
+ // Read-only reconciliation: an in-memory record whose exit event never
3196
+ // fired (or one reconstructed on restart) can lag its log. Derive the
3197
+ // real state from the marker / pid liveness WITHOUT mutating or
3198
+ // persisting — the list path and the 30s poller own persistence. This
3199
+ // keeps by-id and list from disagreeing for up to a poll interval.
3200
+ let exit = rec.exitCode;
3201
+ if (exit === undefined && !rec.child) {
3202
+ // No live handle (reconstructed/adopted) — derive from the log or a
3203
+ // dead pid. A live child is authoritative: a running job whose own
3204
+ // output contains a spurious __BGRUN_EXIT__ line must not read done.
3205
+ const fromLog = parseExitFromLogPath(rec.logPath);
3206
+ if (fromLog !== null) exit = fromLog;
3207
+ else if (rec.pid <= 0 || !isRunningPid(rec.pid)) exit = -1;
3208
+ }
3209
+ const state = exit === undefined ? "running" : "done";
3210
+ const exitStr = exit === undefined ? "" : ` exit=${exit}`;
3211
+ const lines = [`${id}: ${state}${exitStr}`];
1405
3212
  if (rec.name) lines.push(` name: ${rec.name}`);
3213
+ if (rec.type) lines.push(` type: ${rec.type}`);
1406
3214
  lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
1407
3215
  return {
1408
3216
  content: [{ type: "text", text: lines.join("\n") }],
1409
3217
  details: {
1410
3218
  id,
1411
3219
  state,
1412
- exitCode: rec.exitCode ?? undefined,
3220
+ exitCode: exit ?? undefined,
1413
3221
  cmd: rec.cmd,
1414
3222
  name: rec.name,
3223
+ type: rec.type,
1415
3224
  recovered: false,
1416
3225
  },
1417
3226
  };
1418
3227
  }
1419
3228
  const logPath = join(jobsDir, `${id}.log`);
1420
- try {
1421
- const exit = parseExitFromLog(logPath);
1422
- const state = exit === null ? "running" : "done";
1423
- return {
1424
- content: [
1425
- {
1426
- type: "text",
1427
- text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
1428
- },
1429
- ],
1430
- details: {
1431
- id,
1432
- state,
1433
- exitCode: exit ?? undefined,
1434
- recovered: true,
1435
- },
1436
- };
1437
- } catch {
3229
+ if (!existsSync(logPath)) {
1438
3230
  return {
1439
3231
  content: [{ type: "text", text: `No job found with id ${id}` }],
1440
3232
  details: { id, state: "unknown" },
1441
3233
  isError: true,
1442
3234
  };
1443
3235
  }
3236
+ let exit = parseExitFromLogPath(logPath);
3237
+ if (exit === null) {
3238
+ const pid = pidFromId(id);
3239
+ if (pid !== null && (pid <= 0 || !isRunningPid(pid))) exit = -1;
3240
+ }
3241
+ const state = exit === null ? "running" : "done";
3242
+ return {
3243
+ content: [
3244
+ {
3245
+ type: "text",
3246
+ text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
3247
+ },
3248
+ ],
3249
+ details: {
3250
+ id,
3251
+ state,
3252
+ exitCode: exit ?? undefined,
3253
+ recovered: true,
3254
+ },
3255
+ };
1444
3256
  }
1445
3257
  // 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.
3258
+ // includeDone / showCompletedJobs is set). Other sessions' RUNNING jobs
3259
+ // appear only when adoptForeignJobs is opted in; finished foreign logs
3260
+ // from the shared dir can also appear when finished jobs are included.
3261
+ // Hidden disk logs get a one-line count instead of spamming the listing.
1449
3262
  const showDone = params.includeDone ?? cfg.showCompletedJobs;
1450
- revalidateStaleJobs();
1451
- updateWidget(ctx);
3263
+ // Read-only: asking for status must not append transcript cards. The stale
3264
+ // poller (when a job is unsupervised) persists independently.
3265
+ revalidateStaleJobs({ persist: false });
3266
+ updateWidget(ctx, { persistRevalidate: false });
1452
3267
  const lines: string[] = [];
1453
3268
  const seen = new Set<string>();
1454
3269
  for (const [jid, rec] of jobs) {
@@ -1462,30 +3277,33 @@ export default function (pi: ExtensionAPI) {
1462
3277
  }
1463
3278
  }
1464
3279
  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) {
3280
+ if (!showDone && !cfg.adoptForeignJobs) {
3281
+ // Default listing: every on-disk log is just a hidden count. Skip the
3282
+ // exit-marker parse (a 256 KB tail read per file) — the cheap scan's
3283
+ // names are all we need.
3284
+ for (const entry of scanLogFiles(jobsDir)) {
3285
+ if (!seen.has(entry.id)) hiddenOnDisk++;
3286
+ }
3287
+ } else {
3288
+ for (const entry of scanJobsDir(jobsDir)) {
3289
+ if (seen.has(entry.id)) continue;
3290
+ if (entry.exit !== null) {
1473
3291
  // finished log on disk (other or older session)
1474
3292
  if (showDone) {
1475
- lines.push(` ${jid}: done exit=${exit} (from log)`);
3293
+ lines.push(` ${entry.id}: done exit=${entry.exit} (from log)`);
1476
3294
  } else {
1477
3295
  hiddenOnDisk++;
1478
3296
  }
1479
- } else if (cfg.adoptForeignJobs) {
1480
- // running foreign job — only surfaced when adoption is enabled
1481
- lines.push(` ${jid}: running (from log)`);
3297
+ } else if (cfg.adoptForeignJobs && entry.alive) {
3298
+ lines.push(` ${entry.id}: running (from log)`);
1482
3299
  } else {
1483
3300
  hiddenOnDisk++;
1484
3301
  }
1485
3302
  }
1486
- } catch {
1487
- // jobs dir doesn't exist — nothing to scan.
1488
3303
  }
3304
+ // Count jobs, not display lines: capture before appending the "(N more…)"
3305
+ // footer, which is a note rather than a job.
3306
+ const jobCount = lines.length;
1489
3307
  if (hiddenOnDisk > 0) {
1490
3308
  lines.push(
1491
3309
  ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean all to prune)`,
@@ -1499,7 +3317,7 @@ export default function (pi: ExtensionAPI) {
1499
3317
  }
1500
3318
  return {
1501
3319
  content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
1502
- details: { count: lines.length },
3320
+ details: { count: jobCount },
1503
3321
  };
1504
3322
  }
1505
3323
 
@@ -1509,7 +3327,8 @@ export default function (pi: ExtensionAPI) {
1509
3327
  description:
1510
3328
  "Show status of background jobs. With an id: one job's state + exit code. Without: list this session's " +
1511
3329
  "running jobs (finished jobs are hidden by default — pass includeDone or set showCompletedJobs to list " +
1512
- "them; other sessions' jobs are only listed when adoptForeignJobs is enabled).",
3330
+ "them). Other sessions' running jobs are listed only when adoptForeignJobs is enabled; finished foreign " +
3331
+ "logs from the shared dir can also appear when finished jobs are included.",
1513
3332
  promptSnippet: "Check status of bgrun jobs",
1514
3333
  parameters: Type.Object({
1515
3334
  id: Type.Optional(
@@ -1529,8 +3348,6 @@ export default function (pi: ExtensionAPI) {
1529
3348
 
1530
3349
  // ── bgclean: remove old job logs ───────────────────────────────────────────
1531
3350
 
1532
- // ── bgclean: remove old job logs ──────────────────────────────────────
1533
-
1534
3351
  // Shared by the bgclean tool (agent-facing) and the /bgclean slash command
1535
3352
  // (human-facing).
1536
3353
  async function bgcleanCore(
@@ -1542,21 +3359,38 @@ export default function (pi: ExtensionAPI) {
1542
3359
  }> {
1543
3360
  const cfg = resolveConfig(ctx);
1544
3361
  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
- );
3362
+ if (typeof days !== "number" || days <= 0 || !Number.isFinite(days)) {
3363
+ throw new Error(`bgclean: days must be a positive number, got ${days}`);
1549
3364
  }
1550
3365
  let result;
1551
3366
  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
3367
+ result = { removed: 0, kept: 0, skippedRunning: 0 };
3368
+ // "all" spans every shared jobs dir — the current project's plus the
3369
+ // machine-global default (so pre-project-local logs are still reachable).
3370
+ for (const dir of sharedJobsDirs(cfg.jobsDir, cfg.jobsDirProjectLocal)) {
3371
+ // An explicit `bgclean all` is the user's direct intent — bypass the
3372
+ // ownership marker gate so it always works.
3373
+ const r = cleanOldJobs(days, dir, ctx, { requireOwnership: false });
3374
+ result.removed += r.removed;
3375
+ result.kept += r.kept;
3376
+ result.skippedRunning += r.skippedRunning;
3377
+ // Do not create a jobs dir just to stamp the throttle marker — that
3378
+ // would dirty git status in a repo with no jobs (and mutate an
3379
+ // untrusted repo). Only refresh the marker when the dir already exists.
3380
+ if (!existsSync(dir)) continue;
3381
+ // Only the project-local dir may be git-excluded. The shared global
3382
+ // dir must NOT be excluded in whatever repo happens to contain it
3383
+ // (e.g. $HOME being a dotfiles repo).
3384
+ if (
3385
+ cfg.jobsDirProjectLocal &&
3386
+ safeRealpath(dir) === safeRealpath(cfg.jobsDir)
3387
+ )
3388
+ ensureGitExcluded(dir);
3389
+ try {
3390
+ writeFileSync(join(dir, ".last-clean"), String(Date.now()));
3391
+ } catch {
3392
+ // best-effort
3393
+ }
1560
3394
  }
1561
3395
  } else {
1562
3396
  // Session-scoped by default: bg* commands apply to the current
@@ -1576,8 +3410,11 @@ export default function (pi: ExtensionAPI) {
1576
3410
  label: "Clean Old Background Jobs",
1577
3411
  description:
1578
3412
  "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.",
3413
+ "untouched); this also drops stale per-project digest markers in the session's jobs dir (they are not session " +
3414
+ "data). Pass all: true to sweep every shared jobs dir under the project-local default that is the current " +
3415
+ "project's dir plus the machine-global one; an explicit absolute jobsDir is swept alone. Retention: " +
3416
+ "cleanupDays config (default 7 days). Never removes a running job's log. Prints a summary of what was removed " +
3417
+ "vs kept.",
1581
3418
  promptSnippet:
1582
3419
  "Remove old bgrun job logs (this session by default; all: true for every session's)",
1583
3420
  parameters: Type.Object({
@@ -1589,7 +3426,7 @@ export default function (pi: ExtensionAPI) {
1589
3426
  all: Type.Optional(
1590
3427
  Type.Boolean({
1591
3428
  description:
1592
- "Sweep the whole shared jobs dir (all sessions' logs), not just this session's (default false)",
3429
+ "Sweep every shared jobs dir (all sessions' logs) plus the machine-global dir under the project-local default; an absolute jobsDir is swept alone (default false)",
1593
3430
  }),
1594
3431
  ),
1595
3432
  }),