@stablekernel/pi-background-run 0.4.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.
@@ -34,26 +34,607 @@ import { Type } from "typebox";
34
34
  import { Box, Text } from "@earendil-works/pi-tui";
35
35
  import { spawn } from "node:child_process";
36
36
  import {
37
- openSync,
37
+ appendFileSync,
38
38
  closeSync,
39
- readFileSync,
39
+ existsSync,
40
+ fstatSync,
41
+ readSync,
40
42
  mkdirSync,
43
+ openSync,
44
+ readFileSync,
41
45
  readdirSync,
46
+ realpathSync,
42
47
  renameSync,
43
- unlinkSync,
44
48
  statSync,
49
+ unlinkSync,
45
50
  writeFileSync,
46
51
  } from "node:fs";
47
- import { join } from "node:path";
52
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
48
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";
49
63
 
50
64
  // Exit marker appended to every log so the file is self-describing: the exit
51
65
  // code survives pi restarting. `;` (not `&&`) ensures the printf runs even when
52
66
  // the command fails. Never use `set -e` in the wrapper.
53
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;
54
110
 
55
111
  const DEFAULT_CLEANUP_DAYS = 7;
56
112
  const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle
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
+ }
335
+
336
+ // Default regex for bggrep when the caller passes no pattern: common failure
337
+ // signatures across test runners and build tools. ONLY a convenience default —
338
+ // bggrep's contract is that the caller's own pattern always wins, because a
339
+ // generic default on arbitrary tools/languages misses more than it catches.
340
+ export const DEFAULT_GREP_PATTERN =
341
+ "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖";
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
+ }
57
638
 
58
639
  // ── Configuration ───────────────────────────────────────────────────────────
59
640
  //
@@ -66,6 +647,11 @@ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child h
66
647
 
67
648
  interface BgrunConfig {
68
649
  jobsDir: string;
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.
654
+ jobsDirProjectLocal: boolean;
69
655
  // Adopt other sessions' running jobs (found in the shared jobs dir) into
70
656
  // this session's widget and job list. Default false — most sessions don't
71
657
  // want unrelated jobs from other projects cluttering the widget.
@@ -75,14 +661,32 @@ interface BgrunConfig {
75
661
  showCompletedJobs: boolean;
76
662
  // Log retention for cleanup (auto-sweeps and the bgclean default).
77
663
  cleanupDays: number;
78
- // 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 —
79
670
  // finished (exit marker or dead pid) logs older than cleanupDays from
80
- // 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
81
674
  // pid-protected. Throttled to once per cleanupDays via a .last-clean marker.
82
675
  // Default true — without it, orphaned logs accumulate forever. Set false to
83
676
  // keep every sweep session-scoped (then only `bgclean all` touches foreign
84
677
  // logs).
85
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[];
86
690
  }
87
691
 
88
692
  interface BgrunConfigFile {
@@ -90,7 +694,9 @@ interface BgrunConfigFile {
90
694
  adoptForeignJobs?: unknown;
91
695
  showCompletedJobs?: unknown;
92
696
  cleanupDays?: unknown;
697
+ maxLogBytes?: unknown;
93
698
  globalAutoClean?: unknown;
699
+ digest?: unknown;
94
700
  }
95
701
 
96
702
  function parseBoolEnv(v: string | undefined): boolean | undefined {
@@ -102,28 +708,533 @@ function parseBoolEnv(v: string | undefined): boolean | undefined {
102
708
  }
103
709
 
104
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
+ }
105
717
  try {
106
- const raw = JSON.parse(readFileSync(path, "utf8"));
718
+ const raw = JSON.parse(text);
107
719
  if (raw && typeof raw === "object" && !Array.isArray(raw))
108
720
  return raw as BgrunConfigFile;
109
- } catch {
110
- // 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
+ );
111
728
  }
112
729
  return {};
113
730
  }
114
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
+
848
+ // ── Project-local jobs dir ──────────────────────────────────────────────────
849
+ //
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.
859
+
860
+ function isProjectRootLike(dir: string): boolean {
861
+ // Cheap heuristic: a directory holding .git or pi's config dir is a project.
862
+ return (
863
+ existsSync(join(dir, ".git")) || existsSync(join(dir, CONFIG_DIR_NAME))
864
+ );
865
+ }
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
+
920
+ export function resolveJobsDirPath(
921
+ raw: string | undefined,
922
+ ctx?: { cwd?: string; home?: string },
923
+ ): { dir: string; projectLocal: boolean } {
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 };
934
+ }
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 };
945
+ }
946
+
947
+ // Auto-ignore a project-local jobs dir in git so logs never pollute
948
+ // `git status`: appends the dir pattern to the enclosing repo's
949
+ // .git/info/exclude (local-only — the tracked .gitignore is never touched).
950
+ // Memoized only on SUCCESS — a transient failure (unwritable exclude file,
951
+ // .git appearing later) is retried on the next bgrun. Every step is
952
+ // best-effort and must never fail a bgrun.
953
+ const gitExcludedDirs = new Set<string>();
954
+
955
+ // Returns true when the dir is settled (pattern written, already present, or
956
+ // legitimately nothing to do — no repo above, dir is the repo root itself).
957
+ // False only on failure, so the caller retries next time.
958
+ export function ensureGitExcluded(jobsDir: string): boolean {
959
+ if (gitExcludedDirs.has(jobsDir)) return true;
960
+ if (tryEnsureGitExcluded(jobsDir)) {
961
+ gitExcludedDirs.add(jobsDir);
962
+ return true;
963
+ }
964
+ return false;
965
+ }
966
+
967
+ function tryEnsureGitExcluded(jobsDir: string): boolean {
968
+ try {
969
+ // Walk up from jobsDir to the enclosing work tree.
970
+ let cur = jobsDir;
971
+ for (;;) {
972
+ const dot = join(cur, ".git");
973
+ if (existsSync(dot)) return appendExcludePattern(cur, dot, jobsDir);
974
+ const parent = dirname(cur);
975
+ if (parent === cur) return true; // filesystem root — no repo above; nothing to do
976
+ cur = parent;
977
+ }
978
+ } catch {
979
+ // best-effort — ignore hygiene must never break job creation
980
+ return false;
981
+ }
982
+ }
983
+
984
+ function appendExcludePattern(
985
+ repoRoot: string,
986
+ dotGit: string,
987
+ jobsDir: string,
988
+ ): boolean {
989
+ if (jobsDir === repoRoot) return true; // can't exclude the whole repo; nothing to do
990
+ // `.git` is a directory in a normal checkout, or a file pointing at the
991
+ // real git dir in linked worktrees (git worktree add) and submodules.
992
+ let gitDir = dotGit;
993
+ if (statSync(dotGit).isFile()) {
994
+ const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
995
+ if (!m) return false; // unparseable .git file — retry later
996
+ gitDir = m[1].trim();
997
+ }
998
+ gitDir = resolveGitCommonDir(gitDir);
999
+ const rel = relative(repoRoot, jobsDir);
1000
+ // Defense-in-depth: the walk-up guarantees jobsDir sits under repoRoot, but
1001
+ // a future caller or symlinked path could break that — ../-prefixed
1002
+ // patterns are silently useless in gitignore semantics, so skip them.
1003
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel))
1004
+ return true;
1005
+ const pattern = rel.split(sep).join("/") + "/";
1006
+ const excludePath = join(gitDir, "info", "exclude");
1007
+ let existing = "";
1008
+ try {
1009
+ existing = readFileSync(excludePath, "utf8");
1010
+ } catch {
1011
+ // no exclude file yet — we'll create it
1012
+ }
1013
+ if (existing.split("\n").some((l) => l.trim() === pattern)) return true;
1014
+ mkdirSync(join(gitDir, "info"), { recursive: true });
1015
+ appendFileSync(
1016
+ excludePath,
1017
+ `\n# pi-bgrun job logs (auto-added)\n${pattern}\n`,
1018
+ );
1019
+ return true;
1020
+ }
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
+
115
1210
  // Resolved per call (cheap: at most two small file reads) so env/config
116
1211
  // changes are picked up without module reloads — and tests can isolate.
117
- function resolveConfig(ctx?: {
1212
+ // Exported for tests, like formatSince.
1213
+ export function resolveConfig(ctx?: {
118
1214
  cwd?: string;
119
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;
120
1220
  }): BgrunConfig {
121
- 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
+ );
122
1229
  let project: BgrunConfigFile = {};
123
1230
  try {
124
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);
125
1236
  project = readConfigFile(
126
- join(ctx.cwd ?? process.cwd(), CONFIG_DIR_NAME, "pi-bgrun.json"),
1237
+ join(projectRoot, CONFIG_DIR_NAME, "pi-bgrun.json"),
127
1238
  );
128
1239
  }
129
1240
  } catch {
@@ -154,11 +1265,59 @@ function resolveConfig(ctx?: {
154
1265
  : undefined;
155
1266
  const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
156
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);
1282
+ const { dir: jobsDir, projectLocal: jobsDirProjectLocal } =
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
+ }
157
1318
  return {
158
- jobsDir:
159
- process.env.PI_BGRUN_DIR ||
160
- dirFile ||
161
- join(homedir(), ".pi-bgrun", "jobs"),
1319
+ jobsDir,
1320
+ jobsDirProjectLocal,
162
1321
  adoptForeignJobs:
163
1322
  parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false,
164
1323
  showCompletedJobs:
@@ -166,10 +1325,12 @@ function resolveConfig(ctx?: {
166
1325
  completedFile ??
167
1326
  false,
168
1327
  cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
1328
+ maxLogBytes: maxBytesEnv ?? maxBytesFile ?? DEFAULT_MAX_LOG_BYTES,
169
1329
  globalAutoClean:
170
1330
  parseBoolEnv(process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN) ??
171
1331
  globalCleanFile ??
172
1332
  true,
1333
+ digest,
173
1334
  };
174
1335
  }
175
1336
 
@@ -198,15 +1359,28 @@ export function formatSince(started: number, now: number = Date.now()): string {
198
1359
  return `${ymd} ${time}`;
199
1360
  }
200
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
+
201
1373
  interface JobRecord {
202
1374
  id: string;
203
1375
  pid: number;
204
1376
  cmd: string;
205
1377
  name?: string; // optional human-readable label
1378
+ type?: string; // optional job type used for digest scorecard selection
206
1379
  started: number;
207
1380
  logPath: string;
208
1381
  exitedAt?: number;
209
1382
  exitCode?: number;
1383
+ donePersisted?: boolean; // done entry already appended to the transcript
210
1384
  child?: ReturnType<typeof spawn>; // absent for adopted (fs-discovered) jobs
211
1385
  ctx: ExtensionContext; // captured at tool-call time for isIdle() in the exit handler
212
1386
  adopted?: boolean; // true when discovered from the jobs dir (another session's job)
@@ -219,6 +1393,7 @@ interface BgrunJobEntryData {
219
1393
  pid: number;
220
1394
  cmd: string;
221
1395
  name?: string;
1396
+ type?: string;
222
1397
  started: number;
223
1398
  logPath: string;
224
1399
  state: "running" | "done";
@@ -232,21 +1407,30 @@ interface BgStatusDetails {
232
1407
  exitCode?: number;
233
1408
  cmd?: string;
234
1409
  name?: string;
1410
+ type?: string;
235
1411
  count?: number;
236
1412
  recovered?: boolean;
237
1413
  }
238
1414
 
239
- function isRunningPid(pid: number): boolean {
240
- try {
241
- process.kill(pid, 0);
242
- return true;
243
- } catch {
244
- return false;
245
- }
246
- }
247
-
248
1415
  export default function (pi: ExtensionAPI) {
249
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>();
250
1434
  // Poller for stale job records — anything running with no live ChildProcess
251
1435
  // handle (adopted foreign jobs + jobs reconstructed from transcript entries
252
1436
  // after a restart). No exit event exists for those, so their logs/pids are
@@ -256,7 +1440,7 @@ export default function (pi: ExtensionAPI) {
256
1440
  // ── Helpers ───────────────────────────────────────────────────────────────
257
1441
 
258
1442
  function makeSlug(command: string): string {
259
- const raw = command
1443
+ const raw = redactForSlug(command)
260
1444
  .toLowerCase()
261
1445
  .replace(/[/\\.-]+/g, " ")
262
1446
  .trim();
@@ -267,52 +1451,117 @@ export default function (pi: ExtensionAPI) {
267
1451
  return slug || "job";
268
1452
  }
269
1453
 
270
- // 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.
271
1457
  function sanitizeName(name: string | undefined): string | undefined {
272
- const trimmed = (name ?? "").trim();
1458
+ const trimmed = (name ?? "")
1459
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
1460
+ .replace(/\s+/g, " ")
1461
+ .trim();
273
1462
  if (!trimmed) return undefined;
274
1463
  return trimmed.slice(0, 80);
275
1464
  }
276
1465
 
277
- 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;
278
1478
  try {
279
- const content = readFileSync(logPath, "utf8");
280
- const lines = content.split("\n").filter((l) => l.trim().length > 0);
281
- if (lines.length === 0) return null;
282
- const real = lines.filter((l) => !l.startsWith(EXIT_MARKER));
283
- const last = real[real.length - 1] ?? lines[lines.length - 1];
284
- return last.length > maxLen ? last.slice(0, maxLen) + "…" : last;
1479
+ fd = openSync(logPath, "r");
285
1480
  } catch {
286
1481
  return null;
287
1482
  }
288
- }
289
-
290
- function parseExitFromLog(logPath: string): number | null {
291
1483
  try {
292
- const content = readFileSync(logPath, "utf8");
293
- const lines = content
294
- .split("\n")
295
- .filter((l) => l.startsWith(EXIT_MARKER));
296
- if (lines.length === 0) return null;
297
- const match = lines[lines.length - 1].match(/^__BGRUN_EXIT__=(\d+)/);
298
- 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;
299
1550
  } catch {
300
1551
  return null;
1552
+ } finally {
1553
+ closeSync(fd);
301
1554
  }
302
1555
  }
303
1556
 
304
- function pidFromId(id: string): number | null {
305
- // id format: <slug>-<ts>-<pid>
306
- const parts = id.split("-");
307
- const pid = parseInt(parts[parts.length - 1], 10);
308
- return Number.isFinite(pid) ? pid : null;
309
- }
310
-
311
1557
  // ── Live status widget ────────────────────────────────────────────────────
312
1558
 
313
- function updateWidget(ctx: ExtensionContext): void {
1559
+ function updateWidget(
1560
+ ctx: ExtensionContext,
1561
+ opts: { persistRevalidate?: boolean } = {},
1562
+ ): void {
314
1563
  if (!ctx.hasUI) return;
315
- revalidateStaleJobs();
1564
+ revalidateStaleJobs({ persist: opts.persistRevalidate ?? true });
316
1565
  const running: JobRecord[] = [];
317
1566
  for (const rec of jobs.values()) {
318
1567
  if (rec.exitCode === undefined) running.push(rec);
@@ -325,65 +1574,121 @@ export default function (pi: ExtensionAPI) {
325
1574
  for (const rec of running) {
326
1575
  const startedAt = formatSince(rec.started);
327
1576
  const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
328
- const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
1577
+ const label = rec.name ? `${rec.name} · ${cmd}` : cmd;
329
1578
  const tag = rec.adopted ? " (adopted)" : "";
330
- lines.push(
331
- ` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`,
332
- );
1579
+ // Full id (not truncated) so it can be copied straight into /bgtail <id>.
1580
+ lines.push(` ${rec.id} ${label} (since ${startedAt})${tag}`);
333
1581
  }
334
1582
  ctx.ui.setWidget("bgrun", lines);
335
1583
  }
336
1584
 
337
1585
  // ── Cleanup ───────────────────────────────────────────────────────────────
338
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
+
339
1645
  function cleanOldJobs(
340
1646
  days: number,
341
1647
  jobsDir: string,
342
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 } = {},
343
1653
  ): { removed: number; kept: number; skippedRunning: number } {
344
1654
  const result = { removed: 0, kept: 0, skippedRunning: 0 };
345
- let entries: string[];
346
- try {
347
- entries = readdirSync(jobsDir);
348
- } 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.
349
1662
  return result;
350
1663
  }
351
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
352
- for (const name of entries) {
353
- if (!name.endsWith(".log")) continue;
354
- const logPath = join(jobsDir, name);
355
- let st;
356
- try {
357
- st = statSync(logPath);
358
- } catch {
359
- continue;
360
- }
361
- // mtime check
362
- 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) {
363
1669
  result.kept++;
364
1670
  continue;
365
1671
  }
366
- const id = name.slice(0, -".log".length);
367
- // Exit marker is the authoritative finished signal check it BEFORE pid
368
- // liveness, so completed jobs are never mistaken for running (pid reuse
369
- // and shared pids made the old order keep stale jobs forever).
370
- 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;
371
1678
  if (!finished) {
372
- // No marker yet — running only if the pid is alive.
373
- const rec = jobs.get(id);
374
- if (rec && rec.exitCode === undefined) {
375
- result.skippedRunning++;
376
- continue;
377
- }
378
- const pid = pidFromId(id);
379
- if (pid !== null && pid > 0 && isRunningPid(pid)) {
1679
+ const rec = jobs.get(entry.id);
1680
+ if (entry.alive && rec?.exitCode === undefined) {
380
1681
  result.skippedRunning++;
381
1682
  continue;
382
1683
  }
383
1684
  }
1685
+ // finished, dead pid, or our record says done → safe to remove.
384
1686
  try {
385
- unlinkSync(logPath);
1687
+ unlinkSync(entry.logPath);
386
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);
387
1692
  } catch {
388
1693
  // ignore
389
1694
  }
@@ -406,15 +1711,26 @@ export default function (pi: ExtensionAPI) {
406
1711
  const result = { removed: 0, kept: 0, skippedRunning: 0 };
407
1712
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
408
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;
409
1718
  if (rec.exitCode === undefined) {
410
1719
  result.skippedRunning++;
411
1720
  continue;
412
1721
  }
413
- let st;
1722
+ let st: ReturnType<typeof statSync>;
414
1723
  try {
415
1724
  st = statSync(rec.logPath);
416
1725
  } catch {
417
- 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;
418
1734
  }
419
1735
  if (st.mtimeMs > cutoff) {
420
1736
  result.kept++;
@@ -423,49 +1739,98 @@ export default function (pi: ExtensionAPI) {
423
1739
  try {
424
1740
  unlinkSync(rec.logPath);
425
1741
  result.removed++;
1742
+ jobs.delete(rec.id);
1743
+ tailBookmarks.delete(rec.id);
426
1744
  } catch {
427
1745
  // ignore
428
1746
  }
429
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
+ }
430
1756
  if (result.removed > 0 && ctx?.hasUI) {
431
1757
  ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info");
432
1758
  }
433
1759
  return result;
434
1760
  }
435
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
+
436
1776
  // Auto-clean at session boundaries. Two parts:
437
1777
  // 1. Session-scoped sweep — this session's old logs only; cheap,
438
1778
  // unthrottled.
439
- // 2. Global orphan sweep (default on; disable via globalAutoClean: false /
440
- // PI_BGRUN_GLOBAL_AUTO_CLEAN=0) — the whole shared jobs dir, removing
441
- // FINISHED logs (exit marker, or dead pid) older than cleanupDays. This
442
- // is what keeps orphans from crashed / never-resumed sessions from
443
- // accumulating: a week-old finished log is garbage under the same
444
- // retention the owning session would apply itself, and running jobs are
445
- // always pid-protected. Throttled to one sweep per cleanupDays via a
446
- // .last-clean marker so restart-heavy workflows don't re-sweep on every
447
- // launch.
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.
448
1788
  function autoCleanJobs(ctx: ExtensionContext): void {
449
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);
450
1798
  cleanSessionJobs(cfg.cleanupDays, ctx);
451
1799
  if (!cfg.globalAutoClean) return;
452
- const markerPath = join(cfg.jobsDir, ".last-clean");
453
- try {
454
- const last = Number(readFileSync(markerPath, "utf8").trim());
1800
+ for (const dir of sharedJobsDirs(cfg.jobsDir, cfg.jobsDirProjectLocal)) {
455
1801
  if (
456
- Number.isFinite(last) &&
457
- Date.now() - last < cfg.cleanupDays * 24 * 60 * 60 * 1000
1802
+ cfg.jobsDirProjectLocal &&
1803
+ !trusted &&
1804
+ safeRealpath(dir) === safeRealpath(cfg.jobsDir)
458
1805
  )
459
- return;
460
- } catch {
461
- // no marker yet — run the sweep
462
- }
463
- cleanOldJobs(cfg.cleanupDays, cfg.jobsDir, ctx);
464
- try {
465
- mkdirSync(cfg.jobsDir, { recursive: true });
466
- writeFileSync(markerPath, String(Date.now()));
467
- } catch {
468
- // 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
+ }
469
1834
  }
470
1835
  }
471
1836
 
@@ -479,10 +1844,43 @@ export default function (pi: ExtensionAPI) {
479
1844
  // session's history; the log on disk still covers id lookup + cleanup).
480
1845
  // - Reconstructed jobs ARE this session's history: mark them done and
481
1846
  // append a done entry so future resumes reconstruct them as done too.
482
- 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;
483
1868
  for (const [id, rec] of jobs) {
484
- if (rec.child || rec.exitCode !== undefined) continue;
485
- 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
+ }
486
1884
  if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
487
1885
  // pid gone with no marker — killed/crashed before the wrapper could write it,
488
1886
  // or the log was already cleaned up
@@ -494,17 +1892,7 @@ export default function (pi: ExtensionAPI) {
494
1892
  } else {
495
1893
  rec.exitCode = exit;
496
1894
  rec.exitedAt = Date.now();
497
- pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
498
- id: rec.id,
499
- pid: rec.pid,
500
- cmd: rec.cmd,
501
- name: rec.name,
502
- started: rec.started,
503
- logPath: rec.logPath,
504
- state: "done",
505
- exitCode: exit >= 0 ? exit : undefined,
506
- exitedAt: rec.exitedAt,
507
- });
1895
+ if (persist) persistDoneEntry(rec, exit);
508
1896
  }
509
1897
  }
510
1898
  }
@@ -618,10 +2006,14 @@ export default function (pi: ExtensionAPI) {
618
2006
  pid: d.pid,
619
2007
  cmd: d.cmd,
620
2008
  name: d.name,
2009
+ type: d.type,
621
2010
  started: d.started,
622
2011
  logPath: d.logPath,
623
2012
  exitedAt: d.exitedAt,
624
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,
625
2017
  ctx,
626
2018
  });
627
2019
  }
@@ -641,37 +2033,26 @@ export default function (pi: ExtensionAPI) {
641
2033
  const cfg = resolveConfig(ctx);
642
2034
  const jobsDir = cfg.jobsDir;
643
2035
  if (cfg.adoptForeignJobs) {
644
- try {
645
- for (const name of readdirSync(jobsDir)) {
646
- if (!name.endsWith(".log")) continue;
647
- const id = name.slice(0, -".log".length);
648
- if (jobs.has(id)) continue;
649
- const logPath = join(jobsDir, name);
650
- const exit = parseExitFromLog(logPath);
651
- if (exit !== null) continue; // finished — nothing to show in the widget
652
- const pid = pidFromId(id);
653
- if (pid === null || pid <= 0 || !isRunningPid(pid)) continue; // dead pid, marker just not written yet
654
- let started = Date.now();
655
- try {
656
- started = statSync(logPath).birthtimeMs;
657
- } catch {
658
- // keep fallback
659
- }
660
- jobs.set(id, {
661
- id,
662
- pid,
663
- cmd: "(started by another session)",
664
- started,
665
- logPath,
666
- ctx,
667
- adopted: true,
668
- });
669
- }
670
- } catch {
671
- // jobs dir doesn't exist — nothing to adopt.
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
+ });
672
2049
  }
673
2050
  }
674
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
+
675
2056
  // Show the widget if anything is now running. revalidateStaleJobs()
676
2057
  // inside clears zombies — reconstructed jobs that finished while pi was
677
2058
  // down — before they ever render. Then start the stale poller for
@@ -704,14 +2085,16 @@ export default function (pi: ExtensionAPI) {
704
2085
  "Run a long shell command detached in the background. Returns 'started: <job-id>' immediately. " +
705
2086
  "You will be woken automatically when the job finishes. Use this instead of bash for any command " +
706
2087
  "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " +
707
- "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.",
708
2090
  promptSnippet:
709
2091
  "Run a long command detached in the background; get woken on completion",
710
2092
  promptGuidelines: [
711
2093
  "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
712
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.",
713
2096
  "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.",
714
- "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use ctx_execute_file on the log path only when the condensed tail is insufficient.",
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.",
715
2098
  ],
716
2099
  parameters: Type.Object({
717
2100
  command: Type.String({
@@ -725,159 +2108,364 @@ export default function (pi: ExtensionAPI) {
725
2108
  "Used in the job id, status output, the status widget, and wake messages.",
726
2109
  }),
727
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
+ ),
728
2119
  }),
729
2120
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
730
- const { command, name: rawName } = params;
2121
+ const { command, name: rawName, type: rawType } = params;
731
2122
  if (!command || !command.trim()) {
732
2123
  throw new Error("bgrun: command is required");
733
2124
  }
734
2125
  const name = sanitizeName(rawName);
2126
+ const type = sanitizeType(rawType);
735
2127
 
736
- const jobsDir = resolveConfig(ctx).jobsDir;
2128
+ const cfg = resolveConfig(ctx);
2129
+ // Project-local logs are auto-ignored in .git/info/exclude (best-effort)
2130
+ // so they never pollute `git status`. Absolute dirs are left untouched.
2131
+ if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir);
2132
+ const jobsDir = cfg.jobsDir;
737
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
+ }
738
2145
 
739
2146
  const slug = makeSlug(name ?? command);
740
2147
  const ts = Math.floor(Date.now() / 1000);
741
2148
  // The id must carry the CHILD's pid (liveness checks depend on it), but the
742
2149
  // log fd must exist before spawn. Create at a temp path, rename after spawn.
743
- const tmpPath = join(
744
- jobsDir,
745
- `.tmp-${slug}-${ts}-${Math.random().toString(36).slice(2, 8)}.log`,
746
- );
747
- 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;
748
2156
  try {
749
- logFd = openSync(tmpPath, "w");
2157
+ // 0600: job logs can contain secrets pulled from the environment.
2158
+ logFd = openSync(tmpPath, "wx", 0o600);
750
2159
  } catch (err) {
751
2160
  throw new Error(
752
2161
  `bgrun: cannot create log file: ${(err as Error).message}`,
753
2162
  );
754
2163
  }
755
- const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`;
756
-
757
- const child = spawn("sh", ["-c", wrapped], {
758
- stdio: ["ignore", logFd, logFd],
759
- detached: true,
760
- });
761
- child.unref();
762
-
763
- const childPid = child.pid ?? -1;
764
- const id = `${slug}-${ts}-${childPid}`;
765
- const logPath = join(jobsDir, `${id}.log`);
766
2164
  try {
767
- renameSync(tmpPath, logPath);
768
- } catch (err) {
769
- console.error(
770
- `[pi-bgrun] rename to final log path failed:`,
771
- (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
+ },
772
2196
  );
773
- }
774
-
775
- const record: JobRecord = {
776
- id,
777
- pid: childPid,
778
- cmd: command,
779
- name,
780
- started: Date.now(),
781
- logPath,
782
- child,
783
- ctx,
784
- };
785
- jobs.set(id, record);
786
-
787
- // Persist a bgrun-job entry (running state) — transcript card + restart recovery.
788
- pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
789
- id,
790
- pid: childPid,
791
- cmd: command,
792
- name,
793
- started: Date.now(),
794
- logPath,
795
- state: "running",
796
- });
797
-
798
- closeSync(logFd);
799
-
800
- updateWidget(ctx);
2197
+ child.unref();
801
2198
 
802
- // ── exit handler: record exit, persist done entry, wake, notify, widget ─
803
- child.on("exit", (code, signal) => {
804
- const rec = jobs.get(id);
805
- if (!rec) return;
806
- rec.exitedAt = Date.now();
807
- rec.exitCode = code ?? -1;
808
- 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
+ }
809
2211
 
810
- const exitCode = code ?? parseExitFromLog(logPath) ?? -1;
811
- const exitStr =
812
- exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
813
- const exitEmoji = exitCode === 0 ? "✅" : "❌";
814
- 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);
815
2224
 
816
- // Persist the done-state entry.
2225
+ // Persist a bgrun-job entry (running state) — transcript card + restart recovery.
817
2226
  pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
818
2227
  id,
819
- pid: rec.pid,
820
- cmd: rec.cmd,
821
- name: rec.name,
822
- started: rec.started,
2228
+ pid: childPid,
2229
+ cmd: command,
2230
+ name,
2231
+ type,
2232
+ started: Date.now(),
823
2233
  logPath,
824
- state: "done",
825
- exitCode: exitCode >= 0 ? exitCode : undefined,
826
- exitedAt: rec.exitedAt,
2234
+ state: "running",
827
2235
  });
828
2236
 
829
- // Wake the agent.
830
- const namePrefix = rec.name ? `"${rec.name}" ` : "";
831
- let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`;
832
- wake += `Command: ${command}\n`;
833
- if (lastLine) wake += `Last output: ${lastLine}\n`;
834
- 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.`;
835
- try {
836
- if (rec.ctx.isIdle()) {
837
- pi.sendUserMessage(wake);
838
- } else {
839
- 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
840
2253
  }
841
- } 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;
842
2357
  try {
843
- pi.sendUserMessage(wake, { deliverAs: "followUp" });
844
- } 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).
845
2404
  console.error(
846
- `[pi-bgrun] wake failed for job ${id}:`,
847
- (e2 as Error).message,
2405
+ `[pi-bgrun] digest failed for job ${id}:`,
2406
+ (e as Error).message,
848
2407
  );
849
2408
  }
850
- }
851
2409
 
852
- // Toast for the human.
853
- if (rec.ctx.hasUI) {
854
- const toastLabel = (rec.name ?? command).slice(0, 50);
855
- rec.ctx.ui.notify(
856
- `${exitEmoji} ${toastLabel} exit ${exitStr}`,
857
- exitCode === 0 ? "info" : "error",
858
- );
859
- }
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
+ }
860
2436
 
861
- // Update/clear the widget.
862
- updateWidget(rec.ctx);
863
- });
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
+ }
864
2445
 
865
- child.on("error", (err) => {
866
- console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message);
867
- jobs.delete(id);
868
- updateWidget(ctx);
869
- });
2446
+ // Update/clear the widget.
2447
+ updateWidget(rec.ctx);
2448
+ });
870
2449
 
871
- const startedLines = [`started: ${id}`];
872
- if (name) startedLines.push(` name: ${name}`);
873
- startedLines.push(
874
- ` log: ${logPath}`,
875
- ` You'll be woken automatically when it finishes.`,
876
- );
877
- return {
878
- content: [{ type: "text", text: startedLines.join("\n") }],
879
- details: { id, name, logPath, pid: childPid },
880
- };
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
+ }
881
2469
  },
882
2470
  });
883
2471
 
@@ -890,6 +2478,162 @@ export default function (pi: ExtensionAPI) {
890
2478
  const LINE_CAP = 2000; // chars per line after stripping
891
2479
  const TOTAL_CAP = 8000; // chars for the whole bgtail result
892
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
+
893
2637
  function condenseLogLines(
894
2638
  lines: string[],
895
2639
  opts: { raw?: boolean } = {},
@@ -948,63 +2692,237 @@ export default function (pi: ExtensionAPI) {
948
2692
  return { text: out.join("\n"), truncated: notes };
949
2693
  }
950
2694
 
951
- // ── bgtail: read last N lines of a job's log, condensed for context ────────
2695
+ // ── bgtail: read the newest lines of a job's log, condensed for context ────
2696
+ //
2697
+ // Delta tailing: each read bookmarks the total raw line count at read time
2698
+ // (the high-water mark of what the caller has had the opportunity to see).
2699
+ // The FIRST read for a job returns the full last-N tail; repeat reads return
2700
+ // only lines appended since, so polling a running job never re-pays context
2701
+ // for lines already seen. Deliberately-skipped prefix lines are never
2702
+ // replayed as "new". raw: true keeps the verbatim last-N window (no delta
2703
+ // header) but still advances the bookmark. A shrunken log (rotated/replaced)
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
+ }
2715
+
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
+ }
952
2742
 
953
2743
  // Shared by the bgtail tool (agent-facing) and the /bgtail slash command
954
2744
  // (human-facing).
955
2745
  async function bgtailCore(
956
- params: { id: string; lines?: number; raw?: boolean },
2746
+ params: { id: string; lines?: number; raw?: boolean; bytes?: number },
957
2747
  ctx?: ExtensionContext,
958
2748
  ): Promise<{
959
2749
  content: { type: "text"; text: string }[];
960
2750
  details: Record<string, unknown>;
961
2751
  isError?: boolean;
962
2752
  }> {
963
- const { id, lines = 40, raw = false } = params;
2753
+ const { id, lines: linesParam = 40, raw = false } = params;
2754
+ // Clamp defensively — direct callers (e.g. the slash command) bypass the
2755
+ // tool schema, and lines < 1 would corrupt slicing (slice(-0) = whole log).
2756
+ const lines = Math.max(1, Math.floor(linesParam));
964
2757
  if (!id) throw new Error("bgtail: id is required");
965
- const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
966
- try {
967
- const content = readFileSync(logPath, "utf8");
968
- const all = content
969
- .split("\n")
970
- .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
971
- const tail = all.slice(-lines);
972
- const { text, truncated } = condenseLogLines(tail, { raw });
973
- const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
2758
+ const readWindow = clampReadWindow(params.bytes);
2759
+ const resolved = resolveLogForJob(id, "bgtail", ctx, readWindow);
2760
+ if ("errorText" in resolved) {
974
2761
  return {
975
- content: [{ type: "text", text: text + notes || "(empty log)" }],
2762
+ content: [{ type: "text", text: resolved.errorText }],
976
2763
  details: {
977
2764
  id,
978
- linesShown: tail.length,
979
- logPath,
980
- notFound: false,
981
- condensed: !raw,
982
- ...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
2765
+ logPath: resolved.logPath,
2766
+ notFound: resolved.notFound,
983
2767
  },
984
- };
985
- } catch {
986
- return {
987
- content: [
988
- { type: "text", text: `No log found for job ${id} at ${logPath}` },
989
- ],
990
- details: { id, linesShown: 0, logPath, notFound: true },
991
2768
  isError: true,
992
2769
  };
993
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
+ };
994
2909
  }
995
2910
 
996
2911
  pi.registerTool({
997
2912
  name: "bgtail",
998
2913
  label: "Tail Background Log",
999
2914
  description:
1000
- "Print the last N lines of a background job's log (default 40), condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. Pass raw: true for unprocessed output; use ctx_execute_file on the log path for whole-log failure analysis.",
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.",
1001
2916
  promptSnippet: "Read the last N lines of a bgrun job's log",
1002
2917
  parameters: Type.Object({
1003
2918
  id: Type.String({
1004
2919
  description: "Job id (from bgrun's 'started: <id>' response)",
1005
2920
  }),
1006
2921
  lines: Type.Optional(
1007
- Type.Number({ description: "Number of lines to show (default 40)" }),
2922
+ Type.Number({
2923
+ description: "Number of lines to show (default 40)",
2924
+ minimum: 1,
2925
+ }),
1008
2926
  ),
1009
2927
  raw: Type.Optional(
1010
2928
  Type.Boolean({
@@ -1012,12 +2930,249 @@ export default function (pi: ExtensionAPI) {
1012
2930
  "Skip condensing (ANSI strip, collapse, caps) and return raw text",
1013
2931
  }),
1014
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
+ ),
1015
2940
  }),
1016
2941
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1017
2942
  return bgtailCore(params, ctx);
1018
2943
  },
1019
2944
  });
1020
2945
 
2946
+ // ── bggrep: pattern search over a job's log, capped for context ───────────
2947
+ //
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),
2952
+ // optionally with context lines, capped at MAX_GREP_MATCHES, and run
2953
+ // through the same condenser as bgtail so a search can never flood context.
2954
+
2955
+ const MAX_GREP_MATCHES = 50;
2956
+
2957
+ async function bggrepCore(
2958
+ params: { id: string; pattern?: string; context?: number; bytes?: number },
2959
+ ctx?: ExtensionContext,
2960
+
2961
+ ): Promise<{
2962
+ content: { type: "text"; text: string }[];
2963
+ details: Record<string, unknown>;
2964
+ isError?: boolean;
2965
+ }> {
2966
+ const { id, pattern, context: contextParam = 0 } = params;
2967
+ // Clamp defensively — negative context would exclude the match lines
2968
+ // themselves from the context windows (lo > hi no-ops the inner loop).
2969
+ const context = Math.max(0, Math.floor(contextParam));
2970
+ if (!id) throw new Error("bggrep: id is required");
2971
+ // resolveLogForJob() below validates the id; no need to double-check.
2972
+ const source = pattern ?? DEFAULT_GREP_PATTERN;
2973
+ try {
2974
+ // Validate up front so a bad pattern fails immediately, without a worker.
2975
+ void new RegExp(source);
2976
+ } catch (err) {
2977
+ throw new Error(
2978
+ `bggrep: invalid pattern ${JSON.stringify(source)}: ${(err as Error).message}`,
2979
+ );
2980
+ }
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") {
3045
+ return {
3046
+ content: [
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
+ },
3054
+ ],
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
+ },
3066
+ isError: true,
3067
+ };
3068
+ }
3069
+ const matchIdx = outcome.matchIdx;
3070
+ const header =
3071
+ `${matchIdx.length} match${matchIdx.length === 1 ? "" : "es"} for /${source}/ ` +
3072
+ `in ${rawLines.length} line${rawLines.length === 1 ? "" : "s"}`;
3073
+ if (matchIdx.length === 0) {
3074
+ return {
3075
+ content: [
3076
+ { type: "text", text: `${header} — none${truncNote}${windowNote}${lineNote}` },
3077
+ ],
3078
+ details: {
3079
+ id,
3080
+ matches: 0,
3081
+ linesSearched: rawLines.length,
3082
+ logPath,
3083
+ notFound: false,
3084
+ windowBytes: readWindow,
3085
+ ...truncDetails,
3086
+ },
3087
+ };
3088
+ }
3089
+ const capped = matchIdx.length > MAX_GREP_MATCHES;
3090
+ const shownIdx = capped ? matchIdx.slice(0, MAX_GREP_MATCHES) : matchIdx;
3091
+ // Context windows, merged where they overlap or touch (grep -C style).
3092
+ const include = new Set<number>();
3093
+ for (const i of shownIdx) {
3094
+ const lo = Math.max(0, i - context);
3095
+ const hi = Math.min(rawLines.length - 1, i + context);
3096
+ for (let j = lo; j <= hi; j++) include.add(j);
3097
+ }
3098
+ const sorted = [...include].sort((a, b) => a - b);
3099
+ const out: string[] = [];
3100
+ let prev = -2;
3101
+ for (const i of sorted) {
3102
+ if (prev >= 0 && i > prev + 1) {
3103
+ const gap = i - prev - 1;
3104
+ out.push(`…[${gap} line${gap === 1 ? "" : "s"} skipped]…`);
3105
+ }
3106
+ out.push(`L${i + 1}: ${rawLines[i]}`);
3107
+ prev = i;
3108
+ }
3109
+ const { text, truncated } = condenseLogLines(out);
3110
+ const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
3111
+ const capNote = capped
3112
+ ? ` — showing first ${MAX_GREP_MATCHES}; ${matchIdx.length - MAX_GREP_MATCHES} more not shown`
3113
+ : "";
3114
+ return {
3115
+ content: [
3116
+ {
3117
+ type: "text",
3118
+ text: `${header}${capNote}\n${text}${notes}${truncNote}${windowNote}${lineNote}`,
3119
+ },
3120
+ ],
3121
+ details: {
3122
+ id,
3123
+ matches: matchIdx.length,
3124
+ linesSearched: rawLines.length,
3125
+ logPath,
3126
+ notFound: false,
3127
+ pattern: source,
3128
+ capped,
3129
+ windowBytes: readWindow,
3130
+ ...truncDetails,
3131
+ },
3132
+ };
3133
+ }
3134
+
3135
+ pi.registerTool({
3136
+ name: "bggrep",
3137
+ label: "Grep Background Log",
3138
+ description:
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).",
3140
+ promptSnippet: "Search a bgrun job's log for a pattern",
3141
+ promptGuidelines: [
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.",
3143
+ "Prefer bggrep over bash grep or reading a bgrun log — matches are line-numbered, capped, and condensed.",
3144
+ "Pass an explicit pattern when you know the tool's output format; the default only catches common failure signatures.",
3145
+ ],
3146
+ parameters: Type.Object({
3147
+ id: Type.String({
3148
+ description: "Job id (from bgrun's 'started: <id>' response)",
3149
+ }),
3150
+ pattern: Type.Optional(
3151
+ Type.String({
3152
+ description:
3153
+ "Regex to search for. Default: generic failure signatures — override when you know the format.",
3154
+ }),
3155
+ ),
3156
+ context: Type.Optional(
3157
+ Type.Number({
3158
+ description:
3159
+ "Context lines around each match (default 0, grep -C style)",
3160
+ minimum: 0,
3161
+ }),
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
+ ),
3170
+ }),
3171
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3172
+ return bggrepCore(params, ctx);
3173
+ },
3174
+ });
3175
+
1021
3176
  // ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
1022
3177
 
1023
3178
  // Shared by the bgstatus tool (agent-facing) and the /bgstatus slash command
@@ -1034,58 +3189,81 @@ export default function (pi: ExtensionAPI) {
1034
3189
  const cfg = resolveConfig(ctx);
1035
3190
  const jobsDir = cfg.jobsDir;
1036
3191
  if (id) {
3192
+ validateJobId(id, "bgstatus");
1037
3193
  const rec = jobs.get(id);
1038
3194
  if (rec) {
1039
- const state = rec.exitCode === undefined ? "running" : "done";
1040
- const exit = rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
1041
- const lines = [`${id}: ${state}${exit}`];
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}`];
1042
3212
  if (rec.name) lines.push(` name: ${rec.name}`);
3213
+ if (rec.type) lines.push(` type: ${rec.type}`);
1043
3214
  lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
1044
3215
  return {
1045
3216
  content: [{ type: "text", text: lines.join("\n") }],
1046
3217
  details: {
1047
3218
  id,
1048
3219
  state,
1049
- exitCode: rec.exitCode ?? undefined,
3220
+ exitCode: exit ?? undefined,
1050
3221
  cmd: rec.cmd,
1051
3222
  name: rec.name,
3223
+ type: rec.type,
1052
3224
  recovered: false,
1053
3225
  },
1054
3226
  };
1055
3227
  }
1056
3228
  const logPath = join(jobsDir, `${id}.log`);
1057
- try {
1058
- const exit = parseExitFromLog(logPath);
1059
- const state = exit === null ? "running" : "done";
1060
- return {
1061
- content: [
1062
- {
1063
- type: "text",
1064
- text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
1065
- },
1066
- ],
1067
- details: {
1068
- id,
1069
- state,
1070
- exitCode: exit ?? undefined,
1071
- recovered: true,
1072
- },
1073
- };
1074
- } catch {
3229
+ if (!existsSync(logPath)) {
1075
3230
  return {
1076
3231
  content: [{ type: "text", text: `No job found with id ${id}` }],
1077
3232
  details: { id, state: "unknown" },
1078
3233
  isError: true,
1079
3234
  };
1080
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
+ };
1081
3256
  }
1082
3257
  // List: this session's jobs (running by default; finished only when
1083
- // includeDone / showCompletedJobs is set), plus when opted in — other
1084
- // sessions' jobs from the shared jobs dir. Hidden disk logs get a
1085
- // one-line count instead of spamming the listing.
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.
1086
3262
  const showDone = params.includeDone ?? cfg.showCompletedJobs;
1087
- revalidateStaleJobs();
1088
- 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 });
1089
3267
  const lines: string[] = [];
1090
3268
  const seen = new Set<string>();
1091
3269
  for (const [jid, rec] of jobs) {
@@ -1099,30 +3277,33 @@ export default function (pi: ExtensionAPI) {
1099
3277
  }
1100
3278
  }
1101
3279
  let hiddenOnDisk = 0;
1102
- try {
1103
- for (const name of readdirSync(jobsDir)) {
1104
- if (!name.endsWith(".log")) continue;
1105
- const jid = name.slice(0, -".log".length);
1106
- if (seen.has(jid)) continue;
1107
- const logPath = join(jobsDir, name);
1108
- const exit = parseExitFromLog(logPath);
1109
- if (exit !== null) {
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) {
1110
3291
  // finished log on disk (other or older session)
1111
3292
  if (showDone) {
1112
- lines.push(` ${jid}: done exit=${exit} (from log)`);
3293
+ lines.push(` ${entry.id}: done exit=${entry.exit} (from log)`);
1113
3294
  } else {
1114
3295
  hiddenOnDisk++;
1115
3296
  }
1116
- } else if (cfg.adoptForeignJobs) {
1117
- // running foreign job — only surfaced when adoption is enabled
1118
- lines.push(` ${jid}: running (from log)`);
3297
+ } else if (cfg.adoptForeignJobs && entry.alive) {
3298
+ lines.push(` ${entry.id}: running (from log)`);
1119
3299
  } else {
1120
3300
  hiddenOnDisk++;
1121
3301
  }
1122
3302
  }
1123
- } catch {
1124
- // jobs dir doesn't exist — nothing to scan.
1125
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;
1126
3307
  if (hiddenOnDisk > 0) {
1127
3308
  lines.push(
1128
3309
  ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean all to prune)`,
@@ -1136,7 +3317,7 @@ export default function (pi: ExtensionAPI) {
1136
3317
  }
1137
3318
  return {
1138
3319
  content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
1139
- details: { count: lines.length },
3320
+ details: { count: jobCount },
1140
3321
  };
1141
3322
  }
1142
3323
 
@@ -1146,7 +3327,8 @@ export default function (pi: ExtensionAPI) {
1146
3327
  description:
1147
3328
  "Show status of background jobs. With an id: one job's state + exit code. Without: list this session's " +
1148
3329
  "running jobs (finished jobs are hidden by default — pass includeDone or set showCompletedJobs to list " +
1149
- "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.",
1150
3332
  promptSnippet: "Check status of bgrun jobs",
1151
3333
  parameters: Type.Object({
1152
3334
  id: Type.Optional(
@@ -1166,8 +3348,6 @@ export default function (pi: ExtensionAPI) {
1166
3348
 
1167
3349
  // ── bgclean: remove old job logs ───────────────────────────────────────────
1168
3350
 
1169
- // ── bgclean: remove old job logs ──────────────────────────────────────
1170
-
1171
3351
  // Shared by the bgclean tool (agent-facing) and the /bgclean slash command
1172
3352
  // (human-facing).
1173
3353
  async function bgcleanCore(
@@ -1179,21 +3359,38 @@ export default function (pi: ExtensionAPI) {
1179
3359
  }> {
1180
3360
  const cfg = resolveConfig(ctx);
1181
3361
  const { days = cfg.cleanupDays, all = false } = params;
1182
- if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
1183
- throw new Error(
1184
- `bgclean: days must be a non-negative number, got ${days}`,
1185
- );
3362
+ if (typeof days !== "number" || days <= 0 || !Number.isFinite(days)) {
3363
+ throw new Error(`bgclean: days must be a positive number, got ${days}`);
1186
3364
  }
1187
3365
  let result;
1188
3366
  if (all) {
1189
- result = cleanOldJobs(days, cfg.jobsDir, ctx);
1190
- // A manual global clean refreshes the throttle marker so the next
1191
- // auto-sweep doesn't immediately redo this work.
1192
- try {
1193
- mkdirSync(cfg.jobsDir, { recursive: true });
1194
- writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
1195
- } catch {
1196
- // best-effort
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
+ }
1197
3394
  }
1198
3395
  } else {
1199
3396
  // Session-scoped by default: bg* commands apply to the current
@@ -1213,8 +3410,11 @@ export default function (pi: ExtensionAPI) {
1213
3410
  label: "Clean Old Background Jobs",
1214
3411
  description:
1215
3412
  "Remove old background job logs from disk. Default scope: THIS session's jobs only (other sessions' logs are " +
1216
- "untouched). Pass all: true to sweep the whole shared jobs dir. Retention: cleanupDays config (default 7 days). " +
1217
- "Never removes a running job's log. Prints a summary of what was removed vs kept.",
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.",
1218
3418
  promptSnippet:
1219
3419
  "Remove old bgrun job logs (this session by default; all: true for every session's)",
1220
3420
  parameters: Type.Object({
@@ -1226,7 +3426,7 @@ export default function (pi: ExtensionAPI) {
1226
3426
  all: Type.Optional(
1227
3427
  Type.Boolean({
1228
3428
  description:
1229
- "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)",
1230
3430
  }),
1231
3431
  ),
1232
3432
  }),