@pify/shell-background 0.2.0 → 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 +5 -2
- package/extensions/shell-background.ts +44 -4
- package/package.json +4 -4
- package/src/config.ts +11 -0
- package/src/format.ts +7 -3
- package/src/sanitize.ts +50 -0
package/README.md
CHANGED
|
@@ -53,11 +53,14 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
|
|
|
53
53
|
```json
|
|
54
54
|
{
|
|
55
55
|
"autoBackgroundMs": 30000,
|
|
56
|
-
"tailBytes": 65536
|
|
56
|
+
"tailBytes": 65536,
|
|
57
|
+
"maxBackground": 8
|
|
57
58
|
}
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
`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.
|
|
61
64
|
|
|
62
65
|
## Coexistence with @pify/pretty
|
|
63
66
|
|
|
@@ -47,6 +47,7 @@ import { buildEnv } from "../src/env.ts";
|
|
|
47
47
|
import { DEFAULT_SETTINGS, resolveSettings, type ShellBgSettings } from "../src/config.ts";
|
|
48
48
|
import { backgroundedResult, deliveryMessage, DELIVERY_TYPE } from "../src/pending.ts";
|
|
49
49
|
import { formatResult, formatList, header } from "../src/format.ts";
|
|
50
|
+
import { sanitizeOutput } from "../src/sanitize.ts";
|
|
50
51
|
import { buildWidgetLines } from "../src/widget.ts";
|
|
51
52
|
import { isFinished } from "../src/types.ts";
|
|
52
53
|
import type { Job } from "../src/types.ts";
|
|
@@ -187,7 +188,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
187
188
|
function snapshot(job: Job): ToolResult {
|
|
188
189
|
const tail = readTail(job.logPath, settings.tailBytes);
|
|
189
190
|
return {
|
|
190
|
-
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)"}` }],
|
|
191
192
|
details: { id: job.id, status: job.status },
|
|
192
193
|
};
|
|
193
194
|
}
|
|
@@ -248,6 +249,27 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
248
249
|
const command = String(params.command ?? "").trim();
|
|
249
250
|
if (!command) return { content: [{ type: "text", text: "Empty command." }], details: {}, isError: true };
|
|
250
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
|
+
|
|
251
273
|
const job = registry.create(command, ctx.cwd);
|
|
252
274
|
const { shell, args } = shellArgv();
|
|
253
275
|
|
|
@@ -316,13 +338,31 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
316
338
|
|
|
317
339
|
try {
|
|
318
340
|
const race: Array<Promise<string>> = [settle.then(() => "exit")];
|
|
319
|
-
if (autoMs > 0) race.push(after(autoMs, "auto"));
|
|
320
341
|
if (params.timeout && params.timeout > 0) race.push(after(params.timeout * 1000, "timeout"));
|
|
321
342
|
if (signal) race.push(abort);
|
|
322
343
|
|
|
323
|
-
|
|
344
|
+
let outcome = await Promise.race(autoMs > 0 ? [...race, after(autoMs, "auto")] : race);
|
|
345
|
+
|
|
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
|
+
}
|
|
324
353
|
|
|
325
|
-
if (outcome === "exit")
|
|
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
|
+
}
|
|
326
366
|
|
|
327
367
|
if (outcome === "auto") {
|
|
328
368
|
scheduleDelivery(job, settle);
|
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/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`;
|
|
@@ -38,14 +39,17 @@ export function header(job: Job): string {
|
|
|
38
39
|
*/
|
|
39
40
|
export function formatResult(job: Job, tailBytes: number): string {
|
|
40
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);
|
|
41
45
|
const lines = [header(job)];
|
|
42
|
-
if (
|
|
46
|
+
if (text.trim() === "") {
|
|
43
47
|
lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
|
|
44
48
|
} else {
|
|
45
49
|
if (tail.truncated) {
|
|
46
|
-
lines.push(`… showing the last ${countLines(
|
|
50
|
+
lines.push(`… showing the last ${countLines(text)} lines — full log: ${job.logPath}`);
|
|
47
51
|
}
|
|
48
|
-
lines.push(
|
|
52
|
+
lines.push(text.replace(/\n+$/, ""));
|
|
49
53
|
}
|
|
50
54
|
return lines.join("\n");
|
|
51
55
|
}
|
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
|
+
}
|