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