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