@pify/shell-background 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/extensions/shell-background.ts +285 -32
- package/package.json +4 -4
- package/src/config.ts +11 -0
- package/src/env.ts +67 -0
- package/src/format.ts +14 -8
- package/src/pending.ts +3 -3
- package/src/registry.ts +37 -5
- package/src/sanitize.ts +50 -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
|
|
|
@@ -50,11 +53,14 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
|
|
|
50
53
|
```json
|
|
51
54
|
{
|
|
52
55
|
"autoBackgroundMs": 30000,
|
|
53
|
-
"tailBytes": 65536
|
|
56
|
+
"tailBytes": 65536,
|
|
57
|
+
"maxBackground": 8
|
|
54
58
|
}
|
|
55
59
|
```
|
|
56
60
|
|
|
57
|
-
`autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result shows. Bad values fall back to the defaults with a warning rather than taking the tool down.
|
|
61
|
+
`autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result shows. `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. Bad values fall back to the defaults with a warning rather than taking the tool down.
|
|
62
|
+
|
|
63
|
+
The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes, control characters and carriage-return progress frames stripped — so a chatty build or dev server does not spend tokens on colour codes and thousands of overwritten progress lines. The log file on disk keeps every byte.
|
|
58
64
|
|
|
59
65
|
## Coexistence with @pify/pretty
|
|
60
66
|
|
|
@@ -37,15 +37,17 @@ import { Type } from "typebox";
|
|
|
37
37
|
import { tmpdir } from "node:os";
|
|
38
38
|
import { join } from "node:path";
|
|
39
39
|
import { createHash } from "node:crypto";
|
|
40
|
-
import { readFileSync } from "node:fs";
|
|
40
|
+
import { readFileSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
41
41
|
|
|
42
|
-
import { JobRegistry } from "../src/registry.ts";
|
|
42
|
+
import { JobRegistry, isAlive } from "../src/registry.ts";
|
|
43
43
|
import { spawnToFile } from "../src/spawn.ts";
|
|
44
44
|
import { killTree } from "../src/kill.ts";
|
|
45
45
|
import { readTail } from "../src/tail.ts";
|
|
46
|
+
import { buildEnv } from "../src/env.ts";
|
|
46
47
|
import { DEFAULT_SETTINGS, resolveSettings, type ShellBgSettings } from "../src/config.ts";
|
|
47
48
|
import { backgroundedResult, deliveryMessage, DELIVERY_TYPE } from "../src/pending.ts";
|
|
48
49
|
import { formatResult, formatList, header } from "../src/format.ts";
|
|
50
|
+
import { sanitizeOutput } from "../src/sanitize.ts";
|
|
49
51
|
import { buildWidgetLines } from "../src/widget.ts";
|
|
50
52
|
import { isFinished } from "../src/types.ts";
|
|
51
53
|
import type { Job } from "../src/types.ts";
|
|
@@ -60,13 +62,67 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
60
62
|
let settings: ShellBgSettings = DEFAULT_SETTINGS;
|
|
61
63
|
let registry: JobRegistry | null = null;
|
|
62
64
|
let lastUiCtx: UiContext | null = null;
|
|
65
|
+
let widgetTimer: NodeJS.Timeout | null = null;
|
|
66
|
+
// Set on a /reload shutdown: this (old) instance's processes are being handed
|
|
67
|
+
// to the next instance in the same host process, so its settle/delivery
|
|
68
|
+
// closures must go quiet — flush status to disk, but never kill and never
|
|
69
|
+
// deliver through the now-stale pi handle. Each instance has its own copy
|
|
70
|
+
// (reload builds a fresh closure), so this only ever flips once, on the way out.
|
|
71
|
+
let handedOff = false;
|
|
72
|
+
|
|
73
|
+
const ROOT = join(tmpdir(), "pify-shell-bg");
|
|
74
|
+
const MAX_SESSION_DIR_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
// Ref'd on purpose: this backs the shell_status `wait` poll, which is awaited
|
|
77
|
+
// inside an in-flight tool call, so the timer must actually resolve rather than
|
|
78
|
+
// let an otherwise-idle loop exit out from under it.
|
|
79
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The one auto-background threshold, computed once so the bash guideline and
|
|
83
|
+
* runBash cannot drift: auto-background needs a session that outlives the run
|
|
84
|
+
* to deliver into, which a headless `pi -p` does not have, so it is off there.
|
|
85
|
+
*/
|
|
86
|
+
const effectiveAutoMs = (hasUI: boolean): number => (hasUI ? settings.autoBackgroundMs : 0);
|
|
63
87
|
|
|
64
88
|
// ── setup ──────────────────────────────────────────────────────────
|
|
65
89
|
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
/**
|
|
91
|
+
* A stable per-session key: the session id survives a /reload (so adopted
|
|
92
|
+
* jobs are found again) and differs across /new and /resume (so sessions never
|
|
93
|
+
* reconcile or kill each other's jobs). Falls back to a cwd hash only when no
|
|
94
|
+
* session manager is present (e.g. the unit harness).
|
|
95
|
+
*/
|
|
96
|
+
function sessionKey(ctx: UiContext): string {
|
|
97
|
+
let id: string | undefined;
|
|
98
|
+
try {
|
|
99
|
+
id = ctx.sessionManager?.getSessionId?.();
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore — fall through to the cwd hash
|
|
102
|
+
}
|
|
68
103
|
if (id) return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
|
|
69
|
-
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
|
104
|
+
return createHash("sha256").update(ctx.cwd).digest("hex").slice(0, 16);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Remove sibling session dirs untouched for a week (a crash leaves the dir). */
|
|
108
|
+
function sweepOldDirs(keep: string): void {
|
|
109
|
+
let names: string[];
|
|
110
|
+
try {
|
|
111
|
+
names = readdirSync(ROOT);
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const cutoff = Date.now() - MAX_SESSION_DIR_AGE_MS;
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
if (name === keep) continue; // never sweep the session we are starting
|
|
118
|
+
const p = join(ROOT, name);
|
|
119
|
+
try {
|
|
120
|
+
const st = statSync(p);
|
|
121
|
+
if (st.isDirectory() && st.mtimeMs < cutoff) rmSync(p, { recursive: true, force: true });
|
|
122
|
+
} catch {
|
|
123
|
+
// A dir we cannot stat or remove is not worth failing startup over.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
70
126
|
}
|
|
71
127
|
|
|
72
128
|
function loadSettings(cwd: string): string[] {
|
|
@@ -100,6 +156,13 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
100
156
|
return { shell: cfg.shell, args };
|
|
101
157
|
}
|
|
102
158
|
|
|
159
|
+
function stopWidgetTimer(): void {
|
|
160
|
+
if (widgetTimer) {
|
|
161
|
+
clearInterval(widgetTimer);
|
|
162
|
+
widgetTimer = null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
103
166
|
function renderWidget(ctx: UiContext | null = lastUiCtx): void {
|
|
104
167
|
if (!ctx || !ctx.hasUI || !registry) return;
|
|
105
168
|
lastUiCtx = ctx;
|
|
@@ -107,9 +170,17 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
107
170
|
const lines = buildWidgetLines(registry.all(), ctx.ui.theme as never, now);
|
|
108
171
|
if (lines.length === 0) {
|
|
109
172
|
ctx.ui.setWidget(WIDGET, undefined);
|
|
173
|
+
stopWidgetTimer();
|
|
110
174
|
return;
|
|
111
175
|
}
|
|
112
176
|
ctx.ui.setWidget(WIDGET, (_tui: unknown) => new Text(lines.join("\n"), 0, 0), { placement: "aboveEditor" });
|
|
177
|
+
// Keep the box live between events: tick the elapsed clock while jobs run and
|
|
178
|
+
// clear a finished job once it falls out of its 15s window, even if nothing
|
|
179
|
+
// else fires a render. Unref'd, so a lingering box never holds the host open.
|
|
180
|
+
if (!widgetTimer) {
|
|
181
|
+
widgetTimer = setInterval(() => renderWidget(), 1000);
|
|
182
|
+
widgetTimer.unref?.();
|
|
183
|
+
}
|
|
113
184
|
}
|
|
114
185
|
|
|
115
186
|
// ── run ────────────────────────────────────────────────────────────
|
|
@@ -117,7 +188,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
117
188
|
function snapshot(job: Job): ToolResult {
|
|
118
189
|
const tail = readTail(job.logPath, settings.tailBytes);
|
|
119
190
|
return {
|
|
120
|
-
content: [{ type: "text", text: `${header(job)}\n${tail.text.replace(/\n+$/, "") || "(no output yet)"}` }],
|
|
191
|
+
content: [{ type: "text", text: `${header(job)}\n${sanitizeOutput(tail.text).replace(/\n+$/, "") || "(no output yet)"}` }],
|
|
121
192
|
details: { id: job.id, status: job.status },
|
|
122
193
|
};
|
|
123
194
|
}
|
|
@@ -130,23 +201,36 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
130
201
|
};
|
|
131
202
|
}
|
|
132
203
|
|
|
204
|
+
/** Push a finished job's result into the conversation through the live pi. */
|
|
205
|
+
function deliver(job: Job): void {
|
|
206
|
+
renderWidget();
|
|
207
|
+
pi.sendMessage(
|
|
208
|
+
{
|
|
209
|
+
customType: DELIVERY_TYPE,
|
|
210
|
+
content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
|
|
211
|
+
display: true,
|
|
212
|
+
details: { id: job.id, status: job.status, exitCode: job.exitCode },
|
|
213
|
+
},
|
|
214
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
133
218
|
/** Deliver a finished background job into the conversation, once. */
|
|
134
219
|
function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
|
|
135
220
|
exit
|
|
136
221
|
.then(() => {
|
|
222
|
+
// Handed off to a newer instance: only flush the final status to disk
|
|
223
|
+
// for it to read. Do not mark delivered, touch lastUiCtx, or send through
|
|
224
|
+
// this stale pi (which would throw and, worse, having already flipped
|
|
225
|
+
// delivered would stop the new instance from ever delivering).
|
|
226
|
+
if (handedOff) {
|
|
227
|
+
registry?.persist(job);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
137
230
|
if (job.delivered) return;
|
|
138
231
|
job.delivered = true;
|
|
139
232
|
registry?.persist(job);
|
|
140
|
-
|
|
141
|
-
pi.sendMessage(
|
|
142
|
-
{
|
|
143
|
-
customType: DELIVERY_TYPE,
|
|
144
|
-
content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
|
|
145
|
-
display: true,
|
|
146
|
-
details: { id: job.id, status: job.status, exitCode: job.exitCode },
|
|
147
|
-
},
|
|
148
|
-
{ deliverAs: "followUp", triggerTurn: true },
|
|
149
|
-
);
|
|
233
|
+
deliver(job);
|
|
150
234
|
})
|
|
151
235
|
.catch(() => {
|
|
152
236
|
// A /reload can make captured handles throw; delivery is a convenience,
|
|
@@ -165,12 +249,33 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
165
249
|
const command = String(params.command ?? "").trim();
|
|
166
250
|
if (!command) return { content: [{ type: "text", text: "Empty command." }], details: {}, isError: true };
|
|
167
251
|
|
|
252
|
+
// The cap bounds how many jobs may be alive, not whether a command runs:
|
|
253
|
+
// an explicit background request over it is refused (the model can still
|
|
254
|
+
// run the command in the foreground); an auto-background transition over
|
|
255
|
+
// it is skipped and the command simply stays in the foreground.
|
|
256
|
+
const liveOthers = (self?: string) => registry!.running().filter((j) => j.id !== self).length;
|
|
257
|
+
if (params.background && liveOthers() >= settings.maxBackground) {
|
|
258
|
+
const n = liveOthers();
|
|
259
|
+
return {
|
|
260
|
+
content: [
|
|
261
|
+
{
|
|
262
|
+
type: "text",
|
|
263
|
+
text:
|
|
264
|
+
`${n} background command${n === 1 ? " is" : "s are"} already running (maxBackground = ${settings.maxBackground}). ` +
|
|
265
|
+
`shell_kill one you no longer need, wait for one with shell_status {id, wait}, or run this in the foreground without background:true.`,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
details: { running: n, limit: settings.maxBackground },
|
|
269
|
+
isError: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
168
273
|
const job = registry.create(command, ctx.cwd);
|
|
169
274
|
const { shell, args } = shellArgv();
|
|
170
275
|
|
|
171
276
|
let spawned;
|
|
172
277
|
try {
|
|
173
|
-
spawned = spawnToFile(shell, args, command, ctx.cwd,
|
|
278
|
+
spawned = spawnToFile(shell, args, command, ctx.cwd, buildEnv(ctx, join(getAgentDir(), "bin")), job.logPath);
|
|
174
279
|
} catch (err) {
|
|
175
280
|
job.status = "failed";
|
|
176
281
|
job.endedAt = Date.now();
|
|
@@ -189,6 +294,12 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
189
294
|
job.exitCode = code;
|
|
190
295
|
job.signal = sig;
|
|
191
296
|
job.endedAt = Date.now();
|
|
297
|
+
// Handed off on /reload: flush the final status to the sidecar for the new
|
|
298
|
+
// instance to read, but do not deliver or touch the stale UI ctx here.
|
|
299
|
+
if (handedOff) {
|
|
300
|
+
registry?.persist(job);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
192
303
|
registry?.persist(job);
|
|
193
304
|
renderWidget();
|
|
194
305
|
});
|
|
@@ -208,7 +319,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
208
319
|
|
|
209
320
|
// Foreground: race the process against the auto-background threshold, an
|
|
210
321
|
// optional timeout, and the turn's abort signal — streaming the tail.
|
|
211
|
-
const autoMs = ctx.hasUI
|
|
322
|
+
const autoMs = effectiveAutoMs(ctx.hasUI);
|
|
212
323
|
const timers: NodeJS.Timeout[] = [];
|
|
213
324
|
const after = (ms: number, val: string) =>
|
|
214
325
|
new Promise<string>((res) => {
|
|
@@ -227,13 +338,31 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
227
338
|
|
|
228
339
|
try {
|
|
229
340
|
const race: Array<Promise<string>> = [settle.then(() => "exit")];
|
|
230
|
-
if (autoMs > 0) race.push(after(autoMs, "auto"));
|
|
231
341
|
if (params.timeout && params.timeout > 0) race.push(after(params.timeout * 1000, "timeout"));
|
|
232
342
|
if (signal) race.push(abort);
|
|
233
343
|
|
|
234
|
-
|
|
344
|
+
let outcome = await Promise.race(autoMs > 0 ? [...race, after(autoMs, "auto")] : race);
|
|
235
345
|
|
|
236
|
-
|
|
346
|
+
// Over the cap, the threshold passes without moving the command: it is
|
|
347
|
+
// never refused, it just keeps its foreground slot until it ends.
|
|
348
|
+
let keptForeground = false;
|
|
349
|
+
if (outcome === "auto" && liveOthers(job.id) >= settings.maxBackground) {
|
|
350
|
+
keptForeground = true;
|
|
351
|
+
outcome = await Promise.race(race);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (outcome === "exit") {
|
|
355
|
+
const r = finished(job);
|
|
356
|
+
if (keptForeground) {
|
|
357
|
+
r.content = [
|
|
358
|
+
{
|
|
359
|
+
type: "text",
|
|
360
|
+
text: `${r.content[0]?.text ?? ""}\n\n[kept in the foreground: ${settings.maxBackground} background commands were already running]`,
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
return r;
|
|
365
|
+
}
|
|
237
366
|
|
|
238
367
|
if (outcome === "auto") {
|
|
239
368
|
scheduleDelivery(job, settle);
|
|
@@ -264,7 +393,27 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
264
393
|
// timeout or abort: stop the tree, let the record settle, report partial.
|
|
265
394
|
job.killedByUs = true;
|
|
266
395
|
killTree(job.pid);
|
|
267
|
-
|
|
396
|
+
// Give the tree time to actually die before reporting — up to killTree's
|
|
397
|
+
// own SIGKILL grace on a timeout, briefly on an abort so the turn ends.
|
|
398
|
+
const graceMs = outcome === "timeout" ? 3000 : 500;
|
|
399
|
+
await Promise.race([settle, after(graceMs, "gave-up")]);
|
|
400
|
+
if (outcome === "timeout" && job.status === "running") {
|
|
401
|
+
// Do not print "[killed]" under a still-"running" header: the signal is
|
|
402
|
+
// out but the process has not confirmed exit. Say so, and point at the
|
|
403
|
+
// status tool rather than claim a death we cannot see yet.
|
|
404
|
+
return {
|
|
405
|
+
content: [
|
|
406
|
+
{
|
|
407
|
+
type: "text",
|
|
408
|
+
text:
|
|
409
|
+
formatResult(job, settings.tailBytes) +
|
|
410
|
+
`\n\n[kill signal sent; process had not exited after 3s — shell_status ${job.id} to confirm]`,
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
details: { id: job.id, status: job.status },
|
|
414
|
+
isError: true,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
268
417
|
const note =
|
|
269
418
|
outcome === "timeout"
|
|
270
419
|
? `\n\n[killed: exceeded the ${params.timeout}s timeout]`
|
|
@@ -282,16 +431,31 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
282
431
|
|
|
283
432
|
// ── tools & lifecycle ────────────────────────────────────────────────
|
|
284
433
|
|
|
285
|
-
function registerBash(cwd: string): void {
|
|
434
|
+
function registerBash(cwd: string, hasUI: boolean): void {
|
|
286
435
|
const original = createBashToolDefinition(cwd) as unknown as AnyTool;
|
|
436
|
+
const autoMs = effectiveAutoMs(hasUI);
|
|
437
|
+
// The guideline must match what runBash will actually do for this session:
|
|
438
|
+
// auto-background only happens interactively with a positive threshold.
|
|
439
|
+
const backgroundLine = !hasUI
|
|
440
|
+
? `This is a headless run: commands run to completion unless you pass background:true, which returns an id immediately. Nothing is delivered after your turn, so collect a backgrounded command within the same turn with shell_status {id, wait: N} (it blocks until the command finishes or the wait elapses).`
|
|
441
|
+
: autoMs === 0
|
|
442
|
+
? `Commands run to completion; pass background:true for anything long-running (a server, build, or watcher) to get an id back immediately, then collect or check it with shell_status.`
|
|
443
|
+
: `A command still running after ${Math.round(autoMs / 1000)}s is moved to the background and its result is delivered when it finishes; pass background:true to background a long task (a server, build, or watcher) immediately. Collect or check with shell_status.`;
|
|
287
444
|
const guidelines = [
|
|
288
445
|
...(Array.isArray((original as { promptGuidelines?: unknown }).promptGuidelines)
|
|
289
446
|
? ((original as { promptGuidelines?: string[] }).promptGuidelines as string[])
|
|
290
447
|
: []),
|
|
291
|
-
|
|
448
|
+
backgroundLine,
|
|
292
449
|
];
|
|
293
450
|
pi.registerTool({
|
|
294
451
|
...original,
|
|
452
|
+
// pi's description promises its own truncation ("last 2000 lines or
|
|
453
|
+
// 50KB … saved to a temp file"); this tool returns the last
|
|
454
|
+
// `tailBytes` of a log it keeps for the whole command, and says where.
|
|
455
|
+
description:
|
|
456
|
+
`Execute a shell command in the current working directory. Returns stdout and stderr, interleaved as they ` +
|
|
457
|
+
`arrived; a long output is shown as its last ${Math.round(settings.tailBytes / 1024)}KB with the path of the ` +
|
|
458
|
+
`full log. Optionally provide a timeout in seconds, or background:true to get an id back immediately.`,
|
|
295
459
|
parameters: Type.Object({
|
|
296
460
|
command: Type.String({ description: "Shell command to execute" }),
|
|
297
461
|
timeout: Type.Optional(
|
|
@@ -315,11 +479,14 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
315
479
|
label: "Background shell status",
|
|
316
480
|
promptSnippet: "Check or collect a backgrounded command",
|
|
317
481
|
description:
|
|
318
|
-
"Report a background command by id (its status and output tail), or list all this session's background commands when given no id. Finished results survive until the session ends.",
|
|
482
|
+
"Report a background command by id (its status and output tail), or list all this session's background commands when given no id. Pass wait (seconds, 0–300) to block until a still-running command finishes or the wait elapses — useful in a headless run where nothing is delivered after the turn. Finished results survive until the session ends.",
|
|
319
483
|
parameters: Type.Object({
|
|
320
484
|
id: Type.Optional(Type.String({ description: "Job id, e.g. bg-1. Omit to list all." })),
|
|
485
|
+
wait: Type.Optional(
|
|
486
|
+
Type.Number({ description: "Seconds to block while the command is still running (clamped 0–300). Default 0 — return immediately." }),
|
|
487
|
+
),
|
|
321
488
|
}),
|
|
322
|
-
async execute(_id: string, params: { id?: string }): Promise<ToolResult> {
|
|
489
|
+
async execute(_id: string, params: { id?: string; wait?: number }, sig?: AbortSignal): Promise<ToolResult> {
|
|
323
490
|
if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true };
|
|
324
491
|
const id = params.id?.trim();
|
|
325
492
|
if (!id) return { content: [{ type: "text", text: formatList(registry.all()) }], details: {} };
|
|
@@ -328,6 +495,15 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
328
495
|
const known = registry.all().map((j) => j.id).join(", ") || "(none)";
|
|
329
496
|
return { content: [{ type: "text", text: `No job "${id}". Known: ${known}` }], details: {}, isError: true };
|
|
330
497
|
}
|
|
498
|
+
// Optionally block until it finishes. A bounded poll of job.status (not an
|
|
499
|
+
// in-memory promise) so it works for adopted jobs too — those have no
|
|
500
|
+
// settle closure; their status is flipped by the adoption poller / a
|
|
501
|
+
// sibling instance persisting the sidecar. Cheap: status mutates in place.
|
|
502
|
+
const waitSec = Number.isFinite(params.wait) ? Math.min(300, Math.max(0, Math.floor(params.wait as number))) : 0;
|
|
503
|
+
if (waitSec > 0 && job.status === "running") {
|
|
504
|
+
const deadline = Date.now() + waitSec * 1000;
|
|
505
|
+
while (job.status === "running" && Date.now() < deadline && !sig?.aborted) await sleep(250);
|
|
506
|
+
}
|
|
331
507
|
// Reading a finished job marks it collected so it will not also be
|
|
332
508
|
// delivered unasked. A still-running poll must never do this: setting
|
|
333
509
|
// delivered here would permanently cancel the promised auto-delivery.
|
|
@@ -381,19 +557,96 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
381
557
|
},
|
|
382
558
|
});
|
|
383
559
|
|
|
560
|
+
/**
|
|
561
|
+
* After load(), take over every job still running from a previous instance of
|
|
562
|
+
* this same host — a /reload survivor (a foreign/dead host's jobs were settled
|
|
563
|
+
* to orphaned by load() and are not here). Their settle/delivery closures went
|
|
564
|
+
* with the old instance, so poll the sidecar the old closure keeps flushing:
|
|
565
|
+
* when it flips to a finished status (or the pid is simply gone) copy that in,
|
|
566
|
+
* persist, re-render, and deliver through the live pi if it was not delivered.
|
|
567
|
+
* One unref'd interval for them all; it stops once none are still running.
|
|
568
|
+
*/
|
|
569
|
+
function adoptRunning(ctx: UiContext): void {
|
|
570
|
+
if (!registry) return;
|
|
571
|
+
const adopted = registry.running();
|
|
572
|
+
if (adopted.length === 0) return;
|
|
573
|
+
lastUiCtx = ctx;
|
|
574
|
+
const timer = setInterval(() => {
|
|
575
|
+
if (!registry) {
|
|
576
|
+
clearInterval(timer);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
let anyRunning = false;
|
|
580
|
+
for (const job of adopted) {
|
|
581
|
+
if (job.status === "running") {
|
|
582
|
+
const disk = registry.readSidecar(job.id);
|
|
583
|
+
if (disk && disk.status !== "running") {
|
|
584
|
+
job.status = disk.status;
|
|
585
|
+
job.exitCode = disk.exitCode;
|
|
586
|
+
job.signal = disk.signal;
|
|
587
|
+
job.endedAt = disk.endedAt ?? Date.now();
|
|
588
|
+
} else if (!isAlive(job.pid)) {
|
|
589
|
+
// The old instance never flushed a final status (it crashed) but the
|
|
590
|
+
// process is gone: settle it here so it is not shown running forever.
|
|
591
|
+
job.status = job.killedByUs ? "killed" : "done";
|
|
592
|
+
job.endedAt = job.endedAt ?? Date.now();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (job.status === "running") anyRunning = true;
|
|
596
|
+
else if (!job.delivered) {
|
|
597
|
+
job.delivered = true;
|
|
598
|
+
registry.persist(job);
|
|
599
|
+
deliver(job);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
renderWidget(ctx);
|
|
603
|
+
if (!anyRunning) clearInterval(timer);
|
|
604
|
+
}, 1000);
|
|
605
|
+
timer.unref?.();
|
|
606
|
+
}
|
|
607
|
+
|
|
384
608
|
pi.on("session_start", async (_event, ctx) => {
|
|
385
609
|
const warnings = loadSettings(ctx.cwd);
|
|
386
|
-
|
|
610
|
+
const key = sessionKey(ctx);
|
|
611
|
+
sweepOldDirs(key);
|
|
612
|
+
registry = new JobRegistry(join(ROOT, key));
|
|
387
613
|
registry.load();
|
|
388
|
-
registerBash(ctx.cwd);
|
|
614
|
+
registerBash(ctx.cwd, ctx.hasUI);
|
|
389
615
|
renderWidget(ctx);
|
|
616
|
+
adoptRunning(ctx);
|
|
390
617
|
if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`shell-background settings: ${warnings.join("; ")}`, "warning");
|
|
391
618
|
});
|
|
392
619
|
|
|
393
|
-
pi.on("session_shutdown", async (
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
620
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
621
|
+
if (event.reason === "reload") {
|
|
622
|
+
// Same host process, a fresh instance is coming right after: hand the jobs
|
|
623
|
+
// off rather than kill them. This (old) instance's closures go quiet
|
|
624
|
+
// (handedOff) and the new instance adopts the still-running ones from the
|
|
625
|
+
// sidecars — that is what registry.ts and the README promise across /reload.
|
|
626
|
+
handedOff = true;
|
|
627
|
+
stopWidgetTimer();
|
|
628
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// quit / new / resume / fork: this session is ending for good. Kill each
|
|
632
|
+
// running tree and record it killed synchronously so a resumed/next reader
|
|
633
|
+
// never sees a dead job as running, then best-effort drop this session's dir.
|
|
634
|
+
if (registry) {
|
|
635
|
+
for (const job of registry.running()) {
|
|
636
|
+
job.killedByUs = true;
|
|
637
|
+
killTree(job.pid);
|
|
638
|
+
job.status = "killed";
|
|
639
|
+
job.endedAt = job.endedAt ?? Date.now();
|
|
640
|
+
registry.persist(job);
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
rmSync(registry.dir(), { recursive: true, force: true });
|
|
644
|
+
} catch {
|
|
645
|
+
// Windows can hold the log write stream open (EBUSY); the 7-day age
|
|
646
|
+
// sweep on a later session_start collects whatever is left behind.
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
stopWidgetTimer();
|
|
397
650
|
if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
|
|
398
651
|
});
|
|
399
652
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/shell-background",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Long-running bash goes async: background: true launches detached, and any command still running after 30s auto-backgrounds and delivers its result when it finishes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@earendil-works/pi-ai": "^0.
|
|
67
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
68
|
-
"@earendil-works/pi-tui": "^0.
|
|
66
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
67
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
68
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
69
69
|
"@types/node": "^22.10.2",
|
|
70
70
|
"typebox": "^1.1.38",
|
|
71
71
|
"typescript": "^5.7.2"
|
package/src/config.ts
CHANGED
|
@@ -14,11 +14,21 @@ export interface ShellBgSettings {
|
|
|
14
14
|
autoBackgroundMs: number;
|
|
15
15
|
/** Bytes of the log tail shown in a status/collect result. */
|
|
16
16
|
tailBytes: number;
|
|
17
|
+
/**
|
|
18
|
+
* How many jobs may be alive at once before a request to background one
|
|
19
|
+
* more is refused and an auto-background transition is skipped. Every live
|
|
20
|
+
* job is a whole process tree plus an open log; a session that keeps
|
|
21
|
+
* starting servers and watchers should hear about it rather than
|
|
22
|
+
* accumulate them without bound. Only the backgrounding decision is capped
|
|
23
|
+
* — a command itself is never refused.
|
|
24
|
+
*/
|
|
25
|
+
maxBackground: number;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export const DEFAULT_SETTINGS: ShellBgSettings = {
|
|
20
29
|
autoBackgroundMs: 30_000,
|
|
21
30
|
tailBytes: 64 * 1024,
|
|
31
|
+
maxBackground: 8,
|
|
22
32
|
};
|
|
23
33
|
|
|
24
34
|
const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
|
|
@@ -26,6 +36,7 @@ const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
|
|
|
26
36
|
// does not make every command look long-running.
|
|
27
37
|
autoBackgroundMs: { min: 0, max: 3_600_000 },
|
|
28
38
|
tailBytes: { min: 1024, max: 4 * 1024 * 1024 },
|
|
39
|
+
maxBackground: { min: 1, max: 64 },
|
|
29
40
|
};
|
|
30
41
|
|
|
31
42
|
export function resolveSettings(
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment a backgrounded command is spawned with.
|
|
3
|
+
*
|
|
4
|
+
* pi's own bash does not hand the child a raw `process.env`: it starts from
|
|
5
|
+
* `getShellEnv()` (process.env with `<agentDir>/bin` — where pi auto-installs
|
|
6
|
+
* `fd`/`rg` — prepended to PATH) and, since exposeSessionEnvironment defaults on,
|
|
7
|
+
* sets PI_SESSION_ID / PI_SESSION_FILE / PI_PROVIDER / PI_MODEL /
|
|
8
|
+
* PI_REASONING_LEVEL from the session ctx. The pi host never sets those PI_*
|
|
9
|
+
* vars in its own process.env (it injects them only into its bash tool's child),
|
|
10
|
+
* so a command spawned with plain `process.env` sees none of them and cannot
|
|
11
|
+
* resolve the managed `fd`/`rg` — despite the inherited description/guidelines
|
|
12
|
+
* promising both. This rebuilds the same env so a backgrounded command behaves
|
|
13
|
+
* like a foreground one.
|
|
14
|
+
*
|
|
15
|
+
* Pure and pi-free (the binDir and a minimal ctx shape are passed in) so it unit
|
|
16
|
+
* tests standalone. Zero dependencies — node:path only.
|
|
17
|
+
*/
|
|
18
|
+
import { delimiter } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** The slice of the pi ExtensionContext this needs, kept structural for tests. */
|
|
21
|
+
export interface EnvCtx {
|
|
22
|
+
sessionManager?: { getSessionId?(): string; getSessionFile?(): string | undefined };
|
|
23
|
+
model?: { provider?: string; id?: string } | undefined;
|
|
24
|
+
thinkingLevel?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copy `base`, prepend `binDir` to PATH when absent, and set the PI_* session
|
|
29
|
+
* variables from `ctx`. The ctx reads are wrapped so a stale ctx after a
|
|
30
|
+
* `/reload` degrades to plain env rather than failing the spawn.
|
|
31
|
+
*/
|
|
32
|
+
export function buildEnv(
|
|
33
|
+
ctx: EnvCtx,
|
|
34
|
+
binDir: string,
|
|
35
|
+
base: NodeJS.ProcessEnv = process.env,
|
|
36
|
+
): NodeJS.ProcessEnv {
|
|
37
|
+
const env: NodeJS.ProcessEnv = { ...base };
|
|
38
|
+
|
|
39
|
+
// PATH is case-insensitive on Windows; find the real key so we do not create a
|
|
40
|
+
// second, ignored "PATH" alongside an inherited "Path". Only prepend when the
|
|
41
|
+
// bin dir is not already on it (mirrors pi's getShellEnv hasBinDir check).
|
|
42
|
+
if (binDir) {
|
|
43
|
+
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH";
|
|
44
|
+
const current = env[pathKey] ?? "";
|
|
45
|
+
const entries = current.split(delimiter).filter(Boolean);
|
|
46
|
+
if (!entries.includes(binDir)) {
|
|
47
|
+
env[pathKey] = [binDir, current].filter(Boolean).join(delimiter);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const sid = ctx.sessionManager?.getSessionId?.();
|
|
53
|
+
if (sid) env.PI_SESSION_ID = sid;
|
|
54
|
+
const sessionFile = ctx.sessionManager?.getSessionFile?.();
|
|
55
|
+
if (sessionFile) env.PI_SESSION_FILE = sessionFile;
|
|
56
|
+
const model = ctx.model;
|
|
57
|
+
if (model) {
|
|
58
|
+
if (model.provider) env.PI_PROVIDER = model.provider;
|
|
59
|
+
if (model.id) env.PI_MODEL = model.id;
|
|
60
|
+
}
|
|
61
|
+
if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel;
|
|
62
|
+
} catch {
|
|
63
|
+
// Stale ctx (e.g. read after /reload): a plain env still runs the command.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return env;
|
|
67
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Job } from "./types.ts";
|
|
6
6
|
import { readTail, countLines } from "./tail.ts";
|
|
7
|
+
import { sanitizeOutput } from "./sanitize.ts";
|
|
7
8
|
|
|
8
9
|
function secs(ms: number): string {
|
|
9
10
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -21,11 +22,13 @@ export function header(job: Job): string {
|
|
|
21
22
|
const verdict =
|
|
22
23
|
job.status === "running"
|
|
23
24
|
? "running"
|
|
24
|
-
: job.
|
|
25
|
-
?
|
|
26
|
-
: job.
|
|
27
|
-
?
|
|
28
|
-
:
|
|
25
|
+
: job.status === "orphaned"
|
|
26
|
+
? "orphaned (another session)"
|
|
27
|
+
: job.signal
|
|
28
|
+
? `signal ${job.signal}`
|
|
29
|
+
: job.status === "killed"
|
|
30
|
+
? "killed"
|
|
31
|
+
: `exit ${job.exitCode ?? "?"}`;
|
|
29
32
|
return `[${job.id} · ${job.status} · ${verdict} · ${duration(job)}]`;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -36,14 +39,17 @@ export function header(job: Job): string {
|
|
|
36
39
|
*/
|
|
37
40
|
export function formatResult(job: Job, tailBytes: number): string {
|
|
38
41
|
const tail = readTail(job.logPath, tailBytes);
|
|
42
|
+
// Cleaned the way pi's own bash cleans its output for the model; the log on
|
|
43
|
+
// disk (which the truncation note points at) keeps every byte.
|
|
44
|
+
const text = sanitizeOutput(tail.text);
|
|
39
45
|
const lines = [header(job)];
|
|
40
|
-
if (
|
|
46
|
+
if (text.trim() === "") {
|
|
41
47
|
lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
|
|
42
48
|
} else {
|
|
43
49
|
if (tail.truncated) {
|
|
44
|
-
lines.push(`… showing the last ${countLines(
|
|
50
|
+
lines.push(`… showing the last ${countLines(text)} lines — full log: ${job.logPath}`);
|
|
45
51
|
}
|
|
46
|
-
lines.push(
|
|
52
|
+
lines.push(text.replace(/\n+$/, ""));
|
|
47
53
|
}
|
|
48
54
|
return lines.join("\n");
|
|
49
55
|
}
|
package/src/pending.ts
CHANGED
|
@@ -61,9 +61,9 @@ export function backgroundedResult(input: BackgroundedInput): BackgroundedResult
|
|
|
61
61
|
"with no id lists everything still running.",
|
|
62
62
|
]
|
|
63
63
|
: [
|
|
64
|
-
"This is a headless run: nothing is delivered after your turn ends.
|
|
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/sanitize.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The same cleaning pi's own bash tool applies to what the model sees
|
|
3
|
+
* (bash-executor.ts: `sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "")`).
|
|
4
|
+
*
|
|
5
|
+
* This package re-registers `bash`, and a job's log is captured raw on disk on
|
|
6
|
+
* purpose (the detached spawn writes straight to a file; no in-process pipes).
|
|
7
|
+
* Without this step the tail handed back for a chatty build or dev server is
|
|
8
|
+
* escape codes and carriage-return progress frames — tokens the native tool
|
|
9
|
+
* would never have spent. The on-disk log stays byte-for-byte; only the text
|
|
10
|
+
* that reaches the model is cleaned. Zero dependencies: pi does not export
|
|
11
|
+
* these helpers, so the regex (ansi-regex, MIT) and the code-point filter are
|
|
12
|
+
* vendored here verbatim.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Valid string terminator sequences are BEL, ESC\, and 0x9c
|
|
16
|
+
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
|
|
17
|
+
// OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
|
|
18
|
+
const OSC = `(?:\\u001B\\][\\s\\S]*?${ST})`;
|
|
19
|
+
// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte
|
|
20
|
+
const CSI = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
|
|
21
|
+
const ANSI = new RegExp(`${OSC}|${CSI}`, "g");
|
|
22
|
+
|
|
23
|
+
export function stripAnsi(text: string): string {
|
|
24
|
+
// Fast path: ANSI codes require ESC (7-bit) or CSI (8-bit) introducer.
|
|
25
|
+
if (!text.includes("\u001B") && !text.includes("\u009B")) return text;
|
|
26
|
+
return text.replace(ANSI, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drop characters that crash string-width or corrupt a terminal: control
|
|
31
|
+
* characters (except tab/newline/CR), Unicode format characters, lone
|
|
32
|
+
* surrogates and undefined code points.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeBinaryOutput(text: string): string {
|
|
35
|
+
return Array.from(text)
|
|
36
|
+
.filter((char) => {
|
|
37
|
+
const code = char.codePointAt(0);
|
|
38
|
+
if (code === undefined) return false;
|
|
39
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
|
|
40
|
+
if (code <= 0x1f) return false;
|
|
41
|
+
if (code >= 0xfff9 && code <= 0xfffb) return false;
|
|
42
|
+
return true;
|
|
43
|
+
})
|
|
44
|
+
.join("");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Exactly what pi's bash gives the model: no ANSI, no control noise, no `\r`. */
|
|
48
|
+
export function sanitizeOutput(text: string): string {
|
|
49
|
+
return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
|
|
50
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* No imports from pi packages: src/ typechecks and unit-tests standalone.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export type JobStatus = "running" | "done" | "failed" | "killed";
|
|
6
|
+
export type JobStatus = "running" | "done" | "failed" | "killed" | "orphaned";
|
|
7
7
|
|
|
8
8
|
export interface Job {
|
|
9
9
|
/** Short session-monotonic id, e.g. "bg-1". */
|
|
@@ -12,6 +12,14 @@ export interface Job {
|
|
|
12
12
|
cwd: string;
|
|
13
13
|
/** OS pid of the shell process; null before spawn or if spawn failed. */
|
|
14
14
|
pid: number | null;
|
|
15
|
+
/**
|
|
16
|
+
* pid of the pi host that spawned this job. A record whose hostPid is not the
|
|
17
|
+
* current process was written by another (or a since-crashed) host: its pid is
|
|
18
|
+
* not ours to signal, so it is never treated as running here. A `/reload`
|
|
19
|
+
* keeps the same host pid, so genuine reload survivors still adopt. Optional
|
|
20
|
+
* so a pre-upgrade sidecar (no hostPid) is simply treated as foreign.
|
|
21
|
+
*/
|
|
22
|
+
hostPid?: number;
|
|
15
23
|
status: JobStatus;
|
|
16
24
|
/** Process exit code, once finished. */
|
|
17
25
|
exitCode: number | null;
|