@pify/shell-background 0.2.0 → 0.3.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 CHANGED
@@ -53,11 +53,16 @@ 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
+ A backgrounded command runs with no one to answer it, so its environment says so: `GIT_TERMINAL_PROMPT=0` and `GIT_EDITOR`/`EDITOR`/`VISUAL=true` are forced (a `git commit` without `-m` fails with "empty message" instead of waiting on an editor that never comes, and an https `git fetch` fails instead of prompting for a password), and pagers default to `cat` unless you set them. `CI` and `TERM` are left alone on purpose — dev servers and watchers read them.
64
+
65
+ 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
66
 
62
67
  ## Coexistence with @pify/pretty
63
68
 
@@ -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.1",
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/env.ts CHANGED
@@ -17,6 +17,37 @@
17
17
  */
18
18
  import { delimiter } from "node:path";
19
19
 
20
+ /**
21
+ * Defaults that keep an unattended child from waiting on a human. FORCED —
22
+ * they win over the inherited value — because a background job has no one
23
+ * to answer: `git commit` without -m must not open $EDITOR, `git fetch` over
24
+ * https must not prompt for a password. With stdin ignored such a prompt
25
+ * either hangs forever or sits until the tree is killed, and while it waits
26
+ * it holds one of the maxBackground slots. (Set from oh-my-pi's
27
+ * non-interactive-env; the editor becomes `true`, which exits 0 with the
28
+ * message untouched, so git aborts with "empty commit message" instead.)
29
+ */
30
+ export const NON_INTERACTIVE_FORCED: Readonly<Record<string, string>> = {
31
+ GIT_TERMINAL_PROMPT: "0",
32
+ GIT_EDITOR: "true",
33
+ EDITOR: "true",
34
+ VISUAL: "true",
35
+ };
36
+
37
+ /**
38
+ * Formatting-only defaults, applied UNDER the inherited env so an explicit
39
+ * value still wins: a pager only ever changes how output is shown, and to a
40
+ * pipe it is dead weight. Deliberately NOT here: CI=true and TERM=dumb —
41
+ * this package also runs dev servers and watchers, and those flags make many
42
+ * of them drop watch mode, change ports or strip the output the user wants.
43
+ */
44
+ export const NON_INTERACTIVE_DEFAULTS: Readonly<Record<string, string>> = {
45
+ PAGER: "cat",
46
+ GIT_PAGER: "cat",
47
+ GH_PAGER: "cat",
48
+ LESS: "FRX",
49
+ };
50
+
20
51
  /** The slice of the pi ExtensionContext this needs, kept structural for tests. */
21
52
  export interface EnvCtx {
22
53
  sessionManager?: { getSessionId?(): string; getSessionFile?(): string | undefined };
@@ -25,16 +56,18 @@ export interface EnvCtx {
25
56
  }
26
57
 
27
58
  /**
28
- * Copy `base`, prepend `binDir` to PATH when absent, and set the PI_* session
29
- * variables from `ctx`. The ctx reads are wrapped so a stale ctx after a
30
- * `/reload` degrades to plain env rather than failing the spawn.
59
+ * Copy `base`, prepend `binDir` to PATH when absent, set the PI_* session
60
+ * variables from `ctx`, and layer the non-interactive defaults (formatting
61
+ * ones under the base, the anti-hang ones over it). The ctx reads are
62
+ * wrapped so a stale ctx after a `/reload` degrades to plain env rather than
63
+ * failing the spawn.
31
64
  */
32
65
  export function buildEnv(
33
66
  ctx: EnvCtx,
34
67
  binDir: string,
35
68
  base: NodeJS.ProcessEnv = process.env,
36
69
  ): NodeJS.ProcessEnv {
37
- const env: NodeJS.ProcessEnv = { ...base };
70
+ const env: NodeJS.ProcessEnv = { ...NON_INTERACTIVE_DEFAULTS, ...base, ...NON_INTERACTIVE_FORCED };
38
71
 
39
72
  // PATH is case-insensitive on Windows; find the real key so we do not create a
40
73
  // second, ignored "PATH" alongside an inherited "Path". Only prepend when the
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
+ }