@pify/shell-background 0.1.2 → 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,12 +37,13 @@ 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";
@@ -60,13 +61,67 @@ export default function shellBackground(pi: ExtensionAPI) {
60
61
  let settings: ShellBgSettings = DEFAULT_SETTINGS;
61
62
  let registry: JobRegistry | null = null;
62
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);
63
86
 
64
87
  // ── setup ──────────────────────────────────────────────────────────
65
88
 
66
- function sessionKey(cwd: string): string {
67
- 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
+ }
68
102
  if (id) return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
69
- 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
+ }
70
125
  }
71
126
 
72
127
  function loadSettings(cwd: string): string[] {
@@ -100,6 +155,13 @@ export default function shellBackground(pi: ExtensionAPI) {
100
155
  return { shell: cfg.shell, args };
101
156
  }
102
157
 
158
+ function stopWidgetTimer(): void {
159
+ if (widgetTimer) {
160
+ clearInterval(widgetTimer);
161
+ widgetTimer = null;
162
+ }
163
+ }
164
+
103
165
  function renderWidget(ctx: UiContext | null = lastUiCtx): void {
104
166
  if (!ctx || !ctx.hasUI || !registry) return;
105
167
  lastUiCtx = ctx;
@@ -107,9 +169,17 @@ export default function shellBackground(pi: ExtensionAPI) {
107
169
  const lines = buildWidgetLines(registry.all(), ctx.ui.theme as never, now);
108
170
  if (lines.length === 0) {
109
171
  ctx.ui.setWidget(WIDGET, undefined);
172
+ stopWidgetTimer();
110
173
  return;
111
174
  }
112
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
+ }
113
183
  }
114
184
 
115
185
  // ── run ────────────────────────────────────────────────────────────
@@ -130,23 +200,36 @@ export default function shellBackground(pi: ExtensionAPI) {
130
200
  };
131
201
  }
132
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
+
133
217
  /** Deliver a finished background job into the conversation, once. */
134
218
  function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
135
219
  exit
136
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
+ }
137
229
  if (job.delivered) return;
138
230
  job.delivered = true;
139
231
  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
- );
232
+ deliver(job);
150
233
  })
151
234
  .catch(() => {
152
235
  // A /reload can make captured handles throw; delivery is a convenience,
@@ -170,7 +253,7 @@ export default function shellBackground(pi: ExtensionAPI) {
170
253
 
171
254
  let spawned;
172
255
  try {
173
- 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);
174
257
  } catch (err) {
175
258
  job.status = "failed";
176
259
  job.endedAt = Date.now();
@@ -189,6 +272,12 @@ export default function shellBackground(pi: ExtensionAPI) {
189
272
  job.exitCode = code;
190
273
  job.signal = sig;
191
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
+ }
192
281
  registry?.persist(job);
193
282
  renderWidget();
194
283
  });
@@ -208,7 +297,7 @@ export default function shellBackground(pi: ExtensionAPI) {
208
297
 
209
298
  // Foreground: race the process against the auto-background threshold, an
210
299
  // optional timeout, and the turn's abort signal — streaming the tail.
211
- const autoMs = ctx.hasUI ? settings.autoBackgroundMs : 0;
300
+ const autoMs = effectiveAutoMs(ctx.hasUI);
212
301
  const timers: NodeJS.Timeout[] = [];
213
302
  const after = (ms: number, val: string) =>
214
303
  new Promise<string>((res) => {
@@ -264,7 +353,27 @@ export default function shellBackground(pi: ExtensionAPI) {
264
353
  // timeout or abort: stop the tree, let the record settle, report partial.
265
354
  job.killedByUs = true;
266
355
  killTree(job.pid);
267
- 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
+ }
268
377
  const note =
269
378
  outcome === "timeout"
270
379
  ? `\n\n[killed: exceeded the ${params.timeout}s timeout]`
@@ -282,16 +391,31 @@ export default function shellBackground(pi: ExtensionAPI) {
282
391
 
283
392
  // ── tools & lifecycle ────────────────────────────────────────────────
284
393
 
285
- function registerBash(cwd: string): void {
394
+ function registerBash(cwd: string, hasUI: boolean): void {
286
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.`;
287
404
  const guidelines = [
288
405
  ...(Array.isArray((original as { promptGuidelines?: unknown }).promptGuidelines)
289
406
  ? ((original as { promptGuidelines?: string[] }).promptGuidelines as string[])
290
407
  : []),
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.`,
408
+ backgroundLine,
292
409
  ];
293
410
  pi.registerTool({
294
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.`,
295
419
  parameters: Type.Object({
296
420
  command: Type.String({ description: "Shell command to execute" }),
297
421
  timeout: Type.Optional(
@@ -315,11 +439,14 @@ export default function shellBackground(pi: ExtensionAPI) {
315
439
  label: "Background shell status",
316
440
  promptSnippet: "Check or collect a backgrounded command",
317
441
  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.",
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.",
319
443
  parameters: Type.Object({
320
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
+ ),
321
448
  }),
322
- async execute(_id: string, params: { id?: string }): Promise<ToolResult> {
449
+ async execute(_id: string, params: { id?: string; wait?: number }, sig?: AbortSignal): Promise<ToolResult> {
323
450
  if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true };
324
451
  const id = params.id?.trim();
325
452
  if (!id) return { content: [{ type: "text", text: formatList(registry.all()) }], details: {} };
@@ -328,6 +455,15 @@ export default function shellBackground(pi: ExtensionAPI) {
328
455
  const known = registry.all().map((j) => j.id).join(", ") || "(none)";
329
456
  return { content: [{ type: "text", text: `No job "${id}". Known: ${known}` }], details: {}, isError: true };
330
457
  }
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
+ }
331
467
  // Reading a finished job marks it collected so it will not also be
332
468
  // delivered unasked. A still-running poll must never do this: setting
333
469
  // delivered here would permanently cancel the promised auto-delivery.
@@ -381,19 +517,96 @@ export default function shellBackground(pi: ExtensionAPI) {
381
517
  },
382
518
  });
383
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
+
384
568
  pi.on("session_start", async (_event, ctx) => {
385
569
  const warnings = loadSettings(ctx.cwd);
386
- 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));
387
573
  registry.load();
388
- registerBash(ctx.cwd);
574
+ registerBash(ctx.cwd, ctx.hasUI);
389
575
  renderWidget(ctx);
576
+ adoptRunning(ctx);
390
577
  if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`shell-background settings: ${warnings.join("; ")}`, "warning");
391
578
  });
392
579
 
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);
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();
397
610
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
398
611
  });
399
612
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.1.2",
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/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;