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