@stablekernel/pi-background-run 0.1.0 → 0.2.1
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 +38 -3
- package/extension/index.test.ts +573 -59
- package/extension/index.ts +452 -101
- package/package.json +3 -2
- package/skill/run-bg/SKILL.md +15 -7
package/extension/index.ts
CHANGED
|
@@ -20,9 +20,16 @@
|
|
|
20
20
|
* the Map from bgrun-job entries.
|
|
21
21
|
* 3. Filesystem scan (cross-session, cross-restart, cross-worktree) — the jobs
|
|
22
22
|
* dir is the permanent truth: filename→pid, log→exit code, kill -0→liveness.
|
|
23
|
+
* Surfaced via bgstatus by id (always) or includeDone (explicit); other
|
|
24
|
+
* sessions' RUNNING jobs are adopted into the live widget only when
|
|
25
|
+
* adoptForeignJobs is enabled.
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
|
-
import
|
|
28
|
+
import {
|
|
29
|
+
CONFIG_DIR_NAME,
|
|
30
|
+
type ExtensionAPI,
|
|
31
|
+
type ExtensionContext,
|
|
32
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
33
|
import { Type } from "typebox";
|
|
27
34
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
28
35
|
import { spawn } from "node:child_process";
|
|
@@ -35,6 +42,7 @@ import {
|
|
|
35
42
|
renameSync,
|
|
36
43
|
unlinkSync,
|
|
37
44
|
statSync,
|
|
45
|
+
writeFileSync,
|
|
38
46
|
} from "node:fs";
|
|
39
47
|
import { join } from "node:path";
|
|
40
48
|
import { homedir } from "node:os";
|
|
@@ -44,8 +52,110 @@ import { homedir } from "node:os";
|
|
|
44
52
|
// the command fails. Never use `set -e` in the wrapper.
|
|
45
53
|
const EXIT_MARKER = "__BGRUN_EXIT__=";
|
|
46
54
|
|
|
47
|
-
const AUTO_CLEANUP_DAYS = 14;
|
|
48
55
|
const DEFAULT_CLEANUP_DAYS = 7;
|
|
56
|
+
const ADOPTED_POLL_MS = 30_000; // re-check interval for adopted (foreign) jobs
|
|
57
|
+
|
|
58
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// Layered: defaults ← user config file ← project config file (trusted projects
|
|
61
|
+
// only) ← environment variables. Pi passes no first-class per-extension config
|
|
62
|
+
// through the ExtensionAPI, so this follows the documented pattern: the
|
|
63
|
+
// extension reads its own JSON config from ~/.pi/agent/pi-bgrun.json (user) and
|
|
64
|
+
// <cwd>/<CONFIG_DIR_NAME>/pi-bgrun.json (project, honored only when the project
|
|
65
|
+
// is trusted), with PI_BGRUN_* env vars as overrides.
|
|
66
|
+
|
|
67
|
+
interface BgrunConfig {
|
|
68
|
+
jobsDir: string;
|
|
69
|
+
// Adopt other sessions' running jobs (found in the shared jobs dir) into
|
|
70
|
+
// this session's widget and job list. Default false — most sessions don't
|
|
71
|
+
// want unrelated jobs from other projects cluttering the widget.
|
|
72
|
+
adoptForeignJobs: boolean;
|
|
73
|
+
// Include finished jobs in bgstatus listings by default. Default false —
|
|
74
|
+
// completed jobs are noise; ask for them explicitly (bgstatus includeDone).
|
|
75
|
+
showCompletedJobs: boolean;
|
|
76
|
+
// Log retention for auto-clean sweeps and the bgclean default. Also the
|
|
77
|
+
// throttle interval for auto-clean (at most one sweep per cleanupDays).
|
|
78
|
+
cleanupDays: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface BgrunConfigFile {
|
|
82
|
+
jobsDir?: unknown;
|
|
83
|
+
adoptForeignJobs?: unknown;
|
|
84
|
+
showCompletedJobs?: unknown;
|
|
85
|
+
cleanupDays?: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseBoolEnv(v: string | undefined): boolean | undefined {
|
|
89
|
+
if (v === undefined) return undefined;
|
|
90
|
+
const t = v.trim().toLowerCase();
|
|
91
|
+
if (["1", "true", "yes", "on"].includes(t)) return true;
|
|
92
|
+
if (["0", "false", "no", "off"].includes(t)) return false;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readConfigFile(path: string): BgrunConfigFile {
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
99
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
100
|
+
return raw as BgrunConfigFile;
|
|
101
|
+
} catch {
|
|
102
|
+
// missing or malformed — treat as empty
|
|
103
|
+
}
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Resolved per call (cheap: at most two small file reads) so env/config
|
|
108
|
+
// changes are picked up without module reloads — and tests can isolate.
|
|
109
|
+
function resolveConfig(ctx?: {
|
|
110
|
+
cwd?: string;
|
|
111
|
+
isProjectTrusted?: () => boolean;
|
|
112
|
+
}): BgrunConfig {
|
|
113
|
+
const user = readConfigFile(join(homedir(), ".pi", "agent", "pi-bgrun.json"));
|
|
114
|
+
let project: BgrunConfigFile = {};
|
|
115
|
+
try {
|
|
116
|
+
if (ctx?.isProjectTrusted?.()) {
|
|
117
|
+
project = readConfigFile(
|
|
118
|
+
join(ctx.cwd ?? process.cwd(), CONFIG_DIR_NAME, "pi-bgrun.json"),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// unreadable project config — ignore
|
|
123
|
+
}
|
|
124
|
+
const merged: BgrunConfigFile = { ...user, ...project };
|
|
125
|
+
const foreignFile =
|
|
126
|
+
typeof merged.adoptForeignJobs === "boolean"
|
|
127
|
+
? merged.adoptForeignJobs
|
|
128
|
+
: undefined;
|
|
129
|
+
const completedFile =
|
|
130
|
+
typeof merged.showCompletedJobs === "boolean"
|
|
131
|
+
? merged.showCompletedJobs
|
|
132
|
+
: undefined;
|
|
133
|
+
const dirFile =
|
|
134
|
+
typeof merged.jobsDir === "string" && merged.jobsDir
|
|
135
|
+
? merged.jobsDir
|
|
136
|
+
: undefined;
|
|
137
|
+
const daysFile =
|
|
138
|
+
typeof merged.cleanupDays === "number" &&
|
|
139
|
+
Number.isFinite(merged.cleanupDays) &&
|
|
140
|
+
merged.cleanupDays > 0
|
|
141
|
+
? merged.cleanupDays
|
|
142
|
+
: undefined;
|
|
143
|
+
const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
|
|
144
|
+
const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined;
|
|
145
|
+
return {
|
|
146
|
+
jobsDir:
|
|
147
|
+
process.env.PI_BGRUN_DIR ||
|
|
148
|
+
dirFile ||
|
|
149
|
+
join(homedir(), ".pi-bgrun", "jobs"),
|
|
150
|
+
adoptForeignJobs:
|
|
151
|
+
parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false,
|
|
152
|
+
showCompletedJobs:
|
|
153
|
+
parseBoolEnv(process.env.PI_BGRUN_SHOW_COMPLETED) ??
|
|
154
|
+
completedFile ??
|
|
155
|
+
false,
|
|
156
|
+
cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
49
159
|
|
|
50
160
|
interface JobRecord {
|
|
51
161
|
id: string;
|
|
@@ -96,13 +206,21 @@ function isRunningPid(pid: number): boolean {
|
|
|
96
206
|
|
|
97
207
|
export default function (pi: ExtensionAPI) {
|
|
98
208
|
const jobs = new Map<string, JobRecord>();
|
|
99
|
-
|
|
209
|
+
// Poller for adopted (foreign) jobs — they have no ChildProcess handle, so
|
|
210
|
+
// no exit event; their logs/pids are re-checked on an interval instead.
|
|
211
|
+
let adoptedPoller: ReturnType<typeof setInterval> | undefined;
|
|
100
212
|
|
|
101
213
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
102
214
|
|
|
103
215
|
function makeSlug(command: string): string {
|
|
104
|
-
const raw = command
|
|
105
|
-
|
|
216
|
+
const raw = command
|
|
217
|
+
.toLowerCase()
|
|
218
|
+
.replace(/[/\\.-]+/g, " ")
|
|
219
|
+
.trim();
|
|
220
|
+
const slug = raw
|
|
221
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
222
|
+
.replace(/^-+|-+$/g, "")
|
|
223
|
+
.slice(0, 60);
|
|
106
224
|
return slug || "job";
|
|
107
225
|
}
|
|
108
226
|
|
|
@@ -129,7 +247,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
129
247
|
function parseExitFromLog(logPath: string): number | null {
|
|
130
248
|
try {
|
|
131
249
|
const content = readFileSync(logPath, "utf8");
|
|
132
|
-
const lines = content
|
|
250
|
+
const lines = content
|
|
251
|
+
.split("\n")
|
|
252
|
+
.filter((l) => l.startsWith(EXIT_MARKER));
|
|
133
253
|
if (lines.length === 0) return null;
|
|
134
254
|
const match = lines[lines.length - 1].match(/^__BGRUN_EXIT__=(\d+)/);
|
|
135
255
|
return match ? parseInt(match[1], 10) : null;
|
|
@@ -149,6 +269,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
269
|
|
|
150
270
|
function updateWidget(ctx: ExtensionContext): void {
|
|
151
271
|
if (!ctx.hasUI) return;
|
|
272
|
+
revalidateAdoptedJobs();
|
|
152
273
|
const running: JobRecord[] = [];
|
|
153
274
|
for (const rec of jobs.values()) {
|
|
154
275
|
if (rec.exitCode === undefined) running.push(rec);
|
|
@@ -159,23 +280,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
280
|
}
|
|
160
281
|
const lines = [`📊 bgrun: ${running.length} running`];
|
|
161
282
|
for (const rec of running) {
|
|
162
|
-
const startedAt = new Date(rec.started).toLocaleTimeString([], {
|
|
283
|
+
const startedAt = new Date(rec.started).toLocaleTimeString([], {
|
|
284
|
+
hour12: false,
|
|
285
|
+
});
|
|
163
286
|
const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
|
|
164
287
|
const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40);
|
|
165
288
|
const tag = rec.adopted ? " (adopted)" : "";
|
|
166
|
-
lines.push(
|
|
289
|
+
lines.push(
|
|
290
|
+
` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`,
|
|
291
|
+
);
|
|
167
292
|
}
|
|
168
293
|
ctx.ui.setWidget("bgrun", lines);
|
|
169
294
|
}
|
|
170
295
|
|
|
171
|
-
function clearWidget(ctx: ExtensionContext): void {
|
|
172
|
-
if (!ctx.hasUI) return;
|
|
173
|
-
ctx.ui.setWidget("bgrun", undefined);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
296
|
// ── Cleanup ───────────────────────────────────────────────────────────────
|
|
177
297
|
|
|
178
|
-
function cleanOldJobs(
|
|
298
|
+
function cleanOldJobs(
|
|
299
|
+
days: number,
|
|
300
|
+
jobsDir: string,
|
|
301
|
+
ctx?: ExtensionContext,
|
|
302
|
+
): { removed: number; kept: number; skippedRunning: number } {
|
|
179
303
|
const result = { removed: 0, kept: 0, skippedRunning: 0 };
|
|
180
304
|
let entries: string[];
|
|
181
305
|
try {
|
|
@@ -229,26 +353,132 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
353
|
return result;
|
|
230
354
|
}
|
|
231
355
|
|
|
232
|
-
//
|
|
356
|
+
// Throttled auto-clean: runs at session_start/session_shutdown at most once
|
|
357
|
+
// per cleanupDays (tracked via a .last-clean marker in the jobs dir). Manual
|
|
358
|
+
// bgclean always runs and refreshes the marker. This is the "7-day timer" —
|
|
359
|
+
// any session boundary after the interval fires the sweep, so long-lived
|
|
360
|
+
// sessions and restart-heavy workflows both stay covered without cleaning on
|
|
361
|
+
// every bgrun call.
|
|
362
|
+
function autoCleanJobs(ctx: ExtensionContext): void {
|
|
363
|
+
const cfg = resolveConfig(ctx);
|
|
364
|
+
const markerPath = join(cfg.jobsDir, ".last-clean");
|
|
365
|
+
try {
|
|
366
|
+
const last = Number(readFileSync(markerPath, "utf8").trim());
|
|
367
|
+
if (
|
|
368
|
+
Number.isFinite(last) &&
|
|
369
|
+
Date.now() - last < cfg.cleanupDays * 24 * 60 * 60 * 1000
|
|
370
|
+
)
|
|
371
|
+
return;
|
|
372
|
+
} catch {
|
|
373
|
+
// no marker yet — run the sweep
|
|
374
|
+
}
|
|
375
|
+
cleanOldJobs(cfg.cleanupDays, cfg.jobsDir, ctx);
|
|
376
|
+
try {
|
|
377
|
+
mkdirSync(cfg.jobsDir, { recursive: true });
|
|
378
|
+
writeFileSync(markerPath, String(Date.now()));
|
|
379
|
+
} catch {
|
|
380
|
+
// best-effort
|
|
381
|
+
}
|
|
382
|
+
}
|
|
233
383
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
384
|
+
// Re-check adopted (foreign) jobs: they have no exit event, so the exit
|
|
385
|
+
// marker in the log (or a dead pid) is the only completion signal. Without
|
|
386
|
+
// this, adopted jobs render as "running" forever even after they finish.
|
|
387
|
+
// Finished adopted jobs are dropped from the in-memory registry entirely —
|
|
388
|
+
// they aren't this session's history; the log stays on disk (id lookup,
|
|
389
|
+
// disk note, and cleanup all still cover it). Called from the adopted poller
|
|
390
|
+
// and before rendering the widget / listing jobs.
|
|
391
|
+
function revalidateAdoptedJobs(): void {
|
|
392
|
+
for (const [id, rec] of jobs) {
|
|
393
|
+
if (!rec.adopted || rec.exitCode !== undefined) continue;
|
|
394
|
+
let exit = parseExitFromLog(rec.logPath);
|
|
395
|
+
if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
|
|
396
|
+
// pid gone with no marker — killed/crashed before the wrapper could write it
|
|
397
|
+
exit = -1;
|
|
248
398
|
}
|
|
399
|
+
if (exit !== null) jobs.delete(id);
|
|
249
400
|
}
|
|
250
|
-
|
|
251
|
-
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function hasAdoptedRunning(): boolean {
|
|
404
|
+
for (const rec of jobs.values()) {
|
|
405
|
+
if (rec.adopted && rec.exitCode === undefined) return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function ensureAdoptedPoller(ctx: ExtensionContext): void {
|
|
411
|
+
if (adoptedPoller !== undefined || !hasAdoptedRunning()) return;
|
|
412
|
+
adoptedPoller = setInterval(() => {
|
|
413
|
+
revalidateAdoptedJobs();
|
|
414
|
+
updateWidget(ctx);
|
|
415
|
+
if (!hasAdoptedRunning()) stopAdoptedPoller();
|
|
416
|
+
}, ADOPTED_POLL_MS);
|
|
417
|
+
adoptedPoller.unref();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function stopAdoptedPoller(): void {
|
|
421
|
+
if (adoptedPoller !== undefined) {
|
|
422
|
+
clearInterval(adoptedPoller);
|
|
423
|
+
adoptedPoller = undefined;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Entry renderer: job cards in the transcript ───────────────────────────
|
|
428
|
+
|
|
429
|
+
pi.registerEntryRenderer<BgrunJobEntryData>(
|
|
430
|
+
"bgrun-job",
|
|
431
|
+
(entry, { expanded }, theme) => {
|
|
432
|
+
const d =
|
|
433
|
+
entry.data ??
|
|
434
|
+
({
|
|
435
|
+
id: "?",
|
|
436
|
+
cmd: "",
|
|
437
|
+
started: 0,
|
|
438
|
+
logPath: "",
|
|
439
|
+
state: "running",
|
|
440
|
+
} as BgrunJobEntryData);
|
|
441
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
442
|
+
const icon = d.state === "done" ? (d.exitCode === 0 ? "✅" : "❌") : "🔄";
|
|
443
|
+
const exitStr = d.state === "done" ? ` exit=${d.exitCode ?? "?"}` : "";
|
|
444
|
+
const namePrefix = d.name ? `"${d.name}" ` : "";
|
|
445
|
+
box.addChild(
|
|
446
|
+
new Text(
|
|
447
|
+
`${icon} ${theme.fg("accent", "bgrun")} ${namePrefix}${d.id}${exitStr}`,
|
|
448
|
+
0,
|
|
449
|
+
0,
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
const cmdPreview = d.cmd.length > 60 ? d.cmd.slice(0, 57) + "…" : d.cmd;
|
|
453
|
+
box.addChild(new Text(theme.fg("dim", ` $ ${cmdPreview}`), 0, 0));
|
|
454
|
+
if (expanded) {
|
|
455
|
+
box.addChild(new Text(theme.fg("dim", ` log: ${d.logPath}`), 0, 0));
|
|
456
|
+
box.addChild(
|
|
457
|
+
new Text(
|
|
458
|
+
theme.fg(
|
|
459
|
+
"dim",
|
|
460
|
+
` started: ${new Date(d.started).toLocaleString()}`,
|
|
461
|
+
),
|
|
462
|
+
0,
|
|
463
|
+
0,
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
if (d.exitedAt) {
|
|
467
|
+
box.addChild(
|
|
468
|
+
new Text(
|
|
469
|
+
theme.fg(
|
|
470
|
+
"dim",
|
|
471
|
+
` finished: ${new Date(d.exitedAt).toLocaleString()}`,
|
|
472
|
+
),
|
|
473
|
+
0,
|
|
474
|
+
0,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return box;
|
|
480
|
+
},
|
|
481
|
+
);
|
|
252
482
|
|
|
253
483
|
// ── session_start: reconstruct Map from entries + auto-cleanup ────────────
|
|
254
484
|
|
|
@@ -283,52 +513,69 @@ export default function (pi: ExtensionAPI) {
|
|
|
283
513
|
});
|
|
284
514
|
}
|
|
285
515
|
} catch (err) {
|
|
286
|
-
console.error(
|
|
516
|
+
console.error(
|
|
517
|
+
"[pi-bgrun] session_start reconstruction failed:",
|
|
518
|
+
(err as Error).message,
|
|
519
|
+
);
|
|
287
520
|
}
|
|
288
521
|
|
|
289
522
|
// Adopt running jobs discovered from the jobs dir (started by other sessions).
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
523
|
+
// Opt-in (adoptForeignJobs / PI_BGRUN_FOREIGN_JOBS=1): the jobs dir is shared
|
|
524
|
+
// across every pi session on the machine, and most sessions don't want
|
|
525
|
+
// unrelated jobs from other projects cluttering the widget. Adopted jobs
|
|
526
|
+
// have no ChildProcess handle — no exit event, so a poller re-checks their
|
|
527
|
+
// logs and pids instead, and they leave the widget once finished.
|
|
528
|
+
const cfg = resolveConfig(ctx);
|
|
529
|
+
const jobsDir = cfg.jobsDir;
|
|
530
|
+
if (cfg.adoptForeignJobs) {
|
|
531
|
+
try {
|
|
532
|
+
for (const name of readdirSync(jobsDir)) {
|
|
533
|
+
if (!name.endsWith(".log")) continue;
|
|
534
|
+
const id = name.slice(0, -".log".length);
|
|
535
|
+
if (jobs.has(id)) continue;
|
|
536
|
+
const logPath = join(jobsDir, name);
|
|
537
|
+
const exit = parseExitFromLog(logPath);
|
|
538
|
+
if (exit !== null) continue; // finished — nothing to show in the widget
|
|
539
|
+
const pid = pidFromId(id);
|
|
540
|
+
if (pid === null || pid <= 0 || !isRunningPid(pid)) continue; // dead pid, marker just not written yet
|
|
541
|
+
let started = Date.now();
|
|
542
|
+
try {
|
|
543
|
+
started = statSync(logPath).birthtimeMs;
|
|
544
|
+
} catch {
|
|
545
|
+
// keep fallback
|
|
546
|
+
}
|
|
547
|
+
jobs.set(id, {
|
|
548
|
+
id,
|
|
549
|
+
pid,
|
|
550
|
+
cmd: "(started by another session)",
|
|
551
|
+
started,
|
|
552
|
+
logPath,
|
|
553
|
+
ctx,
|
|
554
|
+
adopted: true,
|
|
555
|
+
});
|
|
307
556
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
pid,
|
|
311
|
-
cmd: "(started by another session)",
|
|
312
|
-
started,
|
|
313
|
-
logPath,
|
|
314
|
-
ctx,
|
|
315
|
-
adopted: true,
|
|
316
|
-
});
|
|
557
|
+
} catch {
|
|
558
|
+
// jobs dir doesn't exist — nothing to adopt.
|
|
317
559
|
}
|
|
318
|
-
|
|
319
|
-
// jobs dir doesn't exist — nothing to adopt.
|
|
560
|
+
ensureAdoptedPoller(ctx);
|
|
320
561
|
}
|
|
321
562
|
|
|
322
563
|
// Show the widget if anything is now running (covers adopted + reconstructed jobs).
|
|
323
564
|
updateWidget(ctx);
|
|
324
|
-
// Auto-cleanup of old logs
|
|
325
|
-
|
|
565
|
+
// Auto-cleanup of old logs, throttled to one sweep per cleanupDays via a
|
|
566
|
+
// marker in the jobs dir (see autoCleanJobs). Also runs on session_shutdown.
|
|
567
|
+
autoCleanJobs(ctx);
|
|
326
568
|
});
|
|
327
569
|
|
|
328
|
-
pi.on("session_shutdown", async () => {
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
//
|
|
570
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
571
|
+
stopAdoptedPoller();
|
|
572
|
+
// Sweep old logs on the way out. Throttled via the .last-clean marker so
|
|
573
|
+
// restart-heavy workflows don't sweep more than once per cleanupDays.
|
|
574
|
+
try {
|
|
575
|
+
autoCleanJobs(ctx);
|
|
576
|
+
} catch {
|
|
577
|
+
// best-effort — shutdown must never throw
|
|
578
|
+
}
|
|
332
579
|
});
|
|
333
580
|
|
|
334
581
|
// ── bgrun tool ────────────────────────────────────────────────────────────
|
|
@@ -341,7 +588,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
341
588
|
"You will be woken automatically when the job finishes. Use this instead of bash for any command " +
|
|
342
589
|
"expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " +
|
|
343
590
|
"short human-readable label used in the job id, status output, and wake messages.",
|
|
344
|
-
promptSnippet:
|
|
591
|
+
promptSnippet:
|
|
592
|
+
"Run a long command detached in the background; get woken on completion",
|
|
345
593
|
promptGuidelines: [
|
|
346
594
|
"Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
|
|
347
595
|
"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.",
|
|
@@ -350,7 +598,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
350
598
|
],
|
|
351
599
|
parameters: Type.Object({
|
|
352
600
|
command: Type.String({
|
|
353
|
-
description:
|
|
601
|
+
description:
|
|
602
|
+
"Shell command to run in the background. Run as `sh -c`, so pipes and && work.",
|
|
354
603
|
}),
|
|
355
604
|
name: Type.Optional(
|
|
356
605
|
Type.String({
|
|
@@ -367,18 +616,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
367
616
|
}
|
|
368
617
|
const name = sanitizeName(rawName);
|
|
369
618
|
|
|
619
|
+
const jobsDir = resolveConfig(ctx).jobsDir;
|
|
370
620
|
mkdirSync(jobsDir, { recursive: true });
|
|
371
621
|
|
|
372
622
|
const slug = makeSlug(name ?? command);
|
|
373
623
|
const ts = Math.floor(Date.now() / 1000);
|
|
374
624
|
// The id must carry the CHILD's pid (liveness checks depend on it), but the
|
|
375
625
|
// log fd must exist before spawn. Create at a temp path, rename after spawn.
|
|
376
|
-
const tmpPath = join(
|
|
626
|
+
const tmpPath = join(
|
|
627
|
+
jobsDir,
|
|
628
|
+
`.tmp-${slug}-${ts}-${Math.random().toString(36).slice(2, 8)}.log`,
|
|
629
|
+
);
|
|
377
630
|
let logFd: number;
|
|
378
631
|
try {
|
|
379
632
|
logFd = openSync(tmpPath, "w");
|
|
380
633
|
} catch (err) {
|
|
381
|
-
throw new Error(
|
|
634
|
+
throw new Error(
|
|
635
|
+
`bgrun: cannot create log file: ${(err as Error).message}`,
|
|
636
|
+
);
|
|
382
637
|
}
|
|
383
638
|
const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`;
|
|
384
639
|
|
|
@@ -394,7 +649,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
649
|
try {
|
|
395
650
|
renameSync(tmpPath, logPath);
|
|
396
651
|
} catch (err) {
|
|
397
|
-
console.error(
|
|
652
|
+
console.error(
|
|
653
|
+
`[pi-bgrun] rename to final log path failed:`,
|
|
654
|
+
(err as Error).message,
|
|
655
|
+
);
|
|
398
656
|
}
|
|
399
657
|
|
|
400
658
|
const record: JobRecord = {
|
|
@@ -433,7 +691,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
433
691
|
delete rec.child; // release the handle reference
|
|
434
692
|
|
|
435
693
|
const exitCode = code ?? parseExitFromLog(logPath) ?? -1;
|
|
436
|
-
const exitStr =
|
|
694
|
+
const exitStr =
|
|
695
|
+
exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
|
|
437
696
|
const exitEmoji = exitCode === 0 ? "✅" : "❌";
|
|
438
697
|
const lastLine = readLastLogLine(logPath);
|
|
439
698
|
|
|
@@ -466,14 +725,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
466
725
|
try {
|
|
467
726
|
pi.sendUserMessage(wake, { deliverAs: "followUp" });
|
|
468
727
|
} catch (e2) {
|
|
469
|
-
console.error(
|
|
728
|
+
console.error(
|
|
729
|
+
`[pi-bgrun] wake failed for job ${id}:`,
|
|
730
|
+
(e2 as Error).message,
|
|
731
|
+
);
|
|
470
732
|
}
|
|
471
733
|
}
|
|
472
734
|
|
|
473
735
|
// Toast for the human.
|
|
474
736
|
if (rec.ctx.hasUI) {
|
|
475
737
|
const toastLabel = (rec.name ?? command).slice(0, 50);
|
|
476
|
-
rec.ctx.ui.notify(
|
|
738
|
+
rec.ctx.ui.notify(
|
|
739
|
+
`${exitEmoji} ${toastLabel} → exit ${exitStr}`,
|
|
740
|
+
exitCode === 0 ? "info" : "error",
|
|
741
|
+
);
|
|
477
742
|
}
|
|
478
743
|
|
|
479
744
|
// Update/clear the widget.
|
|
@@ -488,7 +753,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
488
753
|
|
|
489
754
|
const startedLines = [`started: ${id}`];
|
|
490
755
|
if (name) startedLines.push(` name: ${name}`);
|
|
491
|
-
startedLines.push(
|
|
756
|
+
startedLines.push(
|
|
757
|
+
` log: ${logPath}`,
|
|
758
|
+
` You'll be woken automatically when it finishes.`,
|
|
759
|
+
);
|
|
492
760
|
return {
|
|
493
761
|
content: [{ type: "text", text: startedLines.join("\n") }],
|
|
494
762
|
details: { id, name, logPath, pid: childPid },
|
|
@@ -506,16 +774,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
506
774
|
"Use this for a quick peek at results; use ctx_execute_file on the log path for whole-log failure analysis.",
|
|
507
775
|
promptSnippet: "Read the last N lines of a bgrun job's log",
|
|
508
776
|
parameters: Type.Object({
|
|
509
|
-
id: Type.String({
|
|
510
|
-
|
|
777
|
+
id: Type.String({
|
|
778
|
+
description: "Job id (from bgrun's 'started: <id>' response)",
|
|
779
|
+
}),
|
|
780
|
+
lines: Type.Optional(
|
|
781
|
+
Type.Number({ description: "Number of lines to show (default 40)" }),
|
|
782
|
+
),
|
|
511
783
|
}),
|
|
512
|
-
async execute(_toolCallId, params) {
|
|
784
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
513
785
|
const { id, lines = 40 } = params;
|
|
514
786
|
if (!id) throw new Error("bgtail: id is required");
|
|
515
|
-
const logPath = join(jobsDir, `${id}.log`);
|
|
787
|
+
const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
|
|
516
788
|
try {
|
|
517
789
|
const content = readFileSync(logPath, "utf8");
|
|
518
|
-
const all = content
|
|
790
|
+
const all = content
|
|
791
|
+
.split("\n")
|
|
792
|
+
.filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
|
|
519
793
|
const tail = all.slice(-lines);
|
|
520
794
|
return {
|
|
521
795
|
content: [{ type: "text", text: tail.join("\n") || "(empty log)" }],
|
|
@@ -523,7 +797,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
523
797
|
};
|
|
524
798
|
} catch {
|
|
525
799
|
return {
|
|
526
|
-
content: [
|
|
800
|
+
content: [
|
|
801
|
+
{ type: "text", text: `No log found for job ${id} at ${logPath}` },
|
|
802
|
+
],
|
|
527
803
|
details: { id, linesShown: 0, logPath, notFound: true },
|
|
528
804
|
isError: true,
|
|
529
805
|
};
|
|
@@ -537,38 +813,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
537
813
|
name: "bgstatus",
|
|
538
814
|
label: "Background Job Status",
|
|
539
815
|
description:
|
|
540
|
-
"Show status of background jobs. With an id: one job's state + exit code. Without: list
|
|
816
|
+
"Show status of background jobs. With an id: one job's state + exit code. Without: list this session's " +
|
|
817
|
+
"running jobs (finished jobs are hidden by default — pass includeDone or set showCompletedJobs to list " +
|
|
818
|
+
"them; other sessions' jobs are only listed when adoptForeignJobs is enabled).",
|
|
541
819
|
promptSnippet: "Check status of bgrun jobs",
|
|
542
820
|
parameters: Type.Object({
|
|
543
|
-
id: Type.Optional(
|
|
821
|
+
id: Type.Optional(
|
|
822
|
+
Type.String({ description: "Optional job id to inspect" }),
|
|
823
|
+
),
|
|
824
|
+
includeDone: Type.Optional(
|
|
825
|
+
Type.Boolean({
|
|
826
|
+
description:
|
|
827
|
+
"Include finished jobs (and other logs on disk) in the listing",
|
|
828
|
+
}),
|
|
829
|
+
),
|
|
544
830
|
}),
|
|
545
831
|
async execute(
|
|
546
832
|
_toolCallId,
|
|
547
833
|
params,
|
|
548
|
-
|
|
834
|
+
_signal,
|
|
835
|
+
_onUpdate,
|
|
836
|
+
ctx,
|
|
837
|
+
): Promise<{
|
|
838
|
+
content: { type: "text"; text: string }[];
|
|
839
|
+
details: BgStatusDetails;
|
|
840
|
+
isError?: boolean;
|
|
841
|
+
}> {
|
|
549
842
|
const { id } = params;
|
|
843
|
+
const cfg = resolveConfig(ctx);
|
|
844
|
+
const jobsDir = cfg.jobsDir;
|
|
550
845
|
if (id) {
|
|
551
846
|
const rec = jobs.get(id);
|
|
552
847
|
if (rec) {
|
|
553
|
-
const state = rec.exitCode
|
|
554
|
-
const exit =
|
|
848
|
+
const state = rec.exitCode === undefined ? "running" : "done";
|
|
849
|
+
const exit =
|
|
850
|
+
rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`;
|
|
555
851
|
const lines = [`${id}: ${state}${exit}`];
|
|
556
852
|
if (rec.name) lines.push(` name: ${rec.name}`);
|
|
557
853
|
lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
|
|
558
854
|
return {
|
|
559
855
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
560
|
-
details: {
|
|
856
|
+
details: {
|
|
857
|
+
id,
|
|
858
|
+
state,
|
|
859
|
+
exitCode: rec.exitCode ?? undefined,
|
|
860
|
+
cmd: rec.cmd,
|
|
861
|
+
name: rec.name,
|
|
862
|
+
recovered: false,
|
|
863
|
+
},
|
|
561
864
|
};
|
|
562
865
|
}
|
|
563
866
|
const logPath = join(jobsDir, `${id}.log`);
|
|
564
867
|
try {
|
|
565
868
|
const exit = parseExitFromLog(logPath);
|
|
566
|
-
const state = exit
|
|
869
|
+
const state = exit === null ? "running" : "done";
|
|
567
870
|
return {
|
|
568
871
|
content: [
|
|
569
|
-
{
|
|
872
|
+
{
|
|
873
|
+
type: "text",
|
|
874
|
+
text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
|
|
875
|
+
},
|
|
570
876
|
],
|
|
571
|
-
details: {
|
|
877
|
+
details: {
|
|
878
|
+
id,
|
|
879
|
+
state,
|
|
880
|
+
exitCode: exit ?? undefined,
|
|
881
|
+
recovered: true,
|
|
882
|
+
},
|
|
572
883
|
};
|
|
573
884
|
} catch {
|
|
574
885
|
return {
|
|
@@ -578,16 +889,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
578
889
|
};
|
|
579
890
|
}
|
|
580
891
|
}
|
|
581
|
-
// List
|
|
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);
|
|
582
899
|
const lines: string[] = [];
|
|
583
900
|
const seen = new Set<string>();
|
|
584
901
|
for (const [jid, rec] of jobs) {
|
|
585
902
|
seen.add(jid);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
+
}
|
|
590
911
|
}
|
|
912
|
+
let hiddenOnDisk = 0;
|
|
591
913
|
try {
|
|
592
914
|
for (const name of readdirSync(jobsDir)) {
|
|
593
915
|
if (!name.endsWith(".log")) continue;
|
|
@@ -595,12 +917,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
595
917
|
if (seen.has(jid)) continue;
|
|
596
918
|
const logPath = join(jobsDir, name);
|
|
597
919
|
const exit = parseExitFromLog(logPath);
|
|
598
|
-
|
|
599
|
-
|
|
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
|
+
}
|
|
600
933
|
}
|
|
601
934
|
} catch {
|
|
602
935
|
// jobs dir doesn't exist — nothing to scan.
|
|
603
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
|
+
}
|
|
604
942
|
if (lines.length === 0) {
|
|
605
943
|
return {
|
|
606
944
|
content: [{ type: "text", text: "(no bgrun jobs)" }],
|
|
@@ -625,15 +963,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
625
963
|
promptSnippet: "Remove old bgrun job logs",
|
|
626
964
|
parameters: Type.Object({
|
|
627
965
|
days: Type.Optional(
|
|
628
|
-
Type.Number({
|
|
966
|
+
Type.Number({
|
|
967
|
+
description: "Remove logs older than this many days (default 7)",
|
|
968
|
+
}),
|
|
629
969
|
),
|
|
630
970
|
}),
|
|
631
971
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
632
|
-
const
|
|
972
|
+
const cfg = resolveConfig(ctx);
|
|
973
|
+
const { days = cfg.cleanupDays } = params;
|
|
633
974
|
if (typeof days !== "number" || days < 0 || !Number.isFinite(days)) {
|
|
634
|
-
throw new Error(
|
|
975
|
+
throw new Error(
|
|
976
|
+
`bgclean: days must be a non-negative number, got ${days}`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
const result = cleanOldJobs(days, cfg.jobsDir, ctx);
|
|
980
|
+
// Manual clean refreshes the throttle marker so the next auto-sweep
|
|
981
|
+
// doesn't immediately redo this work.
|
|
982
|
+
try {
|
|
983
|
+
mkdirSync(cfg.jobsDir, { recursive: true });
|
|
984
|
+
writeFileSync(join(cfg.jobsDir, ".last-clean"), String(Date.now()));
|
|
985
|
+
} catch {
|
|
986
|
+
// best-effort
|
|
635
987
|
}
|
|
636
|
-
const result = cleanOldJobs(days, ctx);
|
|
637
988
|
const summary = `removed ${result.removed} job log(s), kept ${result.kept}${result.skippedRunning > 0 ? `, skipped ${result.skippedRunning} running` : ""}`;
|
|
638
989
|
return {
|
|
639
990
|
content: [{ type: "text", text: summary }],
|