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