@pify/shell-background 0.1.2 → 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
@@ -12,7 +12,7 @@ pi's bash tool waits for the command to finish. That is right for `ls` and wrong
12
12
 
13
13
  ## What it does
14
14
 
15
- It re-registers the `bash` tool with the same shell, working directory and environment nothing about how a command runs changes — but a different lifecycle:
15
+ It re-registers the `bash` tool with the same shell, working directory, PATH (including pi's managed `fd`/`rg` bin dir) and `PI_*` session variables pi's own bash hands a command — but a different lifecycle:
16
16
 
17
17
  | Situation | What happens |
18
18
  |---|---|
@@ -25,6 +25,7 @@ It re-registers the `bash` tool with the same shell, working directory and envir
25
25
  bash { command: "npm run build" } # returns when done, or auto-backgrounds at 30s
26
26
  bash { command: "npm run dev", background: true } # → "bg-2 started in the background"
27
27
  shell_status { id: "bg-2" } # status + output so far
28
+ shell_status { id: "bg-2", wait: 60 } # block up to 60s until it finishes (headless collect)
28
29
  shell_status # list every background command this session
29
30
  shell_kill { id: "bg-2" } # stop it and its whole process tree
30
31
  ```
@@ -33,13 +34,15 @@ shell_kill { id: "bg-2" } # stop it and its whole process tree
33
34
 
34
35
  ## Delivery, and the headless caveat
35
36
 
36
- When a backgrounded command finishes in an **interactive** session, its result is pushed into the conversation as the next turn — you do not have to poll. Under headless `pi -p` there is nothing to deliver into (the session tears down when the prompt resolves), so **auto-background is disabled there** and only explicit `background: true` applies; collect it with `shell_status` inside the same turn. This is the same delivery rule the rest of the suite lives by.
37
+ When a backgrounded command finishes in an **interactive** session, its result is pushed into the conversation as the next turn — you do not have to poll. Under headless `pi -p` there is nothing to deliver into (the session tears down when the prompt resolves), so **auto-background is disabled there** and only explicit `background: true` applies; collect it within the same turn with `shell_status { id, wait: N }`, which blocks (up to `N` seconds, 0–300) until the command finishes rather than returning immediately. This is the same delivery rule the rest of the suite lives by.
37
38
 
38
39
  ## How it works
39
40
 
40
41
  Each command is spawned with its stdout and stderr piped into a single log file, drained on every chunk so nothing is lost no matter how much it prints, and finalized only after the pipes end (with a short grace so a daemonized grandchild that holds a handle open cannot truncate the tail). The process is spawned detached (POSIX) and `unref`'d so a running job never holds the host open, and killed as a whole process tree — `taskkill /T` on Windows, a process-group signal on POSIX — on timeout, abort, `shell_kill`, or session shutdown.
41
42
 
42
- Shell resolution reuses pi's own `getShellConfig` (Git Bash on Windows, `/bin/bash` then `sh` on Unix), so a backgrounded command behaves identically to a foreground one. Jobs are tracked in memory and mirrored to a per-session sidecar under the temp dir, so `shell_status` still answers after a `/reload` and a job whose process has died is reconciled rather than shown as forever-running.
43
+ Shell resolution reuses pi's own `getShellConfig` (Git Bash on Windows, `/bin/bash` then `sh` on Unix) and the environment is rebuilt the way pi's bash builds it (managed bin dir on PATH, `PI_SESSION_ID`/`PI_SESSION_FILE`/`PI_PROVIDER`/`PI_MODEL`/`PI_REASONING_LEVEL` from the session), so a backgrounded command behaves identically to a foreground one.
44
+
45
+ Jobs are tracked in memory and mirrored to a sidecar under the temp dir, keyed by the pi **session id** — so two sessions in the same directory never see or kill each other's jobs, and the id is stable across a `/reload`. A `/reload` does **not** kill background jobs: pi hands the same host process to a fresh instance, which adopts every still-running job from the sidecars and delivers each one when it finishes (exactly once). Every other way a session ends — quit, or switching to another session — kills its jobs and their whole process trees. Each record also carries the host pid that spawned it: a `running` record left by a different (or crashed) host is surfaced as `orphaned` and never treated as live, so its pid — which may since belong to something unrelated — is never signalled. A job whose process has died is reconciled rather than shown as forever-running, this session's dir is removed on a clean exit, and stray dirs from a crash are swept after seven days.
43
46
 
44
47
  There are **no runtime dependencies**, and it works on Linux, macOS and Windows.
45
48
 
@@ -50,11 +53,14 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
50
53
  ```json
51
54
  {
52
55
  "autoBackgroundMs": 30000,
53
- "tailBytes": 65536
56
+ "tailBytes": 65536,
57
+ "maxBackground": 8
54
58
  }
55
59
  ```
56
60
 
57
- `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.
58
64
 
59
65
  ## Coexistence with @pify/pretty
60
66
 
@@ -37,15 +37,17 @@ import { Type } from "typebox";
37
37
  import { tmpdir } from "node:os";
38
38
  import { join } from "node:path";
39
39
  import { createHash } from "node:crypto";
40
- import { readFileSync } from "node:fs";
40
+ import { readFileSync, readdirSync, statSync, rmSync } from "node:fs";
41
41
 
42
- import { JobRegistry } from "../src/registry.ts";
42
+ import { JobRegistry, isAlive } from "../src/registry.ts";
43
43
  import { spawnToFile } from "../src/spawn.ts";
44
44
  import { killTree } from "../src/kill.ts";
45
45
  import { readTail } from "../src/tail.ts";
46
+ import { buildEnv } from "../src/env.ts";
46
47
  import { DEFAULT_SETTINGS, resolveSettings, type ShellBgSettings } from "../src/config.ts";
47
48
  import { backgroundedResult, deliveryMessage, DELIVERY_TYPE } from "../src/pending.ts";
48
49
  import { formatResult, formatList, header } from "../src/format.ts";
50
+ import { sanitizeOutput } from "../src/sanitize.ts";
49
51
  import { buildWidgetLines } from "../src/widget.ts";
50
52
  import { isFinished } from "../src/types.ts";
51
53
  import type { Job } from "../src/types.ts";
@@ -60,13 +62,67 @@ export default function shellBackground(pi: ExtensionAPI) {
60
62
  let settings: ShellBgSettings = DEFAULT_SETTINGS;
61
63
  let registry: JobRegistry | null = null;
62
64
  let lastUiCtx: UiContext | null = null;
65
+ let widgetTimer: NodeJS.Timeout | null = null;
66
+ // Set on a /reload shutdown: this (old) instance's processes are being handed
67
+ // to the next instance in the same host process, so its settle/delivery
68
+ // closures must go quiet — flush status to disk, but never kill and never
69
+ // deliver through the now-stale pi handle. Each instance has its own copy
70
+ // (reload builds a fresh closure), so this only ever flips once, on the way out.
71
+ let handedOff = false;
72
+
73
+ const ROOT = join(tmpdir(), "pify-shell-bg");
74
+ const MAX_SESSION_DIR_AGE_MS = 7 * 24 * 60 * 60 * 1000;
75
+
76
+ // Ref'd on purpose: this backs the shell_status `wait` poll, which is awaited
77
+ // inside an in-flight tool call, so the timer must actually resolve rather than
78
+ // let an otherwise-idle loop exit out from under it.
79
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
80
+
81
+ /**
82
+ * The one auto-background threshold, computed once so the bash guideline and
83
+ * runBash cannot drift: auto-background needs a session that outlives the run
84
+ * to deliver into, which a headless `pi -p` does not have, so it is off there.
85
+ */
86
+ const effectiveAutoMs = (hasUI: boolean): number => (hasUI ? settings.autoBackgroundMs : 0);
63
87
 
64
88
  // ── setup ──────────────────────────────────────────────────────────
65
89
 
66
- function sessionKey(cwd: string): string {
67
- const id = process.env.PI_SESSION_ID;
90
+ /**
91
+ * A stable per-session key: the session id survives a /reload (so adopted
92
+ * jobs are found again) and differs across /new and /resume (so sessions never
93
+ * reconcile or kill each other's jobs). Falls back to a cwd hash only when no
94
+ * session manager is present (e.g. the unit harness).
95
+ */
96
+ function sessionKey(ctx: UiContext): string {
97
+ let id: string | undefined;
98
+ try {
99
+ id = ctx.sessionManager?.getSessionId?.();
100
+ } catch {
101
+ // ignore — fall through to the cwd hash
102
+ }
68
103
  if (id) return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
69
- return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
104
+ return createHash("sha256").update(ctx.cwd).digest("hex").slice(0, 16);
105
+ }
106
+
107
+ /** Remove sibling session dirs untouched for a week (a crash leaves the dir). */
108
+ function sweepOldDirs(keep: string): void {
109
+ let names: string[];
110
+ try {
111
+ names = readdirSync(ROOT);
112
+ } catch {
113
+ return;
114
+ }
115
+ const cutoff = Date.now() - MAX_SESSION_DIR_AGE_MS;
116
+ for (const name of names) {
117
+ if (name === keep) continue; // never sweep the session we are starting
118
+ const p = join(ROOT, name);
119
+ try {
120
+ const st = statSync(p);
121
+ if (st.isDirectory() && st.mtimeMs < cutoff) rmSync(p, { recursive: true, force: true });
122
+ } catch {
123
+ // A dir we cannot stat or remove is not worth failing startup over.
124
+ }
125
+ }
70
126
  }
71
127
 
72
128
  function loadSettings(cwd: string): string[] {
@@ -100,6 +156,13 @@ export default function shellBackground(pi: ExtensionAPI) {
100
156
  return { shell: cfg.shell, args };
101
157
  }
102
158
 
159
+ function stopWidgetTimer(): void {
160
+ if (widgetTimer) {
161
+ clearInterval(widgetTimer);
162
+ widgetTimer = null;
163
+ }
164
+ }
165
+
103
166
  function renderWidget(ctx: UiContext | null = lastUiCtx): void {
104
167
  if (!ctx || !ctx.hasUI || !registry) return;
105
168
  lastUiCtx = ctx;
@@ -107,9 +170,17 @@ export default function shellBackground(pi: ExtensionAPI) {
107
170
  const lines = buildWidgetLines(registry.all(), ctx.ui.theme as never, now);
108
171
  if (lines.length === 0) {
109
172
  ctx.ui.setWidget(WIDGET, undefined);
173
+ stopWidgetTimer();
110
174
  return;
111
175
  }
112
176
  ctx.ui.setWidget(WIDGET, (_tui: unknown) => new Text(lines.join("\n"), 0, 0), { placement: "aboveEditor" });
177
+ // Keep the box live between events: tick the elapsed clock while jobs run and
178
+ // clear a finished job once it falls out of its 15s window, even if nothing
179
+ // else fires a render. Unref'd, so a lingering box never holds the host open.
180
+ if (!widgetTimer) {
181
+ widgetTimer = setInterval(() => renderWidget(), 1000);
182
+ widgetTimer.unref?.();
183
+ }
113
184
  }
114
185
 
115
186
  // ── run ────────────────────────────────────────────────────────────
@@ -117,7 +188,7 @@ export default function shellBackground(pi: ExtensionAPI) {
117
188
  function snapshot(job: Job): ToolResult {
118
189
  const tail = readTail(job.logPath, settings.tailBytes);
119
190
  return {
120
- 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)"}` }],
121
192
  details: { id: job.id, status: job.status },
122
193
  };
123
194
  }
@@ -130,23 +201,36 @@ export default function shellBackground(pi: ExtensionAPI) {
130
201
  };
131
202
  }
132
203
 
204
+ /** Push a finished job's result into the conversation through the live pi. */
205
+ function deliver(job: Job): void {
206
+ renderWidget();
207
+ pi.sendMessage(
208
+ {
209
+ customType: DELIVERY_TYPE,
210
+ content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
211
+ display: true,
212
+ details: { id: job.id, status: job.status, exitCode: job.exitCode },
213
+ },
214
+ { deliverAs: "followUp", triggerTurn: true },
215
+ );
216
+ }
217
+
133
218
  /** Deliver a finished background job into the conversation, once. */
134
219
  function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
135
220
  exit
136
221
  .then(() => {
222
+ // Handed off to a newer instance: only flush the final status to disk
223
+ // for it to read. Do not mark delivered, touch lastUiCtx, or send through
224
+ // this stale pi (which would throw and, worse, having already flipped
225
+ // delivered would stop the new instance from ever delivering).
226
+ if (handedOff) {
227
+ registry?.persist(job);
228
+ return;
229
+ }
137
230
  if (job.delivered) return;
138
231
  job.delivered = true;
139
232
  registry?.persist(job);
140
- renderWidget();
141
- pi.sendMessage(
142
- {
143
- customType: DELIVERY_TYPE,
144
- content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
145
- display: true,
146
- details: { id: job.id, status: job.status, exitCode: job.exitCode },
147
- },
148
- { deliverAs: "followUp", triggerTurn: true },
149
- );
233
+ deliver(job);
150
234
  })
151
235
  .catch(() => {
152
236
  // A /reload can make captured handles throw; delivery is a convenience,
@@ -165,12 +249,33 @@ export default function shellBackground(pi: ExtensionAPI) {
165
249
  const command = String(params.command ?? "").trim();
166
250
  if (!command) return { content: [{ type: "text", text: "Empty command." }], details: {}, isError: true };
167
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
+
168
273
  const job = registry.create(command, ctx.cwd);
169
274
  const { shell, args } = shellArgv();
170
275
 
171
276
  let spawned;
172
277
  try {
173
- spawned = spawnToFile(shell, args, command, ctx.cwd, process.env, job.logPath);
278
+ spawned = spawnToFile(shell, args, command, ctx.cwd, buildEnv(ctx, join(getAgentDir(), "bin")), job.logPath);
174
279
  } catch (err) {
175
280
  job.status = "failed";
176
281
  job.endedAt = Date.now();
@@ -189,6 +294,12 @@ export default function shellBackground(pi: ExtensionAPI) {
189
294
  job.exitCode = code;
190
295
  job.signal = sig;
191
296
  job.endedAt = Date.now();
297
+ // Handed off on /reload: flush the final status to the sidecar for the new
298
+ // instance to read, but do not deliver or touch the stale UI ctx here.
299
+ if (handedOff) {
300
+ registry?.persist(job);
301
+ return;
302
+ }
192
303
  registry?.persist(job);
193
304
  renderWidget();
194
305
  });
@@ -208,7 +319,7 @@ export default function shellBackground(pi: ExtensionAPI) {
208
319
 
209
320
  // Foreground: race the process against the auto-background threshold, an
210
321
  // optional timeout, and the turn's abort signal — streaming the tail.
211
- const autoMs = ctx.hasUI ? settings.autoBackgroundMs : 0;
322
+ const autoMs = effectiveAutoMs(ctx.hasUI);
212
323
  const timers: NodeJS.Timeout[] = [];
213
324
  const after = (ms: number, val: string) =>
214
325
  new Promise<string>((res) => {
@@ -227,13 +338,31 @@ export default function shellBackground(pi: ExtensionAPI) {
227
338
 
228
339
  try {
229
340
  const race: Array<Promise<string>> = [settle.then(() => "exit")];
230
- if (autoMs > 0) race.push(after(autoMs, "auto"));
231
341
  if (params.timeout && params.timeout > 0) race.push(after(params.timeout * 1000, "timeout"));
232
342
  if (signal) race.push(abort);
233
343
 
234
- const outcome = await Promise.race(race);
344
+ let outcome = await Promise.race(autoMs > 0 ? [...race, after(autoMs, "auto")] : race);
235
345
 
236
- if (outcome === "exit") return finished(job);
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
+ }
353
+
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
+ }
237
366
 
238
367
  if (outcome === "auto") {
239
368
  scheduleDelivery(job, settle);
@@ -264,7 +393,27 @@ export default function shellBackground(pi: ExtensionAPI) {
264
393
  // timeout or abort: stop the tree, let the record settle, report partial.
265
394
  job.killedByUs = true;
266
395
  killTree(job.pid);
267
- await Promise.race([settle, after(500, "gave-up")]);
396
+ // Give the tree time to actually die before reporting — up to killTree's
397
+ // own SIGKILL grace on a timeout, briefly on an abort so the turn ends.
398
+ const graceMs = outcome === "timeout" ? 3000 : 500;
399
+ await Promise.race([settle, after(graceMs, "gave-up")]);
400
+ if (outcome === "timeout" && job.status === "running") {
401
+ // Do not print "[killed]" under a still-"running" header: the signal is
402
+ // out but the process has not confirmed exit. Say so, and point at the
403
+ // status tool rather than claim a death we cannot see yet.
404
+ return {
405
+ content: [
406
+ {
407
+ type: "text",
408
+ text:
409
+ formatResult(job, settings.tailBytes) +
410
+ `\n\n[kill signal sent; process had not exited after 3s — shell_status ${job.id} to confirm]`,
411
+ },
412
+ ],
413
+ details: { id: job.id, status: job.status },
414
+ isError: true,
415
+ };
416
+ }
268
417
  const note =
269
418
  outcome === "timeout"
270
419
  ? `\n\n[killed: exceeded the ${params.timeout}s timeout]`
@@ -282,16 +431,31 @@ export default function shellBackground(pi: ExtensionAPI) {
282
431
 
283
432
  // ── tools & lifecycle ────────────────────────────────────────────────
284
433
 
285
- function registerBash(cwd: string): void {
434
+ function registerBash(cwd: string, hasUI: boolean): void {
286
435
  const original = createBashToolDefinition(cwd) as unknown as AnyTool;
436
+ const autoMs = effectiveAutoMs(hasUI);
437
+ // The guideline must match what runBash will actually do for this session:
438
+ // auto-background only happens interactively with a positive threshold.
439
+ const backgroundLine = !hasUI
440
+ ? `This is a headless run: commands run to completion unless you pass background:true, which returns an id immediately. Nothing is delivered after your turn, so collect a backgrounded command within the same turn with shell_status {id, wait: N} (it blocks until the command finishes or the wait elapses).`
441
+ : autoMs === 0
442
+ ? `Commands run to completion; pass background:true for anything long-running (a server, build, or watcher) to get an id back immediately, then collect or check it with shell_status.`
443
+ : `A command still running after ${Math.round(autoMs / 1000)}s is moved to the background and its result is delivered when it finishes; pass background:true to background a long task (a server, build, or watcher) immediately. Collect or check with shell_status.`;
287
444
  const guidelines = [
288
445
  ...(Array.isArray((original as { promptGuidelines?: unknown }).promptGuidelines)
289
446
  ? ((original as { promptGuidelines?: string[] }).promptGuidelines as string[])
290
447
  : []),
291
- `A command still running after ${Math.round(settings.autoBackgroundMs / 1000)}s is moved to the background and its result is delivered when it finishes; pass background:true to background a long task (a server, build, or watcher) immediately. Collect or check with shell_status.`,
448
+ backgroundLine,
292
449
  ];
293
450
  pi.registerTool({
294
451
  ...original,
452
+ // pi's description promises its own truncation ("last 2000 lines or
453
+ // 50KB … saved to a temp file"); this tool returns the last
454
+ // `tailBytes` of a log it keeps for the whole command, and says where.
455
+ description:
456
+ `Execute a shell command in the current working directory. Returns stdout and stderr, interleaved as they ` +
457
+ `arrived; a long output is shown as its last ${Math.round(settings.tailBytes / 1024)}KB with the path of the ` +
458
+ `full log. Optionally provide a timeout in seconds, or background:true to get an id back immediately.`,
295
459
  parameters: Type.Object({
296
460
  command: Type.String({ description: "Shell command to execute" }),
297
461
  timeout: Type.Optional(
@@ -315,11 +479,14 @@ export default function shellBackground(pi: ExtensionAPI) {
315
479
  label: "Background shell status",
316
480
  promptSnippet: "Check or collect a backgrounded command",
317
481
  description:
318
- "Report a background command by id (its status and output tail), or list all this session's background commands when given no id. Finished results survive until the session ends.",
482
+ "Report a background command by id (its status and output tail), or list all this session's background commands when given no id. Pass wait (seconds, 0–300) to block until a still-running command finishes or the wait elapses — useful in a headless run where nothing is delivered after the turn. Finished results survive until the session ends.",
319
483
  parameters: Type.Object({
320
484
  id: Type.Optional(Type.String({ description: "Job id, e.g. bg-1. Omit to list all." })),
485
+ wait: Type.Optional(
486
+ Type.Number({ description: "Seconds to block while the command is still running (clamped 0–300). Default 0 — return immediately." }),
487
+ ),
321
488
  }),
322
- async execute(_id: string, params: { id?: string }): Promise<ToolResult> {
489
+ async execute(_id: string, params: { id?: string; wait?: number }, sig?: AbortSignal): Promise<ToolResult> {
323
490
  if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true };
324
491
  const id = params.id?.trim();
325
492
  if (!id) return { content: [{ type: "text", text: formatList(registry.all()) }], details: {} };
@@ -328,6 +495,15 @@ export default function shellBackground(pi: ExtensionAPI) {
328
495
  const known = registry.all().map((j) => j.id).join(", ") || "(none)";
329
496
  return { content: [{ type: "text", text: `No job "${id}". Known: ${known}` }], details: {}, isError: true };
330
497
  }
498
+ // Optionally block until it finishes. A bounded poll of job.status (not an
499
+ // in-memory promise) so it works for adopted jobs too — those have no
500
+ // settle closure; their status is flipped by the adoption poller / a
501
+ // sibling instance persisting the sidecar. Cheap: status mutates in place.
502
+ const waitSec = Number.isFinite(params.wait) ? Math.min(300, Math.max(0, Math.floor(params.wait as number))) : 0;
503
+ if (waitSec > 0 && job.status === "running") {
504
+ const deadline = Date.now() + waitSec * 1000;
505
+ while (job.status === "running" && Date.now() < deadline && !sig?.aborted) await sleep(250);
506
+ }
331
507
  // Reading a finished job marks it collected so it will not also be
332
508
  // delivered unasked. A still-running poll must never do this: setting
333
509
  // delivered here would permanently cancel the promised auto-delivery.
@@ -381,19 +557,96 @@ export default function shellBackground(pi: ExtensionAPI) {
381
557
  },
382
558
  });
383
559
 
560
+ /**
561
+ * After load(), take over every job still running from a previous instance of
562
+ * this same host — a /reload survivor (a foreign/dead host's jobs were settled
563
+ * to orphaned by load() and are not here). Their settle/delivery closures went
564
+ * with the old instance, so poll the sidecar the old closure keeps flushing:
565
+ * when it flips to a finished status (or the pid is simply gone) copy that in,
566
+ * persist, re-render, and deliver through the live pi if it was not delivered.
567
+ * One unref'd interval for them all; it stops once none are still running.
568
+ */
569
+ function adoptRunning(ctx: UiContext): void {
570
+ if (!registry) return;
571
+ const adopted = registry.running();
572
+ if (adopted.length === 0) return;
573
+ lastUiCtx = ctx;
574
+ const timer = setInterval(() => {
575
+ if (!registry) {
576
+ clearInterval(timer);
577
+ return;
578
+ }
579
+ let anyRunning = false;
580
+ for (const job of adopted) {
581
+ if (job.status === "running") {
582
+ const disk = registry.readSidecar(job.id);
583
+ if (disk && disk.status !== "running") {
584
+ job.status = disk.status;
585
+ job.exitCode = disk.exitCode;
586
+ job.signal = disk.signal;
587
+ job.endedAt = disk.endedAt ?? Date.now();
588
+ } else if (!isAlive(job.pid)) {
589
+ // The old instance never flushed a final status (it crashed) but the
590
+ // process is gone: settle it here so it is not shown running forever.
591
+ job.status = job.killedByUs ? "killed" : "done";
592
+ job.endedAt = job.endedAt ?? Date.now();
593
+ }
594
+ }
595
+ if (job.status === "running") anyRunning = true;
596
+ else if (!job.delivered) {
597
+ job.delivered = true;
598
+ registry.persist(job);
599
+ deliver(job);
600
+ }
601
+ }
602
+ renderWidget(ctx);
603
+ if (!anyRunning) clearInterval(timer);
604
+ }, 1000);
605
+ timer.unref?.();
606
+ }
607
+
384
608
  pi.on("session_start", async (_event, ctx) => {
385
609
  const warnings = loadSettings(ctx.cwd);
386
- registry = new JobRegistry(join(tmpdir(), "pify-shell-bg", sessionKey(ctx.cwd)));
610
+ const key = sessionKey(ctx);
611
+ sweepOldDirs(key);
612
+ registry = new JobRegistry(join(ROOT, key));
387
613
  registry.load();
388
- registerBash(ctx.cwd);
614
+ registerBash(ctx.cwd, ctx.hasUI);
389
615
  renderWidget(ctx);
616
+ adoptRunning(ctx);
390
617
  if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`shell-background settings: ${warnings.join("; ")}`, "warning");
391
618
  });
392
619
 
393
- pi.on("session_shutdown", async (_event, ctx) => {
394
- // Background jobs are tied to the session; do not leave orphans running
395
- // after pi exits.
396
- if (registry) for (const job of registry.running()) killTree(job.pid);
620
+ pi.on("session_shutdown", async (event, ctx) => {
621
+ if (event.reason === "reload") {
622
+ // Same host process, a fresh instance is coming right after: hand the jobs
623
+ // off rather than kill them. This (old) instance's closures go quiet
624
+ // (handedOff) and the new instance adopts the still-running ones from the
625
+ // sidecars — that is what registry.ts and the README promise across /reload.
626
+ handedOff = true;
627
+ stopWidgetTimer();
628
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
629
+ return;
630
+ }
631
+ // quit / new / resume / fork: this session is ending for good. Kill each
632
+ // running tree and record it killed synchronously so a resumed/next reader
633
+ // never sees a dead job as running, then best-effort drop this session's dir.
634
+ if (registry) {
635
+ for (const job of registry.running()) {
636
+ job.killedByUs = true;
637
+ killTree(job.pid);
638
+ job.status = "killed";
639
+ job.endedAt = job.endedAt ?? Date.now();
640
+ registry.persist(job);
641
+ }
642
+ try {
643
+ rmSync(registry.dir(), { recursive: true, force: true });
644
+ } catch {
645
+ // Windows can hold the log write stream open (EBUSY); the 7-day age
646
+ // sweep on a later session_start collects whatever is left behind.
647
+ }
648
+ }
649
+ stopWidgetTimer();
397
650
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
398
651
  });
399
652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.1.2",
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/env.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The environment a backgrounded command is spawned with.
3
+ *
4
+ * pi's own bash does not hand the child a raw `process.env`: it starts from
5
+ * `getShellEnv()` (process.env with `<agentDir>/bin` — where pi auto-installs
6
+ * `fd`/`rg` — prepended to PATH) and, since exposeSessionEnvironment defaults on,
7
+ * sets PI_SESSION_ID / PI_SESSION_FILE / PI_PROVIDER / PI_MODEL /
8
+ * PI_REASONING_LEVEL from the session ctx. The pi host never sets those PI_*
9
+ * vars in its own process.env (it injects them only into its bash tool's child),
10
+ * so a command spawned with plain `process.env` sees none of them and cannot
11
+ * resolve the managed `fd`/`rg` — despite the inherited description/guidelines
12
+ * promising both. This rebuilds the same env so a backgrounded command behaves
13
+ * like a foreground one.
14
+ *
15
+ * Pure and pi-free (the binDir and a minimal ctx shape are passed in) so it unit
16
+ * tests standalone. Zero dependencies — node:path only.
17
+ */
18
+ import { delimiter } from "node:path";
19
+
20
+ /** The slice of the pi ExtensionContext this needs, kept structural for tests. */
21
+ export interface EnvCtx {
22
+ sessionManager?: { getSessionId?(): string; getSessionFile?(): string | undefined };
23
+ model?: { provider?: string; id?: string } | undefined;
24
+ thinkingLevel?: string | undefined;
25
+ }
26
+
27
+ /**
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.
31
+ */
32
+ export function buildEnv(
33
+ ctx: EnvCtx,
34
+ binDir: string,
35
+ base: NodeJS.ProcessEnv = process.env,
36
+ ): NodeJS.ProcessEnv {
37
+ const env: NodeJS.ProcessEnv = { ...base };
38
+
39
+ // PATH is case-insensitive on Windows; find the real key so we do not create a
40
+ // second, ignored "PATH" alongside an inherited "Path". Only prepend when the
41
+ // bin dir is not already on it (mirrors pi's getShellEnv hasBinDir check).
42
+ if (binDir) {
43
+ const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH";
44
+ const current = env[pathKey] ?? "";
45
+ const entries = current.split(delimiter).filter(Boolean);
46
+ if (!entries.includes(binDir)) {
47
+ env[pathKey] = [binDir, current].filter(Boolean).join(delimiter);
48
+ }
49
+ }
50
+
51
+ try {
52
+ const sid = ctx.sessionManager?.getSessionId?.();
53
+ if (sid) env.PI_SESSION_ID = sid;
54
+ const sessionFile = ctx.sessionManager?.getSessionFile?.();
55
+ if (sessionFile) env.PI_SESSION_FILE = sessionFile;
56
+ const model = ctx.model;
57
+ if (model) {
58
+ if (model.provider) env.PI_PROVIDER = model.provider;
59
+ if (model.id) env.PI_MODEL = model.id;
60
+ }
61
+ if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel;
62
+ } catch {
63
+ // Stale ctx (e.g. read after /reload): a plain env still runs the command.
64
+ }
65
+
66
+ return env;
67
+ }
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`;
@@ -21,11 +22,13 @@ export function header(job: Job): string {
21
22
  const verdict =
22
23
  job.status === "running"
23
24
  ? "running"
24
- : job.signal
25
- ? `signal ${job.signal}`
26
- : job.status === "killed"
27
- ? "killed"
28
- : `exit ${job.exitCode ?? "?"}`;
25
+ : job.status === "orphaned"
26
+ ? "orphaned (another session)"
27
+ : job.signal
28
+ ? `signal ${job.signal}`
29
+ : job.status === "killed"
30
+ ? "killed"
31
+ : `exit ${job.exitCode ?? "?"}`;
29
32
  return `[${job.id} · ${job.status} · ${verdict} · ${duration(job)}]`;
30
33
  }
31
34
 
@@ -36,14 +39,17 @@ export function header(job: Job): string {
36
39
  */
37
40
  export function formatResult(job: Job, tailBytes: number): string {
38
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);
39
45
  const lines = [header(job)];
40
- if (tail.text.trim() === "") {
46
+ if (text.trim() === "") {
41
47
  lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
42
48
  } else {
43
49
  if (tail.truncated) {
44
- 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}`);
45
51
  }
46
- lines.push(tail.text.replace(/\n+$/, ""));
52
+ lines.push(text.replace(/\n+$/, ""));
47
53
  }
48
54
  return lines.join("\n");
49
55
  }
package/src/pending.ts CHANGED
@@ -61,9 +61,9 @@ export function backgroundedResult(input: BackgroundedInput): BackgroundedResult
61
61
  "with no id lists everything still running.",
62
62
  ]
63
63
  : [
64
- "This is a headless run: nothing is delivered after your turn ends. Call",
65
- `${input.collectWith} with id "${input.id}" again in this same turn until it reports finished —`,
66
- "do not end your turn expecting the result to arrive on its own.",
64
+ "This is a headless run: nothing is delivered after your turn ends. Collect it",
65
+ `in this same turn — ${input.collectWith} {id: "${input.id}", wait: 60} blocks until it`,
66
+ "finishes (or the wait elapses); do not end your turn expecting the result to arrive on its own.",
67
67
  ];
68
68
  return {
69
69
  text: [head, line, "", ...tail].join("\n"),
package/src/registry.ts CHANGED
@@ -16,7 +16,7 @@ import { join } from "node:path";
16
16
  import type { Job } from "./types.ts";
17
17
  import { isRecord } from "./types.ts";
18
18
 
19
- function isAlive(pid: number | null): boolean {
19
+ export function isAlive(pid: number | null | undefined): boolean {
20
20
  if (!pid || pid <= 0) return false;
21
21
  try {
22
22
  process.kill(pid, 0);
@@ -47,6 +47,11 @@ export class JobRegistry {
47
47
  mkdirSync(join(baseDir, "logs"), { recursive: true });
48
48
  }
49
49
 
50
+ /** The directory this registry's sidecars and logs live under. */
51
+ dir(): string {
52
+ return this.baseDir;
53
+ }
54
+
50
55
  logPathFor(id: string): string {
51
56
  return join(this.baseDir, "logs", `${id}.log`);
52
57
  }
@@ -58,6 +63,7 @@ export class JobRegistry {
58
63
  command,
59
64
  cwd,
60
65
  pid: null,
66
+ hostPid: process.pid,
61
67
  status: "running",
62
68
  exitCode: null,
63
69
  signal: null,
@@ -94,7 +100,28 @@ export class JobRegistry {
94
100
  }
95
101
  }
96
102
 
97
- /** Load persisted jobs and settle any whose process has since died. */
103
+ /** Read one job's sidecar from disk, or null if missing/corrupt. */
104
+ readSidecar(id: string): Job | null {
105
+ try {
106
+ const raw = JSON.parse(readFileSync(join(this.baseDir, `${id}.json`), "utf8"));
107
+ return isJob(raw) ? raw : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Load persisted jobs and reconcile any still marked running:
115
+ *
116
+ * - a record written by this same host process (a `/reload` keeps the host
117
+ * pid) is a genuine survivor — kept running if its pid is still alive so the
118
+ * new instance can adopt it, settled to `done` if the process has since died;
119
+ * - a record from any other host pid (another session that reused this dir, or
120
+ * a crashed one, or a pre-upgrade sidecar with no hostPid) is *not* ours: its
121
+ * pid may since belong to something unrelated, so we never treat it as
122
+ * running (which would make session_shutdown kill a stranger's pid) — it is
123
+ * surfaced as `orphaned` and kept out of running().
124
+ */
98
125
  load(): void {
99
126
  let files: string[];
100
127
  try {
@@ -108,9 +135,14 @@ export class JobRegistry {
108
135
  const raw = JSON.parse(readFileSync(join(this.baseDir, f), "utf8"));
109
136
  if (!isJob(raw)) continue;
110
137
  const job = raw;
111
- if (job.status === "running" && !isAlive(job.pid)) {
112
- job.status = "done";
113
- job.endedAt = job.endedAt ?? Date.now();
138
+ if (job.status === "running") {
139
+ if (job.hostPid !== process.pid) {
140
+ job.status = "orphaned";
141
+ job.endedAt = job.endedAt ?? Date.now();
142
+ } else if (!isAlive(job.pid)) {
143
+ job.status = "done";
144
+ job.endedAt = job.endedAt ?? Date.now();
145
+ }
114
146
  }
115
147
  this.jobs.set(job.id, job);
116
148
  const n = Number(job.id.replace(/^bg-/, ""));
@@ -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
+ }
package/src/types.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * No imports from pi packages: src/ typechecks and unit-tests standalone.
4
4
  */
5
5
 
6
- export type JobStatus = "running" | "done" | "failed" | "killed";
6
+ export type JobStatus = "running" | "done" | "failed" | "killed" | "orphaned";
7
7
 
8
8
  export interface Job {
9
9
  /** Short session-monotonic id, e.g. "bg-1". */
@@ -12,6 +12,14 @@ export interface Job {
12
12
  cwd: string;
13
13
  /** OS pid of the shell process; null before spawn or if spawn failed. */
14
14
  pid: number | null;
15
+ /**
16
+ * pid of the pi host that spawned this job. A record whose hostPid is not the
17
+ * current process was written by another (or a since-crashed) host: its pid is
18
+ * not ours to signal, so it is never treated as running here. A `/reload`
19
+ * keeps the same host pid, so genuine reload survivors still adopt. Optional
20
+ * so a pre-upgrade sidecar (no hostPid) is simply treated as foreign.
21
+ */
22
+ hostPid?: number;
15
23
  status: JobStatus;
16
24
  /** Process exit code, once finished. */
17
25
  exitCode: number | null;