@pify/shell-background 0.1.1 → 0.2.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
 
@@ -37,16 +37,18 @@ 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";
49
50
  import { buildWidgetLines } from "../src/widget.ts";
51
+ import { isFinished } from "../src/types.ts";
50
52
  import type { Job } from "../src/types.ts";
51
53
 
52
54
  type UiContext = ExtensionContext;
@@ -59,13 +61,67 @@ export default function shellBackground(pi: ExtensionAPI) {
59
61
  let settings: ShellBgSettings = DEFAULT_SETTINGS;
60
62
  let registry: JobRegistry | null = null;
61
63
  let lastUiCtx: UiContext | null = null;
64
+ let widgetTimer: NodeJS.Timeout | null = null;
65
+ // Set on a /reload shutdown: this (old) instance's processes are being handed
66
+ // to the next instance in the same host process, so its settle/delivery
67
+ // closures must go quiet — flush status to disk, but never kill and never
68
+ // deliver through the now-stale pi handle. Each instance has its own copy
69
+ // (reload builds a fresh closure), so this only ever flips once, on the way out.
70
+ let handedOff = false;
71
+
72
+ const ROOT = join(tmpdir(), "pify-shell-bg");
73
+ const MAX_SESSION_DIR_AGE_MS = 7 * 24 * 60 * 60 * 1000;
74
+
75
+ // Ref'd on purpose: this backs the shell_status `wait` poll, which is awaited
76
+ // inside an in-flight tool call, so the timer must actually resolve rather than
77
+ // let an otherwise-idle loop exit out from under it.
78
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
79
+
80
+ /**
81
+ * The one auto-background threshold, computed once so the bash guideline and
82
+ * runBash cannot drift: auto-background needs a session that outlives the run
83
+ * to deliver into, which a headless `pi -p` does not have, so it is off there.
84
+ */
85
+ const effectiveAutoMs = (hasUI: boolean): number => (hasUI ? settings.autoBackgroundMs : 0);
62
86
 
63
87
  // ── setup ──────────────────────────────────────────────────────────
64
88
 
65
- function sessionKey(cwd: string): string {
66
- const id = process.env.PI_SESSION_ID;
89
+ /**
90
+ * A stable per-session key: the session id survives a /reload (so adopted
91
+ * jobs are found again) and differs across /new and /resume (so sessions never
92
+ * reconcile or kill each other's jobs). Falls back to a cwd hash only when no
93
+ * session manager is present (e.g. the unit harness).
94
+ */
95
+ function sessionKey(ctx: UiContext): string {
96
+ let id: string | undefined;
97
+ try {
98
+ id = ctx.sessionManager?.getSessionId?.();
99
+ } catch {
100
+ // ignore — fall through to the cwd hash
101
+ }
67
102
  if (id) return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
68
- return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
103
+ return createHash("sha256").update(ctx.cwd).digest("hex").slice(0, 16);
104
+ }
105
+
106
+ /** Remove sibling session dirs untouched for a week (a crash leaves the dir). */
107
+ function sweepOldDirs(keep: string): void {
108
+ let names: string[];
109
+ try {
110
+ names = readdirSync(ROOT);
111
+ } catch {
112
+ return;
113
+ }
114
+ const cutoff = Date.now() - MAX_SESSION_DIR_AGE_MS;
115
+ for (const name of names) {
116
+ if (name === keep) continue; // never sweep the session we are starting
117
+ const p = join(ROOT, name);
118
+ try {
119
+ const st = statSync(p);
120
+ if (st.isDirectory() && st.mtimeMs < cutoff) rmSync(p, { recursive: true, force: true });
121
+ } catch {
122
+ // A dir we cannot stat or remove is not worth failing startup over.
123
+ }
124
+ }
69
125
  }
70
126
 
71
127
  function loadSettings(cwd: string): string[] {
@@ -99,6 +155,13 @@ export default function shellBackground(pi: ExtensionAPI) {
99
155
  return { shell: cfg.shell, args };
100
156
  }
101
157
 
158
+ function stopWidgetTimer(): void {
159
+ if (widgetTimer) {
160
+ clearInterval(widgetTimer);
161
+ widgetTimer = null;
162
+ }
163
+ }
164
+
102
165
  function renderWidget(ctx: UiContext | null = lastUiCtx): void {
103
166
  if (!ctx || !ctx.hasUI || !registry) return;
104
167
  lastUiCtx = ctx;
@@ -106,9 +169,17 @@ export default function shellBackground(pi: ExtensionAPI) {
106
169
  const lines = buildWidgetLines(registry.all(), ctx.ui.theme as never, now);
107
170
  if (lines.length === 0) {
108
171
  ctx.ui.setWidget(WIDGET, undefined);
172
+ stopWidgetTimer();
109
173
  return;
110
174
  }
111
175
  ctx.ui.setWidget(WIDGET, (_tui: unknown) => new Text(lines.join("\n"), 0, 0), { placement: "aboveEditor" });
176
+ // Keep the box live between events: tick the elapsed clock while jobs run and
177
+ // clear a finished job once it falls out of its 15s window, even if nothing
178
+ // else fires a render. Unref'd, so a lingering box never holds the host open.
179
+ if (!widgetTimer) {
180
+ widgetTimer = setInterval(() => renderWidget(), 1000);
181
+ widgetTimer.unref?.();
182
+ }
112
183
  }
113
184
 
114
185
  // ── run ────────────────────────────────────────────────────────────
@@ -129,23 +200,36 @@ export default function shellBackground(pi: ExtensionAPI) {
129
200
  };
130
201
  }
131
202
 
203
+ /** Push a finished job's result into the conversation through the live pi. */
204
+ function deliver(job: Job): void {
205
+ renderWidget();
206
+ pi.sendMessage(
207
+ {
208
+ customType: DELIVERY_TYPE,
209
+ content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
210
+ display: true,
211
+ details: { id: job.id, status: job.status, exitCode: job.exitCode },
212
+ },
213
+ { deliverAs: "followUp", triggerTurn: true },
214
+ );
215
+ }
216
+
132
217
  /** Deliver a finished background job into the conversation, once. */
133
218
  function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
134
219
  exit
135
220
  .then(() => {
221
+ // Handed off to a newer instance: only flush the final status to disk
222
+ // for it to read. Do not mark delivered, touch lastUiCtx, or send through
223
+ // this stale pi (which would throw and, worse, having already flipped
224
+ // delivered would stop the new instance from ever delivering).
225
+ if (handedOff) {
226
+ registry?.persist(job);
227
+ return;
228
+ }
136
229
  if (job.delivered) return;
137
230
  job.delivered = true;
138
231
  registry?.persist(job);
139
- renderWidget();
140
- pi.sendMessage(
141
- {
142
- customType: DELIVERY_TYPE,
143
- content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
144
- display: true,
145
- details: { id: job.id, status: job.status, exitCode: job.exitCode },
146
- },
147
- { deliverAs: "followUp", triggerTurn: true },
148
- );
232
+ deliver(job);
149
233
  })
150
234
  .catch(() => {
151
235
  // A /reload can make captured handles throw; delivery is a convenience,
@@ -169,7 +253,7 @@ export default function shellBackground(pi: ExtensionAPI) {
169
253
 
170
254
  let spawned;
171
255
  try {
172
- spawned = spawnToFile(shell, args, command, ctx.cwd, process.env, job.logPath);
256
+ spawned = spawnToFile(shell, args, command, ctx.cwd, buildEnv(ctx, join(getAgentDir(), "bin")), job.logPath);
173
257
  } catch (err) {
174
258
  job.status = "failed";
175
259
  job.endedAt = Date.now();
@@ -188,6 +272,12 @@ export default function shellBackground(pi: ExtensionAPI) {
188
272
  job.exitCode = code;
189
273
  job.signal = sig;
190
274
  job.endedAt = Date.now();
275
+ // Handed off on /reload: flush the final status to the sidecar for the new
276
+ // instance to read, but do not deliver or touch the stale UI ctx here.
277
+ if (handedOff) {
278
+ registry?.persist(job);
279
+ return;
280
+ }
191
281
  registry?.persist(job);
192
282
  renderWidget();
193
283
  });
@@ -207,7 +297,7 @@ export default function shellBackground(pi: ExtensionAPI) {
207
297
 
208
298
  // Foreground: race the process against the auto-background threshold, an
209
299
  // optional timeout, and the turn's abort signal — streaming the tail.
210
- const autoMs = ctx.hasUI ? settings.autoBackgroundMs : 0;
300
+ const autoMs = effectiveAutoMs(ctx.hasUI);
211
301
  const timers: NodeJS.Timeout[] = [];
212
302
  const after = (ms: number, val: string) =>
213
303
  new Promise<string>((res) => {
@@ -236,6 +326,19 @@ export default function shellBackground(pi: ExtensionAPI) {
236
326
 
237
327
  if (outcome === "auto") {
238
328
  scheduleDelivery(job, settle);
329
+ // A timeout the caller set still applies once the command is in the
330
+ // background: schedule the kill for the time it has left so the deadline
331
+ // the model expects is honoured rather than silently dropped. This timer
332
+ // deliberately outlives the `finally` below, so it is not in `timers`.
333
+ if (params.timeout && params.timeout > 0) {
334
+ const remaining = params.timeout * 1000 - (Date.now() - job.startedAt);
335
+ const killAt = setTimeout(() => {
336
+ if (job.status !== "running") return;
337
+ job.killedByUs = true;
338
+ killTree(job.pid);
339
+ }, Math.max(0, remaining));
340
+ killAt.unref?.();
341
+ }
239
342
  const r = backgroundedResult({
240
343
  id: job.id,
241
344
  command,
@@ -250,7 +353,27 @@ export default function shellBackground(pi: ExtensionAPI) {
250
353
  // timeout or abort: stop the tree, let the record settle, report partial.
251
354
  job.killedByUs = true;
252
355
  killTree(job.pid);
253
- await Promise.race([settle, after(500, "gave-up")]);
356
+ // Give the tree time to actually die before reporting — up to killTree's
357
+ // own SIGKILL grace on a timeout, briefly on an abort so the turn ends.
358
+ const graceMs = outcome === "timeout" ? 3000 : 500;
359
+ await Promise.race([settle, after(graceMs, "gave-up")]);
360
+ if (outcome === "timeout" && job.status === "running") {
361
+ // Do not print "[killed]" under a still-"running" header: the signal is
362
+ // out but the process has not confirmed exit. Say so, and point at the
363
+ // status tool rather than claim a death we cannot see yet.
364
+ return {
365
+ content: [
366
+ {
367
+ type: "text",
368
+ text:
369
+ formatResult(job, settings.tailBytes) +
370
+ `\n\n[kill signal sent; process had not exited after 3s — shell_status ${job.id} to confirm]`,
371
+ },
372
+ ],
373
+ details: { id: job.id, status: job.status },
374
+ isError: true,
375
+ };
376
+ }
254
377
  const note =
255
378
  outcome === "timeout"
256
379
  ? `\n\n[killed: exceeded the ${params.timeout}s timeout]`
@@ -268,16 +391,31 @@ export default function shellBackground(pi: ExtensionAPI) {
268
391
 
269
392
  // ── tools & lifecycle ────────────────────────────────────────────────
270
393
 
271
- function registerBash(cwd: string): void {
394
+ function registerBash(cwd: string, hasUI: boolean): void {
272
395
  const original = createBashToolDefinition(cwd) as unknown as AnyTool;
396
+ const autoMs = effectiveAutoMs(hasUI);
397
+ // The guideline must match what runBash will actually do for this session:
398
+ // auto-background only happens interactively with a positive threshold.
399
+ const backgroundLine = !hasUI
400
+ ? `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).`
401
+ : autoMs === 0
402
+ ? `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.`
403
+ : `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.`;
273
404
  const guidelines = [
274
405
  ...(Array.isArray((original as { promptGuidelines?: unknown }).promptGuidelines)
275
406
  ? ((original as { promptGuidelines?: string[] }).promptGuidelines as string[])
276
407
  : []),
277
- `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.`,
408
+ backgroundLine,
278
409
  ];
279
410
  pi.registerTool({
280
411
  ...original,
412
+ // pi's description promises its own truncation ("last 2000 lines or
413
+ // 50KB … saved to a temp file"); this tool returns the last
414
+ // `tailBytes` of a log it keeps for the whole command, and says where.
415
+ description:
416
+ `Execute a shell command in the current working directory. Returns stdout and stderr, interleaved as they ` +
417
+ `arrived; a long output is shown as its last ${Math.round(settings.tailBytes / 1024)}KB with the path of the ` +
418
+ `full log. Optionally provide a timeout in seconds, or background:true to get an id back immediately.`,
281
419
  parameters: Type.Object({
282
420
  command: Type.String({ description: "Shell command to execute" }),
283
421
  timeout: Type.Optional(
@@ -301,11 +439,14 @@ export default function shellBackground(pi: ExtensionAPI) {
301
439
  label: "Background shell status",
302
440
  promptSnippet: "Check or collect a backgrounded command",
303
441
  description:
304
- "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.",
442
+ "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.",
305
443
  parameters: Type.Object({
306
444
  id: Type.Optional(Type.String({ description: "Job id, e.g. bg-1. Omit to list all." })),
445
+ wait: Type.Optional(
446
+ Type.Number({ description: "Seconds to block while the command is still running (clamped 0–300). Default 0 — return immediately." }),
447
+ ),
307
448
  }),
308
- async execute(_id: string, params: { id?: string }): Promise<ToolResult> {
449
+ async execute(_id: string, params: { id?: string; wait?: number }, sig?: AbortSignal): Promise<ToolResult> {
309
450
  if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true };
310
451
  const id = params.id?.trim();
311
452
  if (!id) return { content: [{ type: "text", text: formatList(registry.all()) }], details: {} };
@@ -314,8 +455,22 @@ export default function shellBackground(pi: ExtensionAPI) {
314
455
  const known = registry.all().map((j) => j.id).join(", ") || "(none)";
315
456
  return { content: [{ type: "text", text: `No job "${id}". Known: ${known}` }], details: {}, isError: true };
316
457
  }
317
- // Reading marks it collected so it will not also be delivered unasked.
318
- job.delivered = true;
458
+ // Optionally block until it finishes. A bounded poll of job.status (not an
459
+ // in-memory promise) so it works for adopted jobs too — those have no
460
+ // settle closure; their status is flipped by the adoption poller / a
461
+ // sibling instance persisting the sidecar. Cheap: status mutates in place.
462
+ const waitSec = Number.isFinite(params.wait) ? Math.min(300, Math.max(0, Math.floor(params.wait as number))) : 0;
463
+ if (waitSec > 0 && job.status === "running") {
464
+ const deadline = Date.now() + waitSec * 1000;
465
+ while (job.status === "running" && Date.now() < deadline && !sig?.aborted) await sleep(250);
466
+ }
467
+ // Reading a finished job marks it collected so it will not also be
468
+ // delivered unasked. A still-running poll must never do this: setting
469
+ // delivered here would permanently cancel the promised auto-delivery.
470
+ if (isFinished(job)) {
471
+ job.delivered = true;
472
+ registry.persist(job);
473
+ }
319
474
  return {
320
475
  content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
321
476
  details: { id: job.id, status: job.status, exitCode: job.exitCode },
@@ -362,19 +517,96 @@ export default function shellBackground(pi: ExtensionAPI) {
362
517
  },
363
518
  });
364
519
 
520
+ /**
521
+ * After load(), take over every job still running from a previous instance of
522
+ * this same host — a /reload survivor (a foreign/dead host's jobs were settled
523
+ * to orphaned by load() and are not here). Their settle/delivery closures went
524
+ * with the old instance, so poll the sidecar the old closure keeps flushing:
525
+ * when it flips to a finished status (or the pid is simply gone) copy that in,
526
+ * persist, re-render, and deliver through the live pi if it was not delivered.
527
+ * One unref'd interval for them all; it stops once none are still running.
528
+ */
529
+ function adoptRunning(ctx: UiContext): void {
530
+ if (!registry) return;
531
+ const adopted = registry.running();
532
+ if (adopted.length === 0) return;
533
+ lastUiCtx = ctx;
534
+ const timer = setInterval(() => {
535
+ if (!registry) {
536
+ clearInterval(timer);
537
+ return;
538
+ }
539
+ let anyRunning = false;
540
+ for (const job of adopted) {
541
+ if (job.status === "running") {
542
+ const disk = registry.readSidecar(job.id);
543
+ if (disk && disk.status !== "running") {
544
+ job.status = disk.status;
545
+ job.exitCode = disk.exitCode;
546
+ job.signal = disk.signal;
547
+ job.endedAt = disk.endedAt ?? Date.now();
548
+ } else if (!isAlive(job.pid)) {
549
+ // The old instance never flushed a final status (it crashed) but the
550
+ // process is gone: settle it here so it is not shown running forever.
551
+ job.status = job.killedByUs ? "killed" : "done";
552
+ job.endedAt = job.endedAt ?? Date.now();
553
+ }
554
+ }
555
+ if (job.status === "running") anyRunning = true;
556
+ else if (!job.delivered) {
557
+ job.delivered = true;
558
+ registry.persist(job);
559
+ deliver(job);
560
+ }
561
+ }
562
+ renderWidget(ctx);
563
+ if (!anyRunning) clearInterval(timer);
564
+ }, 1000);
565
+ timer.unref?.();
566
+ }
567
+
365
568
  pi.on("session_start", async (_event, ctx) => {
366
569
  const warnings = loadSettings(ctx.cwd);
367
- registry = new JobRegistry(join(tmpdir(), "pify-shell-bg", sessionKey(ctx.cwd)));
570
+ const key = sessionKey(ctx);
571
+ sweepOldDirs(key);
572
+ registry = new JobRegistry(join(ROOT, key));
368
573
  registry.load();
369
- registerBash(ctx.cwd);
574
+ registerBash(ctx.cwd, ctx.hasUI);
370
575
  renderWidget(ctx);
576
+ adoptRunning(ctx);
371
577
  if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`shell-background settings: ${warnings.join("; ")}`, "warning");
372
578
  });
373
579
 
374
- pi.on("session_shutdown", async (_event, ctx) => {
375
- // Background jobs are tied to the session; do not leave orphans running
376
- // after pi exits.
377
- if (registry) for (const job of registry.running()) killTree(job.pid);
580
+ pi.on("session_shutdown", async (event, ctx) => {
581
+ if (event.reason === "reload") {
582
+ // Same host process, a fresh instance is coming right after: hand the jobs
583
+ // off rather than kill them. This (old) instance's closures go quiet
584
+ // (handedOff) and the new instance adopts the still-running ones from the
585
+ // sidecars — that is what registry.ts and the README promise across /reload.
586
+ handedOff = true;
587
+ stopWidgetTimer();
588
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
589
+ return;
590
+ }
591
+ // quit / new / resume / fork: this session is ending for good. Kill each
592
+ // running tree and record it killed synchronously so a resumed/next reader
593
+ // never sees a dead job as running, then best-effort drop this session's dir.
594
+ if (registry) {
595
+ for (const job of registry.running()) {
596
+ job.killedByUs = true;
597
+ killTree(job.pid);
598
+ job.status = "killed";
599
+ job.endedAt = job.endedAt ?? Date.now();
600
+ registry.persist(job);
601
+ }
602
+ try {
603
+ rmSync(registry.dir(), { recursive: true, force: true });
604
+ } catch {
605
+ // Windows can hold the log write stream open (EBUSY); the 7-day age
606
+ // sweep on a later session_start collects whatever is left behind.
607
+ }
608
+ }
609
+ stopWidgetTimer();
378
610
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
379
611
  });
380
612
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
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
@@ -21,11 +21,13 @@ export function header(job: Job): string {
21
21
  const verdict =
22
22
  job.status === "running"
23
23
  ? "running"
24
- : job.signal
25
- ? `signal ${job.signal}`
26
- : job.status === "killed"
27
- ? "killed"
28
- : `exit ${job.exitCode ?? "?"}`;
24
+ : job.status === "orphaned"
25
+ ? "orphaned (another session)"
26
+ : job.signal
27
+ ? `signal ${job.signal}`
28
+ : job.status === "killed"
29
+ ? "killed"
30
+ : `exit ${job.exitCode ?? "?"}`;
29
31
  return `[${job.id} · ${job.status} · ${verdict} · ${duration(job)}]`;
30
32
  }
31
33
 
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-/, ""));
package/src/spawn.ts CHANGED
@@ -41,6 +41,11 @@ export function spawnToFile(
41
41
  ): Spawned {
42
42
  // Append so a re-attach or racing read never clips output already written.
43
43
  const out = createWriteStream(logPath, { flags: "a" });
44
+ // A write stream with no 'error' listener turns any disk error (ENOSPC,
45
+ // EACCES) or a stray write-after-end into an uncaught exception that takes the
46
+ // whole pi host down. Losing a log line is survivable; crashing the host is
47
+ // not — so swallow it here.
48
+ out.on("error", () => {});
44
49
 
45
50
  let child;
46
51
  try {
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;