@stablekernel/pi-background-run 0.2.0 → 0.3.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/extension/index.test.ts +126 -0
- package/extension/index.ts +74 -7
- package/package.json +2 -1
package/extension/index.test.ts
CHANGED
|
@@ -234,6 +234,132 @@ test("bgtail: returns last N lines, strips the exit marker", async () => {
|
|
|
234
234
|
}
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lines", async () => {
|
|
238
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
239
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
240
|
+
try {
|
|
241
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
242
|
+
await loadExtension(pi);
|
|
243
|
+
const bgrun = tools.get("bgrun")!;
|
|
244
|
+
const bgtail = tools.get("bgtail")!;
|
|
245
|
+
|
|
246
|
+
// 1 ANSI-colored line, 5 identical spinner lines, 1 huge line
|
|
247
|
+
const esc = "\u001b"; // literal ESC byte, safe to pass through a shell arg
|
|
248
|
+
const payload =
|
|
249
|
+
`printf "${esc}[32mOK green${esc}[0m\nwait\nwait\nwait\nwait\nwait\nline3\n"; ` +
|
|
250
|
+
"echo \"$(printf 'x%.0s' $(seq 1 5000))\"";
|
|
251
|
+
const res = await bgrun.execute(
|
|
252
|
+
"call-c1",
|
|
253
|
+
{ command: payload },
|
|
254
|
+
undefined,
|
|
255
|
+
undefined,
|
|
256
|
+
ctx,
|
|
257
|
+
);
|
|
258
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
259
|
+
await waitForWakes(wakes, 1);
|
|
260
|
+
|
|
261
|
+
const tail = await bgtail.execute(
|
|
262
|
+
"call-c1",
|
|
263
|
+
{ id, lines: 40 },
|
|
264
|
+
undefined,
|
|
265
|
+
undefined,
|
|
266
|
+
ctx,
|
|
267
|
+
);
|
|
268
|
+
const text = tail.content[0].text as string;
|
|
269
|
+
assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
|
|
270
|
+
assert.ok(text.includes("OK green"), "text after stripping survives");
|
|
271
|
+
assert.match(text, /wait \[x5\]/, "5 identical lines collapsed to one with count");
|
|
272
|
+
assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
|
|
273
|
+
assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
|
|
274
|
+
assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
|
|
275
|
+
assert.match(text, /1 repeated-line run collapsed/, "notes mention run collapse");
|
|
276
|
+
assert.ok((tail.details as any).condensed === true);
|
|
277
|
+
} finally {
|
|
278
|
+
delete process.env.PI_BGRUN_DIR;
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("bgtail: raw=true skips condensing", async () => {
|
|
284
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
285
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
286
|
+
try {
|
|
287
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
288
|
+
await loadExtension(pi);
|
|
289
|
+
const bgrun = tools.get("bgrun")!;
|
|
290
|
+
const bgtail = tools.get("bgtail")!;
|
|
291
|
+
|
|
292
|
+
const esc = "\u001b";
|
|
293
|
+
const res = await bgrun.execute(
|
|
294
|
+
"call-c2",
|
|
295
|
+
{ command: `printf "${esc}[31mraw-red${esc}[0m\nwait\nwait\nwait\n"` },
|
|
296
|
+
undefined,
|
|
297
|
+
undefined,
|
|
298
|
+
ctx,
|
|
299
|
+
);
|
|
300
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
301
|
+
await waitForWakes(wakes, 1);
|
|
302
|
+
|
|
303
|
+
const tail = await bgtail.execute(
|
|
304
|
+
"call-c2",
|
|
305
|
+
{ id, raw: true },
|
|
306
|
+
undefined,
|
|
307
|
+
undefined,
|
|
308
|
+
ctx,
|
|
309
|
+
);
|
|
310
|
+
const text = tail.content[0].text as string;
|
|
311
|
+
assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
|
|
312
|
+
assert.ok(text.includes("wait\nwait\nwait"), "raw keeps repeated lines uncollapsed");
|
|
313
|
+
assert.ok((tail.details as any).condensed === false);
|
|
314
|
+
} finally {
|
|
315
|
+
delete process.env.PI_BGRUN_DIR;
|
|
316
|
+
rmSync(dir, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("bgtail: total cap kicks in on large output with guidance note", async () => {
|
|
321
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
322
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
323
|
+
try {
|
|
324
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
325
|
+
await loadExtension(pi);
|
|
326
|
+
const bgrun = tools.get("bgrun")!;
|
|
327
|
+
const bgtail = tools.get("bgtail")!;
|
|
328
|
+
|
|
329
|
+
// ~200 distinct lines x ~500 chars = ~100KB, well past the 8KB total cap
|
|
330
|
+
const cmd =
|
|
331
|
+
"for i in $(seq 1 200); do echo \"line-$i $(printf 'y%.0s' $(seq 1 500))\"; done";
|
|
332
|
+
const res = await bgrun.execute(
|
|
333
|
+
"call-c3",
|
|
334
|
+
{ command: cmd },
|
|
335
|
+
undefined,
|
|
336
|
+
undefined,
|
|
337
|
+
ctx,
|
|
338
|
+
);
|
|
339
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
340
|
+
await waitForWakes(wakes, 1);
|
|
341
|
+
|
|
342
|
+
const tail = await bgtail.execute(
|
|
343
|
+
"call-c3",
|
|
344
|
+
{ id, lines: 200 },
|
|
345
|
+
undefined,
|
|
346
|
+
undefined,
|
|
347
|
+
ctx,
|
|
348
|
+
);
|
|
349
|
+
const text = tail.content[0].text as string;
|
|
350
|
+
assert.ok(text.length < 10_000, "result capped well below raw size");
|
|
351
|
+
assert.match(
|
|
352
|
+
text,
|
|
353
|
+
/output capped at 8000 chars — 200 raw lines total/,
|
|
354
|
+
"cap note names the raw line count and suggests escalation paths",
|
|
355
|
+
);
|
|
356
|
+
assert.ok((tail.details as any).condenserNotes, "notes in details too");
|
|
357
|
+
} finally {
|
|
358
|
+
delete process.env.PI_BGRUN_DIR;
|
|
359
|
+
rmSync(dir, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
237
363
|
test("bgstatus: shows running then done with exit code", async () => {
|
|
238
364
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
239
365
|
process.env.PI_BGRUN_DIR = dir;
|
package/extension/index.ts
CHANGED
|
@@ -594,7 +594,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
594
594
|
"Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
|
|
595
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.",
|
|
596
596
|
"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 —
|
|
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 only when the condensed tail is insufficient.",
|
|
598
598
|
],
|
|
599
599
|
parameters: Type.Object({
|
|
600
600
|
command: Type.String({
|
|
@@ -764,14 +764,67 @@ export default function (pi: ExtensionAPI) {
|
|
|
764
764
|
},
|
|
765
765
|
});
|
|
766
766
|
|
|
767
|
-
// ──
|
|
767
|
+
// ── Log condenser: ANSI strip, per-line cap, collapse runs, total budget ────
|
|
768
|
+
// Keeps bgtail output small enough that a "quick peek" never floods context:
|
|
769
|
+
// colored test output often carries 2-3x its text size in ANSI escapes, and
|
|
770
|
+
// one unbounded line (minified bundle, base64 blob) can blow the whole budget.
|
|
771
|
+
const ANSI_RE = /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g;
|
|
772
|
+
const LINE_CAP = 2000; // chars per line after stripping
|
|
773
|
+
const TOTAL_CAP = 8000; // chars for the whole bgtail result
|
|
774
|
+
|
|
775
|
+
function condenseLogLines(
|
|
776
|
+
lines: string[],
|
|
777
|
+
opts: { raw?: boolean } = {},
|
|
778
|
+
): { text: string; truncated: string[] } {
|
|
779
|
+
const notes: string[] = [];
|
|
780
|
+
if (opts.raw) return { text: lines.join("\n"), truncated: notes };
|
|
781
|
+
let stripped = 0;
|
|
782
|
+
let cappedLines = 0;
|
|
783
|
+
const clean = lines.map((l) => {
|
|
784
|
+
if (ANSI_RE.test(l)) { stripped++; l = l.replace(ANSI_RE, ""); }
|
|
785
|
+
return l;
|
|
786
|
+
});
|
|
787
|
+
ANSI_RE.lastIndex = 0;
|
|
788
|
+
// collapse runs of 3+ identical lines (spinner frames, retry spam)
|
|
789
|
+
const collapsed: { text: string; count: number }[] = [];
|
|
790
|
+
let runs = 0;
|
|
791
|
+
for (const l of clean) {
|
|
792
|
+
const prev = collapsed[collapsed.length - 1];
|
|
793
|
+
if (prev && prev.text === l) {
|
|
794
|
+
prev.count++;
|
|
795
|
+
if (prev.count === 3) runs++;
|
|
796
|
+
} else {
|
|
797
|
+
collapsed.push({ text: l, count: 1 });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const out: string[] = [];
|
|
801
|
+
let total = 0;
|
|
802
|
+
for (const c of collapsed) {
|
|
803
|
+
let line = c.count >= 3 ? `${c.text} [x${c.count}]` : c.text;
|
|
804
|
+
if (line.length > LINE_CAP) {
|
|
805
|
+
line = line.slice(0, LINE_CAP) + ` …[+${line.length - LINE_CAP} chars]`;
|
|
806
|
+
cappedLines++;
|
|
807
|
+
}
|
|
808
|
+
total += line.length + 1;
|
|
809
|
+
if (total > TOTAL_CAP) {
|
|
810
|
+
notes.push(`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`);
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
out.push(line);
|
|
814
|
+
}
|
|
815
|
+
if (stripped > 0) notes.push(`${stripped} ANSI escape sequence${stripped === 1 ? "" : "s"} stripped`);
|
|
816
|
+
if (runs > 0) notes.push(`${runs} repeated-line run${runs === 1 ? "" : "s"} collapsed`);
|
|
817
|
+
if (cappedLines > 0) notes.push(`${cappedLines} long line${cappedLines === 1 ? "" : "s"} truncated to ${LINE_CAP} chars`);
|
|
818
|
+
return { text: out.join("\n"), truncated: notes };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ── bgtail: read last N lines of a job's log, condensed for context ────────
|
|
768
822
|
|
|
769
823
|
pi.registerTool({
|
|
770
824
|
name: "bgtail",
|
|
771
825
|
label: "Tail Background Log",
|
|
772
826
|
description:
|
|
773
|
-
"Print the last N lines of a background job's log (default 40). Strips the exit-marker line. "
|
|
774
|
-
"Use this for a quick peek at results; use ctx_execute_file on the log path for whole-log failure analysis.",
|
|
827
|
+
"Print the last N lines of a background job's log (default 40), condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. Pass raw: true for unprocessed output; use ctx_execute_file on the log path for whole-log failure analysis.",
|
|
775
828
|
promptSnippet: "Read the last N lines of a bgrun job's log",
|
|
776
829
|
parameters: Type.Object({
|
|
777
830
|
id: Type.String({
|
|
@@ -780,9 +833,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
780
833
|
lines: Type.Optional(
|
|
781
834
|
Type.Number({ description: "Number of lines to show (default 40)" }),
|
|
782
835
|
),
|
|
836
|
+
raw: Type.Optional(
|
|
837
|
+
Type.Boolean({
|
|
838
|
+
description: "Skip condensing (ANSI strip, collapse, caps) and return raw text",
|
|
839
|
+
}),
|
|
840
|
+
),
|
|
783
841
|
}),
|
|
784
842
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
785
|
-
const { id, lines = 40 } = params;
|
|
843
|
+
const { id, lines = 40, raw = false } = params;
|
|
786
844
|
if (!id) throw new Error("bgtail: id is required");
|
|
787
845
|
const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`);
|
|
788
846
|
try {
|
|
@@ -791,9 +849,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
791
849
|
.split("\n")
|
|
792
850
|
.filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
|
|
793
851
|
const tail = all.slice(-lines);
|
|
852
|
+
const { text, truncated } = condenseLogLines(tail, { raw });
|
|
853
|
+
const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
|
|
794
854
|
return {
|
|
795
|
-
content: [{ type: "text", text:
|
|
796
|
-
details: {
|
|
855
|
+
content: [{ type: "text", text: (text + notes) || "(empty log)" }],
|
|
856
|
+
details: {
|
|
857
|
+
id,
|
|
858
|
+
linesShown: tail.length,
|
|
859
|
+
logPath,
|
|
860
|
+
notFound: false,
|
|
861
|
+
condensed: !raw,
|
|
862
|
+
...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
|
|
863
|
+
},
|
|
797
864
|
};
|
|
798
865
|
} catch {
|
|
799
866
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stablekernel/pi-background-run",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run long shell commands detached in the background for pi; get woken on completion. Output lands in a file; context stays clean.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"url": "https://github.com/stablekernel/pi-background-run/issues"
|
|
38
38
|
},
|
|
39
39
|
"keywords": [
|
|
40
|
+
"pi-package",
|
|
40
41
|
"pi",
|
|
41
42
|
"pi-coding-agent",
|
|
42
43
|
"background",
|