@pify/shell-background 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/README.md CHANGED
@@ -53,11 +53,14 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
53
53
  ```json
54
54
  {
55
55
  "autoBackgroundMs": 30000,
56
- "tailBytes": 65536
56
+ "tailBytes": 65536,
57
+ "maxBackground": 8
57
58
  }
58
59
  ```
59
60
 
60
- `autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result shows. Bad values fall back to the defaults with a warning rather than taking the tool down.
61
+ `autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result shows. `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. Bad values fall back to the defaults with a warning rather than taking the tool down.
62
+
63
+ The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes, control characters and carriage-return progress frames stripped — so a chatty build or dev server does not spend tokens on colour codes and thousands of overwritten progress lines. The log file on disk keeps every byte.
61
64
 
62
65
  ## Coexistence with @pify/pretty
63
66
 
@@ -47,6 +47,7 @@ import { buildEnv } from "../src/env.ts";
47
47
  import { DEFAULT_SETTINGS, resolveSettings, type ShellBgSettings } from "../src/config.ts";
48
48
  import { backgroundedResult, deliveryMessage, DELIVERY_TYPE } from "../src/pending.ts";
49
49
  import { formatResult, formatList, header } from "../src/format.ts";
50
+ import { sanitizeOutput } from "../src/sanitize.ts";
50
51
  import { buildWidgetLines } from "../src/widget.ts";
51
52
  import { isFinished } from "../src/types.ts";
52
53
  import type { Job } from "../src/types.ts";
@@ -187,7 +188,7 @@ export default function shellBackground(pi: ExtensionAPI) {
187
188
  function snapshot(job: Job): ToolResult {
188
189
  const tail = readTail(job.logPath, settings.tailBytes);
189
190
  return {
190
- content: [{ type: "text", text: `${header(job)}\n${tail.text.replace(/\n+$/, "") || "(no output yet)"}` }],
191
+ content: [{ type: "text", text: `${header(job)}\n${sanitizeOutput(tail.text).replace(/\n+$/, "") || "(no output yet)"}` }],
191
192
  details: { id: job.id, status: job.status },
192
193
  };
193
194
  }
@@ -248,6 +249,27 @@ export default function shellBackground(pi: ExtensionAPI) {
248
249
  const command = String(params.command ?? "").trim();
249
250
  if (!command) return { content: [{ type: "text", text: "Empty command." }], details: {}, isError: true };
250
251
 
252
+ // The cap bounds how many jobs may be alive, not whether a command runs:
253
+ // an explicit background request over it is refused (the model can still
254
+ // run the command in the foreground); an auto-background transition over
255
+ // it is skipped and the command simply stays in the foreground.
256
+ const liveOthers = (self?: string) => registry!.running().filter((j) => j.id !== self).length;
257
+ if (params.background && liveOthers() >= settings.maxBackground) {
258
+ const n = liveOthers();
259
+ return {
260
+ content: [
261
+ {
262
+ type: "text",
263
+ text:
264
+ `${n} background command${n === 1 ? " is" : "s are"} already running (maxBackground = ${settings.maxBackground}). ` +
265
+ `shell_kill one you no longer need, wait for one with shell_status {id, wait}, or run this in the foreground without background:true.`,
266
+ },
267
+ ],
268
+ details: { running: n, limit: settings.maxBackground },
269
+ isError: true,
270
+ };
271
+ }
272
+
251
273
  const job = registry.create(command, ctx.cwd);
252
274
  const { shell, args } = shellArgv();
253
275
 
@@ -316,13 +338,31 @@ export default function shellBackground(pi: ExtensionAPI) {
316
338
 
317
339
  try {
318
340
  const race: Array<Promise<string>> = [settle.then(() => "exit")];
319
- if (autoMs > 0) race.push(after(autoMs, "auto"));
320
341
  if (params.timeout && params.timeout > 0) race.push(after(params.timeout * 1000, "timeout"));
321
342
  if (signal) race.push(abort);
322
343
 
323
- const outcome = await Promise.race(race);
344
+ let outcome = await Promise.race(autoMs > 0 ? [...race, after(autoMs, "auto")] : race);
345
+
346
+ // Over the cap, the threshold passes without moving the command: it is
347
+ // never refused, it just keeps its foreground slot until it ends.
348
+ let keptForeground = false;
349
+ if (outcome === "auto" && liveOthers(job.id) >= settings.maxBackground) {
350
+ keptForeground = true;
351
+ outcome = await Promise.race(race);
352
+ }
324
353
 
325
- if (outcome === "exit") return finished(job);
354
+ if (outcome === "exit") {
355
+ const r = finished(job);
356
+ if (keptForeground) {
357
+ r.content = [
358
+ {
359
+ type: "text",
360
+ text: `${r.content[0]?.text ?? ""}\n\n[kept in the foreground: ${settings.maxBackground} background commands were already running]`,
361
+ },
362
+ ];
363
+ }
364
+ return r;
365
+ }
326
366
 
327
367
  if (outcome === "auto") {
328
368
  scheduleDelivery(job, settle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Long-running bash goes async: background: true launches detached, and any command still running after 30s auto-backgrounds and delivers its result when it finishes",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -63,9 +63,9 @@
63
63
  }
64
64
  },
65
65
  "devDependencies": {
66
- "@earendil-works/pi-ai": "^0.85.1",
67
- "@earendil-works/pi-coding-agent": "^0.85.1",
68
- "@earendil-works/pi-tui": "^0.85.1",
66
+ "@earendil-works/pi-ai": "^0.87.0",
67
+ "@earendil-works/pi-coding-agent": "^0.87.0",
68
+ "@earendil-works/pi-tui": "^0.87.0",
69
69
  "@types/node": "^22.10.2",
70
70
  "typebox": "^1.1.38",
71
71
  "typescript": "^5.7.2"
package/src/config.ts CHANGED
@@ -14,11 +14,21 @@ export interface ShellBgSettings {
14
14
  autoBackgroundMs: number;
15
15
  /** Bytes of the log tail shown in a status/collect result. */
16
16
  tailBytes: number;
17
+ /**
18
+ * How many jobs may be alive at once before a request to background one
19
+ * more is refused and an auto-background transition is skipped. Every live
20
+ * job is a whole process tree plus an open log; a session that keeps
21
+ * starting servers and watchers should hear about it rather than
22
+ * accumulate them without bound. Only the backgrounding decision is capped
23
+ * — a command itself is never refused.
24
+ */
25
+ maxBackground: number;
17
26
  }
18
27
 
19
28
  export const DEFAULT_SETTINGS: ShellBgSettings = {
20
29
  autoBackgroundMs: 30_000,
21
30
  tailBytes: 64 * 1024,
31
+ maxBackground: 8,
22
32
  };
23
33
 
24
34
  const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
@@ -26,6 +36,7 @@ const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
26
36
  // does not make every command look long-running.
27
37
  autoBackgroundMs: { min: 0, max: 3_600_000 },
28
38
  tailBytes: { min: 1024, max: 4 * 1024 * 1024 },
39
+ maxBackground: { min: 1, max: 64 },
29
40
  };
30
41
 
31
42
  export function resolveSettings(
package/src/format.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { Job } from "./types.ts";
6
6
  import { readTail, countLines } from "./tail.ts";
7
+ import { sanitizeOutput } from "./sanitize.ts";
7
8
 
8
9
  function secs(ms: number): string {
9
10
  if (ms < 1000) return `${ms}ms`;
@@ -38,14 +39,17 @@ export function header(job: Job): string {
38
39
  */
39
40
  export function formatResult(job: Job, tailBytes: number): string {
40
41
  const tail = readTail(job.logPath, tailBytes);
42
+ // Cleaned the way pi's own bash cleans its output for the model; the log on
43
+ // disk (which the truncation note points at) keeps every byte.
44
+ const text = sanitizeOutput(tail.text);
41
45
  const lines = [header(job)];
42
- if (tail.text.trim() === "") {
46
+ if (text.trim() === "") {
43
47
  lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
44
48
  } else {
45
49
  if (tail.truncated) {
46
- lines.push(`… showing the last ${countLines(tail.text)} lines — full log: ${job.logPath}`);
50
+ lines.push(`… showing the last ${countLines(text)} lines — full log: ${job.logPath}`);
47
51
  }
48
- lines.push(tail.text.replace(/\n+$/, ""));
52
+ lines.push(text.replace(/\n+$/, ""));
49
53
  }
50
54
  return lines.join("\n");
51
55
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The same cleaning pi's own bash tool applies to what the model sees
3
+ * (bash-executor.ts: `sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "")`).
4
+ *
5
+ * This package re-registers `bash`, and a job's log is captured raw on disk on
6
+ * purpose (the detached spawn writes straight to a file; no in-process pipes).
7
+ * Without this step the tail handed back for a chatty build or dev server is
8
+ * escape codes and carriage-return progress frames — tokens the native tool
9
+ * would never have spent. The on-disk log stays byte-for-byte; only the text
10
+ * that reaches the model is cleaned. Zero dependencies: pi does not export
11
+ * these helpers, so the regex (ansi-regex, MIT) and the code-point filter are
12
+ * vendored here verbatim.
13
+ */
14
+
15
+ // Valid string terminator sequences are BEL, ESC\, and 0x9c
16
+ const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
17
+ // OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
18
+ const OSC = `(?:\\u001B\\][\\s\\S]*?${ST})`;
19
+ // CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte
20
+ const CSI = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
21
+ const ANSI = new RegExp(`${OSC}|${CSI}`, "g");
22
+
23
+ export function stripAnsi(text: string): string {
24
+ // Fast path: ANSI codes require ESC (7-bit) or CSI (8-bit) introducer.
25
+ if (!text.includes("\u001B") && !text.includes("\u009B")) return text;
26
+ return text.replace(ANSI, "");
27
+ }
28
+
29
+ /**
30
+ * Drop characters that crash string-width or corrupt a terminal: control
31
+ * characters (except tab/newline/CR), Unicode format characters, lone
32
+ * surrogates and undefined code points.
33
+ */
34
+ export function sanitizeBinaryOutput(text: string): string {
35
+ return Array.from(text)
36
+ .filter((char) => {
37
+ const code = char.codePointAt(0);
38
+ if (code === undefined) return false;
39
+ if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
40
+ if (code <= 0x1f) return false;
41
+ if (code >= 0xfff9 && code <= 0xfffb) return false;
42
+ return true;
43
+ })
44
+ .join("");
45
+ }
46
+
47
+ /** Exactly what pi's bash gives the model: no ANSI, no control noise, no `\r`. */
48
+ export function sanitizeOutput(text: string): string {
49
+ return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
50
+ }