@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.
@@ -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;
@@ -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 — use bgtail for a peek or ctx_execute_file for failure analysis.",
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
- // ── bgtail: read last N lines of a job's log, stripping the exit marker ────
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: tail.join("\n") || "(empty log)" }],
796
- details: { id, linesShown: tail.length, logPath, notFound: false },
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.2.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",