@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 +6 -3
- package/extensions/shell-background.ts +262 -30
- package/package.json +1 -1
- package/src/env.ts +67 -0
- package/src/format.ts +7 -5
- package/src/pending.ts +3 -3
- package/src/registry.ts +37 -5
- package/src/spawn.ts +5 -0
- package/src/types.ts +9 -1
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
|
|
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`
|
|
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)
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
318
|
-
|
|
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
|
-
|
|
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 (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
|
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.
|
|
25
|
-
?
|
|
26
|
-
: job.
|
|
27
|
-
?
|
|
28
|
-
:
|
|
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.
|
|
65
|
-
|
|
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
|
-
/**
|
|
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"
|
|
112
|
-
job.
|
|
113
|
-
|
|
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;
|