@pify/shell-background 0.1.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/LICENSE +21 -0
- package/README.md +65 -0
- package/extensions/shell-background.ts +380 -0
- package/package.json +76 -0
- package/src/config.ts +72 -0
- package/src/format.ts +63 -0
- package/src/kill.ts +56 -0
- package/src/pending.ts +95 -0
- package/src/registry.ts +123 -0
- package/src/spawn.ts +121 -0
- package/src/tail.ts +65 -0
- package/src/types.ts +40 -0
- package/src/widget.ts +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pifydev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @pify/shell-background
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pifydev/shell-background/actions/workflows/ci.yml) [](https://www.npmjs.com/package/@pify/shell-background) [](https://www.npmjs.com/package/@pify/shell-background)
|
|
4
|
+
|
|
5
|
+
Long-running bash goes async in [pi](https://github.com/earendil-works/pi). Pass `background: true` to launch a command detached and get its id back immediately — and any foreground command still running after 30 seconds is **automatically moved to the background**, so a build, a test run, or a dev server never eats the agent's turn while it waits.
|
|
6
|
+
|
|
7
|
+
Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install shell-background`](https://github.com/pifydev/cli) or `pi install npm:@pify/shell-background`.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
pi's bash tool waits for the command to finish. That is right for `ls` and wrong for `npm run build`: the agent sits blocked for a minute with nothing to do, and a `npm run dev` that never exits blocks it forever. The fix is to let a long command keep running in the background while the agent gets on with something else, and to hand back the result when it lands.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
It re-registers the `bash` tool with the same shell, working directory and environment — nothing about how a command runs changes — but a different lifecycle:
|
|
16
|
+
|
|
17
|
+
| Situation | What happens |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Command finishes quickly | Returns normally, exactly like before. |
|
|
20
|
+
| Command still running after 30s | Moved to the background: the tool returns `moved to background, id=bg-1`, and the result is delivered into the conversation when the command finishes. |
|
|
21
|
+
| `background: true` | Launched detached from the start; returns the id immediately. |
|
|
22
|
+
| `timeout: N` | Killed (whole process tree) if it runs past N seconds. |
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
bash { command: "npm run build" } # returns when done, or auto-backgrounds at 30s
|
|
26
|
+
bash { command: "npm run dev", background: true } # → "bg-2 started in the background"
|
|
27
|
+
shell_status { id: "bg-2" } # status + output so far
|
|
28
|
+
shell_status # list every background command this session
|
|
29
|
+
shell_kill { id: "bg-2" } # stop it and its whole process tree
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`/shell-bg` lists the jobs; `/shell-bg kill bg-2` stops one.
|
|
33
|
+
|
|
34
|
+
## Delivery, and the headless caveat
|
|
35
|
+
|
|
36
|
+
When a backgrounded command finishes in an **interactive** session, its result is pushed into the conversation as the next turn — you do not have to poll. Under headless `pi -p` there is nothing to deliver into (the session tears down when the prompt resolves), so **auto-background is disabled there** and only explicit `background: true` applies; collect it with `shell_status` inside the same turn. This is the same delivery rule the rest of the suite lives by.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
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
|
+
Shell resolution reuses pi's own `getShellConfig` (Git Bash on Windows, `/bin/bash` then `sh` on Unix), so a backgrounded command behaves identically to a foreground one. Jobs are tracked in memory and mirrored to a per-session sidecar under the temp dir, so `shell_status` still answers after a `/reload` and a job whose process has died is reconciled rather than shown as forever-running.
|
|
43
|
+
|
|
44
|
+
There are **no runtime dependencies**, and it works on Linux, macOS and Windows.
|
|
45
|
+
|
|
46
|
+
## Settings
|
|
47
|
+
|
|
48
|
+
Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-background.json` (global):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"autoBackgroundMs": 30000,
|
|
53
|
+
"tailBytes": 65536
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
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.
|
|
58
|
+
|
|
59
|
+
## Coexistence
|
|
60
|
+
|
|
61
|
+
This package owns the `bash` tool's execution. If you also run another extension that re-registers `bash` (a renderer like `@pify/pretty`, say), whichever loads last wins — install order decides. `@pify/pretty` only changes rendering and leaves execution alone, so the usual advice is to let this package load after it.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT © [Pify maintainers](https://github.com/pifydev)
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pify/shell-background — long-running bash goes async.
|
|
3
|
+
*
|
|
4
|
+
* Re-registers pi's `bash` tool with the same shell, cwd and env, but a
|
|
5
|
+
* different lifecycle:
|
|
6
|
+
*
|
|
7
|
+
* - a command's stdout+stderr are written straight to a log file via
|
|
8
|
+
* inherited file descriptors (no pipes, so no drain deadlock and no lost
|
|
9
|
+
* output at any volume), the process is spawned detached and unref'd so it
|
|
10
|
+
* survives the tool returning;
|
|
11
|
+
* - a foreground command that is still running after the auto-background
|
|
12
|
+
* threshold (default 30s, interactive sessions only) is moved to the
|
|
13
|
+
* background: the tool returns "moved to background, id=…" and the result is
|
|
14
|
+
* delivered into the conversation when the command finishes;
|
|
15
|
+
* - `background: true` launches detached from the start and returns the id
|
|
16
|
+
* immediately.
|
|
17
|
+
*
|
|
18
|
+
* `shell_status` polls or collects a job (and lists them all); `shell_kill`
|
|
19
|
+
* terminates one and its whole process tree. Delivery reuses the suite's
|
|
20
|
+
* pending pattern and its one hard rule: it only works in a session that
|
|
21
|
+
* outlives the run, so auto-background is off under headless `pi -p` (explicit
|
|
22
|
+
* background still works, collected with shell_status inside the turn).
|
|
23
|
+
*
|
|
24
|
+
* pi has no native background bash — every command is awaited to completion —
|
|
25
|
+
* so the spawn is our own, reusing pi's shell resolution (getShellConfig) for
|
|
26
|
+
* byte-identical shell behaviour. Zero runtime dependencies.
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
createBashToolDefinition,
|
|
30
|
+
getAgentDir,
|
|
31
|
+
getShellConfig,
|
|
32
|
+
type ExtensionAPI,
|
|
33
|
+
type ExtensionContext,
|
|
34
|
+
} from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
36
|
+
import { Type } from "typebox";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
import { readFileSync } from "node:fs";
|
|
41
|
+
|
|
42
|
+
import { JobRegistry } from "../src/registry.ts";
|
|
43
|
+
import { spawnToFile } from "../src/spawn.ts";
|
|
44
|
+
import { killTree } from "../src/kill.ts";
|
|
45
|
+
import { readTail } from "../src/tail.ts";
|
|
46
|
+
import { DEFAULT_SETTINGS, resolveSettings, type ShellBgSettings } from "../src/config.ts";
|
|
47
|
+
import { backgroundedResult, deliveryMessage, DELIVERY_TYPE } from "../src/pending.ts";
|
|
48
|
+
import { formatResult, formatList, header } from "../src/format.ts";
|
|
49
|
+
import { buildWidgetLines } from "../src/widget.ts";
|
|
50
|
+
import type { Job } from "../src/types.ts";
|
|
51
|
+
|
|
52
|
+
type UiContext = ExtensionContext;
|
|
53
|
+
type AnyTool = { name: string; execute: (...a: never[]) => unknown; [k: string]: unknown };
|
|
54
|
+
type ToolResult = { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown>; isError?: boolean };
|
|
55
|
+
|
|
56
|
+
const WIDGET = "shell-bg";
|
|
57
|
+
|
|
58
|
+
export default function shellBackground(pi: ExtensionAPI) {
|
|
59
|
+
let settings: ShellBgSettings = DEFAULT_SETTINGS;
|
|
60
|
+
let registry: JobRegistry | null = null;
|
|
61
|
+
let lastUiCtx: UiContext | null = null;
|
|
62
|
+
|
|
63
|
+
// ── setup ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function sessionKey(cwd: string): string {
|
|
66
|
+
const id = process.env.PI_SESSION_ID;
|
|
67
|
+
if (id) return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
|
|
68
|
+
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadSettings(cwd: string): string[] {
|
|
72
|
+
for (const file of [join(cwd, ".pi", "shell-background.json"), join(getAgentDir(), "shell-background.json")]) {
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = readFileSync(file, "utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed = resolveSettings(JSON.parse(raw));
|
|
81
|
+
settings = parsed.settings;
|
|
82
|
+
return parsed.warnings;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
settings = DEFAULT_SETTINGS;
|
|
85
|
+
return [`${file}: ${err instanceof Error ? err.message : String(err)}`];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const parsed = resolveSettings(undefined);
|
|
89
|
+
settings = parsed.settings;
|
|
90
|
+
return parsed.warnings;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Shell + args, reusing pi's resolution; command rides in argv (not stdin). */
|
|
94
|
+
function shellArgv(): { shell: string; args: string[] } {
|
|
95
|
+
const cfg = getShellConfig();
|
|
96
|
+
// Our stdio has stdin ignored, so a stdin command transport (legacy WSL)
|
|
97
|
+
// cannot receive the command — fall back to -c, which every bash accepts.
|
|
98
|
+
const args = cfg.commandTransport === "stdin" ? ["-c"] : [...cfg.args];
|
|
99
|
+
return { shell: cfg.shell, args };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderWidget(ctx: UiContext | null = lastUiCtx): void {
|
|
103
|
+
if (!ctx || !ctx.hasUI || !registry) return;
|
|
104
|
+
lastUiCtx = ctx;
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const lines = buildWidgetLines(registry.all(), ctx.ui.theme as never, now);
|
|
107
|
+
if (lines.length === 0) {
|
|
108
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
ctx.ui.setWidget(WIDGET, (_tui: unknown) => new Text(lines.join("\n"), 0, 0), { placement: "aboveEditor" });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── run ────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function snapshot(job: Job): ToolResult {
|
|
117
|
+
const tail = readTail(job.logPath, settings.tailBytes);
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `${header(job)}\n${tail.text.replace(/\n+$/, "") || "(no output yet)"}` }],
|
|
120
|
+
details: { id: job.id, status: job.status },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function finished(job: Job): ToolResult {
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
|
|
127
|
+
details: { id: job.id, status: job.status, exitCode: job.exitCode, signal: job.signal, background: false },
|
|
128
|
+
isError: job.status === "failed",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Deliver a finished background job into the conversation, once. */
|
|
133
|
+
function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
|
|
134
|
+
exit
|
|
135
|
+
.then(() => {
|
|
136
|
+
if (job.delivered) return;
|
|
137
|
+
job.delivered = true;
|
|
138
|
+
registry?.persist(job);
|
|
139
|
+
renderWidget();
|
|
140
|
+
pi.sendMessage(
|
|
141
|
+
{
|
|
142
|
+
customType: DELIVERY_TYPE,
|
|
143
|
+
content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
|
|
144
|
+
display: true,
|
|
145
|
+
details: { id: job.id, status: job.status, exitCode: job.exitCode },
|
|
146
|
+
},
|
|
147
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
148
|
+
);
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {
|
|
151
|
+
// A /reload can make captured handles throw; delivery is a convenience,
|
|
152
|
+
// shell_status still collects the result.
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runBash(
|
|
157
|
+
params: { command: string; timeout?: number; background?: boolean },
|
|
158
|
+
signal: AbortSignal | undefined,
|
|
159
|
+
onUpdate: ((r: ToolResult) => void) | undefined,
|
|
160
|
+
ctx: UiContext,
|
|
161
|
+
): Promise<ToolResult> {
|
|
162
|
+
lastUiCtx = ctx;
|
|
163
|
+
if (!registry) throw new Error("shell-background not initialized");
|
|
164
|
+
const command = String(params.command ?? "").trim();
|
|
165
|
+
if (!command) return { content: [{ type: "text", text: "Empty command." }], details: {}, isError: true };
|
|
166
|
+
|
|
167
|
+
const job = registry.create(command, ctx.cwd);
|
|
168
|
+
const { shell, args } = shellArgv();
|
|
169
|
+
|
|
170
|
+
let spawned;
|
|
171
|
+
try {
|
|
172
|
+
spawned = spawnToFile(shell, args, command, ctx.cwd, process.env, job.logPath);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
job.status = "failed";
|
|
175
|
+
job.endedAt = Date.now();
|
|
176
|
+
registry.persist(job);
|
|
177
|
+
return { content: [{ type: "text", text: `Failed to start: ${err instanceof Error ? err.message : String(err)}` }], details: {}, isError: true };
|
|
178
|
+
}
|
|
179
|
+
job.pid = spawned.pid;
|
|
180
|
+
registry.persist(job);
|
|
181
|
+
renderWidget(ctx);
|
|
182
|
+
|
|
183
|
+
// Settle the job record the moment the process ends, whatever else happens.
|
|
184
|
+
const settle = spawned.exit.then(({ code, signal: sig }) => {
|
|
185
|
+
if (job.status === "running") {
|
|
186
|
+
job.status = job.killedByUs ? "killed" : sig ? "killed" : code === 0 ? "done" : "failed";
|
|
187
|
+
}
|
|
188
|
+
job.exitCode = code;
|
|
189
|
+
job.signal = sig;
|
|
190
|
+
job.endedAt = Date.now();
|
|
191
|
+
registry?.persist(job);
|
|
192
|
+
renderWidget();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (params.background) {
|
|
196
|
+
scheduleDelivery(job, settle);
|
|
197
|
+
const r = backgroundedResult({
|
|
198
|
+
id: job.id,
|
|
199
|
+
command,
|
|
200
|
+
elapsedMs: 0,
|
|
201
|
+
auto: false,
|
|
202
|
+
interactive: ctx.hasUI,
|
|
203
|
+
collectWith: "shell_status",
|
|
204
|
+
});
|
|
205
|
+
return { content: [{ type: "text", text: r.text }], details: r.details };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Foreground: race the process against the auto-background threshold, an
|
|
209
|
+
// optional timeout, and the turn's abort signal — streaming the tail.
|
|
210
|
+
const autoMs = ctx.hasUI ? settings.autoBackgroundMs : 0;
|
|
211
|
+
const timers: NodeJS.Timeout[] = [];
|
|
212
|
+
const after = (ms: number, val: string) =>
|
|
213
|
+
new Promise<string>((res) => {
|
|
214
|
+
const t = setTimeout(() => res(val), ms);
|
|
215
|
+
t.unref?.();
|
|
216
|
+
timers.push(t);
|
|
217
|
+
});
|
|
218
|
+
const tick = setInterval(() => onUpdate?.(snapshot(job)), 1000);
|
|
219
|
+
tick.unref?.();
|
|
220
|
+
|
|
221
|
+
const abort = new Promise<string>((res) => {
|
|
222
|
+
if (!signal) return;
|
|
223
|
+
if (signal.aborted) res("abort");
|
|
224
|
+
else signal.addEventListener("abort", () => res("abort"), { once: true });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const race: Array<Promise<string>> = [settle.then(() => "exit")];
|
|
229
|
+
if (autoMs > 0) race.push(after(autoMs, "auto"));
|
|
230
|
+
if (params.timeout && params.timeout > 0) race.push(after(params.timeout * 1000, "timeout"));
|
|
231
|
+
if (signal) race.push(abort);
|
|
232
|
+
|
|
233
|
+
const outcome = await Promise.race(race);
|
|
234
|
+
|
|
235
|
+
if (outcome === "exit") return finished(job);
|
|
236
|
+
|
|
237
|
+
if (outcome === "auto") {
|
|
238
|
+
scheduleDelivery(job, settle);
|
|
239
|
+
const r = backgroundedResult({
|
|
240
|
+
id: job.id,
|
|
241
|
+
command,
|
|
242
|
+
elapsedMs: Date.now() - job.startedAt,
|
|
243
|
+
auto: true,
|
|
244
|
+
interactive: ctx.hasUI,
|
|
245
|
+
collectWith: "shell_status",
|
|
246
|
+
});
|
|
247
|
+
return { content: [{ type: "text", text: r.text }], details: r.details };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// timeout or abort: stop the tree, let the record settle, report partial.
|
|
251
|
+
job.killedByUs = true;
|
|
252
|
+
killTree(job.pid);
|
|
253
|
+
await Promise.race([settle, after(500, "gave-up")]);
|
|
254
|
+
const note =
|
|
255
|
+
outcome === "timeout"
|
|
256
|
+
? `\n\n[killed: exceeded the ${params.timeout}s timeout]`
|
|
257
|
+
: `\n\n[killed: the turn was aborted]`;
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: "text", text: formatResult(job, settings.tailBytes) + note }],
|
|
260
|
+
details: { id: job.id, status: job.status },
|
|
261
|
+
isError: true,
|
|
262
|
+
};
|
|
263
|
+
} finally {
|
|
264
|
+
timers.forEach(clearTimeout);
|
|
265
|
+
clearInterval(tick);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── tools & lifecycle ────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function registerBash(cwd: string): void {
|
|
272
|
+
const original = createBashToolDefinition(cwd) as unknown as AnyTool;
|
|
273
|
+
const guidelines = [
|
|
274
|
+
...(Array.isArray((original as { promptGuidelines?: unknown }).promptGuidelines)
|
|
275
|
+
? ((original as { promptGuidelines?: string[] }).promptGuidelines as string[])
|
|
276
|
+
: []),
|
|
277
|
+
`A command still running after ${Math.round(settings.autoBackgroundMs / 1000)}s is moved to the background and its result is delivered when it finishes; pass background:true to background a long task (a server, build, or watcher) immediately. Collect or check with shell_status.`,
|
|
278
|
+
];
|
|
279
|
+
pi.registerTool({
|
|
280
|
+
...original,
|
|
281
|
+
parameters: Type.Object({
|
|
282
|
+
command: Type.String({ description: "Shell command to execute" }),
|
|
283
|
+
timeout: Type.Optional(
|
|
284
|
+
Type.Number({ description: "Timeout in seconds; a command that hits it is killed with its whole process tree." }),
|
|
285
|
+
),
|
|
286
|
+
background: Type.Optional(
|
|
287
|
+
Type.Boolean({ description: "Launch detached and return an id immediately instead of waiting for the command to finish." }),
|
|
288
|
+
),
|
|
289
|
+
}),
|
|
290
|
+
promptGuidelines: guidelines,
|
|
291
|
+
// Let pi render the result with its default text renderer; keep the call
|
|
292
|
+
// renderer (it only needs the command) if the original had one.
|
|
293
|
+
renderResult: undefined,
|
|
294
|
+
execute: async (_id: string, params: never, sig: never, upd: never, ctx: never) =>
|
|
295
|
+
runBash(params, sig as AbortSignal | undefined, upd as never, ctx as UiContext),
|
|
296
|
+
} as never);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
pi.registerTool({
|
|
300
|
+
name: "shell_status",
|
|
301
|
+
label: "Background shell status",
|
|
302
|
+
promptSnippet: "Check or collect a backgrounded command",
|
|
303
|
+
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.",
|
|
305
|
+
parameters: Type.Object({
|
|
306
|
+
id: Type.Optional(Type.String({ description: "Job id, e.g. bg-1. Omit to list all." })),
|
|
307
|
+
}),
|
|
308
|
+
async execute(_id: string, params: { id?: string }): Promise<ToolResult> {
|
|
309
|
+
if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true };
|
|
310
|
+
const id = params.id?.trim();
|
|
311
|
+
if (!id) return { content: [{ type: "text", text: formatList(registry.all()) }], details: {} };
|
|
312
|
+
const job = registry.get(id);
|
|
313
|
+
if (!job) {
|
|
314
|
+
const known = registry.all().map((j) => j.id).join(", ") || "(none)";
|
|
315
|
+
return { content: [{ type: "text", text: `No job "${id}". Known: ${known}` }], details: {}, isError: true };
|
|
316
|
+
}
|
|
317
|
+
// Reading marks it collected so it will not also be delivered unasked.
|
|
318
|
+
job.delivered = true;
|
|
319
|
+
return {
|
|
320
|
+
content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
|
|
321
|
+
details: { id: job.id, status: job.status, exitCode: job.exitCode },
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
pi.registerTool({
|
|
327
|
+
name: "shell_kill",
|
|
328
|
+
label: "Kill background shell",
|
|
329
|
+
promptSnippet: "Stop a backgrounded command",
|
|
330
|
+
description: "Terminate a background command and its whole process tree by id.",
|
|
331
|
+
parameters: Type.Object({ id: Type.String({ description: "Job id, e.g. bg-1" }) }),
|
|
332
|
+
async execute(_id: string, params: { id: string }): Promise<ToolResult> {
|
|
333
|
+
if (!registry) return { content: [{ type: "text", text: "shell-background not initialized" }], details: {}, isError: true };
|
|
334
|
+
const job = registry.get(params.id?.trim());
|
|
335
|
+
if (!job) return { content: [{ type: "text", text: `No job "${params.id}".` }], details: {}, isError: true };
|
|
336
|
+
if (job.status !== "running") {
|
|
337
|
+
return { content: [{ type: "text", text: `${job.id} already ${job.status}.` }], details: { id: job.id, status: job.status } };
|
|
338
|
+
}
|
|
339
|
+
job.killedByUs = true;
|
|
340
|
+
killTree(job.pid);
|
|
341
|
+
return { content: [{ type: "text", text: `Killing ${job.id} (pid ${job.pid ?? "?"}) and its process tree.` }], details: { id: job.id } };
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
pi.registerCommand("shell-bg", {
|
|
346
|
+
description: "Background shell jobs: /shell-bg [kill <id>]",
|
|
347
|
+
handler: async (args, ctx: UiContext) => {
|
|
348
|
+
if (!ctx.hasUI || !registry) return;
|
|
349
|
+
const [verb, id] = (args ?? "").trim().split(/\s+/);
|
|
350
|
+
if (verb === "kill" && id) {
|
|
351
|
+
const job = registry.get(id);
|
|
352
|
+
if (job && job.status === "running") {
|
|
353
|
+
job.killedByUs = true;
|
|
354
|
+
killTree(job.pid);
|
|
355
|
+
ctx.ui.notify(`Killing ${job.id}.`, "info");
|
|
356
|
+
} else {
|
|
357
|
+
ctx.ui.notify(job ? `${id} already ${job.status}.` : `No job "${id}".`, "warning");
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
ctx.ui.notify(formatList(registry.all()), "info");
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
366
|
+
const warnings = loadSettings(ctx.cwd);
|
|
367
|
+
registry = new JobRegistry(join(tmpdir(), "pify-shell-bg", sessionKey(ctx.cwd)));
|
|
368
|
+
registry.load();
|
|
369
|
+
registerBash(ctx.cwd);
|
|
370
|
+
renderWidget(ctx);
|
|
371
|
+
if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`shell-background settings: ${warnings.join("; ")}`, "warning");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
375
|
+
// Background jobs are tied to the session; do not leave orphans running
|
|
376
|
+
// after pi exits.
|
|
377
|
+
if (registry) for (const job of registry.running()) killTree(job.pid);
|
|
378
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET, undefined);
|
|
379
|
+
});
|
|
380
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pify/shell-background",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi",
|
|
9
|
+
"pify",
|
|
10
|
+
"bash",
|
|
11
|
+
"background",
|
|
12
|
+
"async",
|
|
13
|
+
"shell"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/pifydev/shell-background#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/pifydev/shell-background/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/pifydev/shell-background.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Pify maintainers",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"extensions",
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./extensions/shell-background.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@earendil-works/pi-ai": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@earendil-works/pi-coding-agent": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@earendil-works/pi-tui": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"typebox": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
67
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
68
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
69
|
+
"@types/node": "^22.10.2",
|
|
70
|
+
"typebox": "^1.1.38",
|
|
71
|
+
"typescript": "^5.7.2"
|
|
72
|
+
},
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings for @pify/shell-background.
|
|
3
|
+
*
|
|
4
|
+
* The one number that is genuinely a matter of taste is the auto-background
|
|
5
|
+
* threshold: how long a foreground command may run before it is moved to the
|
|
6
|
+
* background so the agent gets its turn back. Read from `.pi/shell-background.json`
|
|
7
|
+
* (project) or `<agentDir>/shell-background.json` (global), with a PIFY_SHELL_BG_MS
|
|
8
|
+
* env override for one-off runs and CI. Bad values fall back to the default with
|
|
9
|
+
* a warning rather than taking the extension down.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ShellBgSettings {
|
|
13
|
+
/** Foreground ms before a command auto-backgrounds. 0 disables auto-background. */
|
|
14
|
+
autoBackgroundMs: number;
|
|
15
|
+
/** Bytes of the log tail shown in a status/collect result. */
|
|
16
|
+
tailBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_SETTINGS: ShellBgSettings = {
|
|
20
|
+
autoBackgroundMs: 30_000,
|
|
21
|
+
tailBytes: 64 * 1024,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
|
|
25
|
+
// 0 is allowed (disable); otherwise at least 1s so a typo of "30" (=30ms)
|
|
26
|
+
// does not make every command look long-running.
|
|
27
|
+
autoBackgroundMs: { min: 0, max: 3_600_000 },
|
|
28
|
+
tailBytes: { min: 1024, max: 4 * 1024 * 1024 },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function resolveSettings(
|
|
32
|
+
raw: unknown,
|
|
33
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
34
|
+
): { settings: ShellBgSettings; warnings: string[] } {
|
|
35
|
+
const settings: ShellBgSettings = { ...DEFAULT_SETTINGS };
|
|
36
|
+
const warnings: string[] = [];
|
|
37
|
+
|
|
38
|
+
if (raw !== undefined && raw !== null) {
|
|
39
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
40
|
+
warnings.push("settings file is not an object — ignored");
|
|
41
|
+
} else {
|
|
42
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
43
|
+
if (!(key in DEFAULT_SETTINGS)) {
|
|
44
|
+
warnings.push(`unknown setting "${key}"`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const name = key as keyof ShellBgSettings;
|
|
48
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
49
|
+
warnings.push(`"${key}" must be a number — using ${DEFAULT_SETTINGS[name]}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
settings[name] = clamp(name, value, warnings);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const envMs = env.PIFY_SHELL_BG_MS;
|
|
58
|
+
if (envMs !== undefined && envMs !== "") {
|
|
59
|
+
const n = Number(envMs);
|
|
60
|
+
if (Number.isFinite(n)) settings.autoBackgroundMs = clamp("autoBackgroundMs", n, warnings);
|
|
61
|
+
else warnings.push(`PIFY_SHELL_BG_MS="${envMs}" is not a number — ignored`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { settings, warnings };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clamp(name: keyof ShellBgSettings, value: number, warnings: string[]): number {
|
|
68
|
+
const { min, max } = LIMITS[name];
|
|
69
|
+
const c = Math.round(Math.min(max, Math.max(min, value)));
|
|
70
|
+
if (c !== value) warnings.push(`"${name}" clamped to ${c} (allowed ${min}–${max})`);
|
|
71
|
+
return c;
|
|
72
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a job (and its log tail) into the text a tool returns. Pure: given a
|
|
3
|
+
* job and a tail, produce the string — the extension supplies both.
|
|
4
|
+
*/
|
|
5
|
+
import type { Job } from "./types.ts";
|
|
6
|
+
import { readTail, countLines } from "./tail.ts";
|
|
7
|
+
|
|
8
|
+
function secs(ms: number): string {
|
|
9
|
+
if (ms < 1000) return `${ms}ms`;
|
|
10
|
+
const s = ms / 1000;
|
|
11
|
+
return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function duration(job: Job): string {
|
|
15
|
+
const end = job.endedAt ?? Date.now();
|
|
16
|
+
return secs(Math.max(0, end - job.startedAt));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `[bg-1 · done · exit 0 · 4.2s]` — the one-line header every result carries. */
|
|
20
|
+
export function header(job: Job): string {
|
|
21
|
+
const verdict =
|
|
22
|
+
job.status === "running"
|
|
23
|
+
? "running"
|
|
24
|
+
: job.signal
|
|
25
|
+
? `signal ${job.signal}`
|
|
26
|
+
: job.status === "killed"
|
|
27
|
+
? "killed"
|
|
28
|
+
: `exit ${job.exitCode ?? "?"}`;
|
|
29
|
+
return `[${job.id} · ${job.status} · ${verdict} · ${duration(job)}]`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The full result of a finished (or polled) job: the header, then the tail of
|
|
34
|
+
* its output, with an honest note when output was truncated and where the whole
|
|
35
|
+
* log lives.
|
|
36
|
+
*/
|
|
37
|
+
export function formatResult(job: Job, tailBytes: number): string {
|
|
38
|
+
const tail = readTail(job.logPath, tailBytes);
|
|
39
|
+
const lines = [header(job)];
|
|
40
|
+
if (tail.text.trim() === "") {
|
|
41
|
+
lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
|
|
42
|
+
} else {
|
|
43
|
+
if (tail.truncated) {
|
|
44
|
+
lines.push(`… showing the last ${countLines(tail.text)} lines — full log: ${job.logPath}`);
|
|
45
|
+
}
|
|
46
|
+
lines.push(tail.text.replace(/\n+$/, ""));
|
|
47
|
+
}
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A roster of jobs for `shell_status` with no id. */
|
|
52
|
+
export function formatList(jobs: Job[]): string {
|
|
53
|
+
if (jobs.length === 0) return "No background commands this session.";
|
|
54
|
+
const rows = jobs
|
|
55
|
+
.slice()
|
|
56
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
57
|
+
.map((j) => {
|
|
58
|
+
const cmd = j.command.replace(/\s+/g, " ").trim();
|
|
59
|
+
const short = cmd.length <= 48 ? cmd : `${cmd.slice(0, 47)}…`;
|
|
60
|
+
return `${header(j)} ${short}`;
|
|
61
|
+
});
|
|
62
|
+
return [`${jobs.filter((j) => j.status === "running").length} running, ${jobs.length} total:`, ...rows].join("\n");
|
|
63
|
+
}
|
package/src/kill.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kill a shell command and everything it spawned, on every platform.
|
|
3
|
+
*
|
|
4
|
+
* A `bash -c "…"` is a tree: the shell, and whatever it launched. Killing only
|
|
5
|
+
* the shell's own pid orphans the grandchildren, which keep running and keep
|
|
6
|
+
* writing to the log. So we kill the whole tree.
|
|
7
|
+
*
|
|
8
|
+
* - POSIX: the command is spawned `detached`, which makes its pid a process
|
|
9
|
+
* group leader, so a signal to the negative pid reaches the group. Send
|
|
10
|
+
* SIGTERM, then escalate to SIGKILL after a grace period for anything that
|
|
11
|
+
* ignored the polite signal. Fall back to the bare pid if the group send
|
|
12
|
+
* fails (e.g. the group already gone).
|
|
13
|
+
* - Windows: no process groups; `taskkill /T` walks and kills the tree. Use
|
|
14
|
+
* the absolute System32 path so it works regardless of PATH, spawned with an
|
|
15
|
+
* error handler so a missing binary can never throw into the caller.
|
|
16
|
+
*
|
|
17
|
+
* Zero dependencies — node:child_process only.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
const GRACE_MS = 3000;
|
|
22
|
+
|
|
23
|
+
export function killTree(pid: number | null | undefined): void {
|
|
24
|
+
if (!pid || pid <= 0) return;
|
|
25
|
+
|
|
26
|
+
if (process.platform === "win32") {
|
|
27
|
+
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
28
|
+
try {
|
|
29
|
+
spawn(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }).on(
|
|
30
|
+
"error",
|
|
31
|
+
() => {},
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
// A machine without taskkill is not one we can do better on.
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const signalGroup = (sig: NodeJS.Signals) => {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(-pid, sig);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, sig);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!signalGroup("SIGTERM")) return;
|
|
54
|
+
const timer = setTimeout(() => signalGroup("SIGKILL"), GRACE_MS);
|
|
55
|
+
timer.unref?.();
|
|
56
|
+
}
|
package/src/pending.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The messages a backgrounded command produces — when it is sent to the
|
|
3
|
+
* background, and when it comes back.
|
|
4
|
+
*
|
|
5
|
+
* Two audiences, one hard constraint. Delivery — pushing the finished result
|
|
6
|
+
* into the conversation unasked — only works if the session outlives the run.
|
|
7
|
+
* An interactive session does; a headless `pi -p` run tears down the moment the
|
|
8
|
+
* prompt resolves, so there is nothing left to deliver into. So the not-ready
|
|
9
|
+
* message tells the truth for the mode it is in: interactive can wait, headless
|
|
10
|
+
* must collect within the turn.
|
|
11
|
+
*
|
|
12
|
+
* Pure strings; the extension owns the clock, the processes and the host.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface BackgroundedInput {
|
|
16
|
+
id: string;
|
|
17
|
+
command: string;
|
|
18
|
+
elapsedMs: number;
|
|
19
|
+
/** True on the auto-30s path, false when the caller asked for background. */
|
|
20
|
+
auto: boolean;
|
|
21
|
+
/** Whether a UI/interactive session is present to deliver into. */
|
|
22
|
+
interactive: boolean;
|
|
23
|
+
/** The tool to collect with, e.g. "shell_status". */
|
|
24
|
+
collectWith: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function elapsed(ms: number): string {
|
|
28
|
+
if (ms < 1000) return "just now";
|
|
29
|
+
const s = Math.round(ms / 1000);
|
|
30
|
+
if (s < 60) return `${s}s`;
|
|
31
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clip(command: string, max = 60): string {
|
|
35
|
+
const one = command.replace(/\s+/g, " ").trim();
|
|
36
|
+
return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BackgroundedResult {
|
|
40
|
+
text: string;
|
|
41
|
+
details: {
|
|
42
|
+
id: string;
|
|
43
|
+
status: "running";
|
|
44
|
+
background: true;
|
|
45
|
+
auto: boolean;
|
|
46
|
+
retryable: true;
|
|
47
|
+
pollRequired: boolean;
|
|
48
|
+
elapsedMs: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function backgroundedResult(input: BackgroundedInput): BackgroundedResult {
|
|
53
|
+
const head = input.auto
|
|
54
|
+
? `${input.id} is still running after ${elapsed(input.elapsedMs)} — moved to the background.`
|
|
55
|
+
: `${input.id} started in the background.`;
|
|
56
|
+
const line = ` $ ${clip(input.command)}`;
|
|
57
|
+
const tail = input.interactive
|
|
58
|
+
? [
|
|
59
|
+
"Its result is delivered here automatically when it finishes, so carry on with other",
|
|
60
|
+
`work. ${input.collectWith} with id "${input.id}" is only if you want it early, and ${input.collectWith}`,
|
|
61
|
+
"with no id lists everything still running.",
|
|
62
|
+
]
|
|
63
|
+
: [
|
|
64
|
+
"This is a headless run: nothing is delivered after your turn ends. Call",
|
|
65
|
+
`${input.collectWith} with id "${input.id}" again in this same turn until it reports finished —`,
|
|
66
|
+
"do not end your turn expecting the result to arrive on its own.",
|
|
67
|
+
];
|
|
68
|
+
return {
|
|
69
|
+
text: [head, line, "", ...tail].join("\n"),
|
|
70
|
+
details: {
|
|
71
|
+
id: input.id,
|
|
72
|
+
status: "running",
|
|
73
|
+
background: true,
|
|
74
|
+
auto: input.auto,
|
|
75
|
+
retryable: true,
|
|
76
|
+
pollRequired: !input.interactive,
|
|
77
|
+
elapsedMs: input.elapsedMs,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How a finished background job introduces itself when it arrives unasked. */
|
|
83
|
+
export function deliveryMessage(id: string, body: string): string {
|
|
84
|
+
return [
|
|
85
|
+
`<shell_background_result id="${id}">`,
|
|
86
|
+
body.trim(),
|
|
87
|
+
`</shell_background_result>`,
|
|
88
|
+
"",
|
|
89
|
+
`This is ${id}, a command you sent to the background; it has just finished and this is its result.`,
|
|
90
|
+
"Fold it into what you are doing. If you had already moved on, say what it changes — or that it changes nothing.",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The custom-message type a delivered result travels under. */
|
|
95
|
+
export const DELIVERY_TYPE = "pify-shell-background-result";
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The set of background jobs, in memory and mirrored to disk.
|
|
3
|
+
*
|
|
4
|
+
* The live Map is the source of truth while the session runs. Each job is also
|
|
5
|
+
* written to a small JSON sidecar (atomically, temp + rename) so the status
|
|
6
|
+
* tool still answers after a `/reload` re-instantiates the extension, and so a
|
|
7
|
+
* job that outran the session can be reconciled: on load, a job still marked
|
|
8
|
+
* running whose pid is no longer alive is settled as finished rather than shown
|
|
9
|
+
* as forever-running.
|
|
10
|
+
*
|
|
11
|
+
* Scoped to one session via the base directory the caller supplies, so two pi
|
|
12
|
+
* sessions never reconcile each other's jobs. Zero dependencies — node:fs/path.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { Job } from "./types.ts";
|
|
17
|
+
import { isRecord } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
function isAlive(pid: number | null): boolean {
|
|
20
|
+
if (!pid || pid <= 0) return false;
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// EPERM means the process exists but we may not signal it — still alive.
|
|
26
|
+
return (err as { code?: string }).code === "EPERM";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isJob(v: unknown): v is Job {
|
|
31
|
+
return (
|
|
32
|
+
isRecord(v) &&
|
|
33
|
+
typeof v.id === "string" &&
|
|
34
|
+
typeof v.command === "string" &&
|
|
35
|
+
typeof v.logPath === "string" &&
|
|
36
|
+
typeof v.status === "string"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class JobRegistry {
|
|
41
|
+
private readonly jobs = new Map<string, Job>();
|
|
42
|
+
private counter = 0;
|
|
43
|
+
private readonly baseDir: string;
|
|
44
|
+
|
|
45
|
+
constructor(baseDir: string) {
|
|
46
|
+
this.baseDir = baseDir;
|
|
47
|
+
mkdirSync(join(baseDir, "logs"), { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logPathFor(id: string): string {
|
|
51
|
+
return join(this.baseDir, "logs", `${id}.log`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
create(command: string, cwd: string): Job {
|
|
55
|
+
const id = `bg-${++this.counter}`;
|
|
56
|
+
const job: Job = {
|
|
57
|
+
id,
|
|
58
|
+
command,
|
|
59
|
+
cwd,
|
|
60
|
+
pid: null,
|
|
61
|
+
status: "running",
|
|
62
|
+
exitCode: null,
|
|
63
|
+
signal: null,
|
|
64
|
+
logPath: this.logPathFor(id),
|
|
65
|
+
startedAt: Date.now(),
|
|
66
|
+
endedAt: null,
|
|
67
|
+
auto: false,
|
|
68
|
+
delivered: false,
|
|
69
|
+
};
|
|
70
|
+
this.jobs.set(id, job);
|
|
71
|
+
return job;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(id: string): Job | undefined {
|
|
75
|
+
return this.jobs.get(id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
all(): Job[] {
|
|
79
|
+
return [...this.jobs.values()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
running(): Job[] {
|
|
83
|
+
return this.all().filter((j) => j.status === "running");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
persist(job: Job): void {
|
|
87
|
+
try {
|
|
88
|
+
const file = join(this.baseDir, `${job.id}.json`);
|
|
89
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
90
|
+
writeFileSync(tmp, JSON.stringify(job));
|
|
91
|
+
renameSync(tmp, file);
|
|
92
|
+
} catch {
|
|
93
|
+
// A registry we cannot persist still works for the live session.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Load persisted jobs and settle any whose process has since died. */
|
|
98
|
+
load(): void {
|
|
99
|
+
let files: string[];
|
|
100
|
+
try {
|
|
101
|
+
files = readdirSync(this.baseDir);
|
|
102
|
+
} catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const f of files) {
|
|
106
|
+
if (!f.endsWith(".json")) continue;
|
|
107
|
+
try {
|
|
108
|
+
const raw = JSON.parse(readFileSync(join(this.baseDir, f), "utf8"));
|
|
109
|
+
if (!isJob(raw)) continue;
|
|
110
|
+
const job = raw;
|
|
111
|
+
if (job.status === "running" && !isAlive(job.pid)) {
|
|
112
|
+
job.status = "done";
|
|
113
|
+
job.endedAt = job.endedAt ?? Date.now();
|
|
114
|
+
}
|
|
115
|
+
this.jobs.set(job.id, job);
|
|
116
|
+
const n = Number(job.id.replace(/^bg-/, ""));
|
|
117
|
+
if (Number.isFinite(n)) this.counter = Math.max(this.counter, n);
|
|
118
|
+
} catch {
|
|
119
|
+
// Skip a corrupt sidecar rather than fail the whole load.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn a shell command whose output streams to a log file, cross-platform.
|
|
3
|
+
*
|
|
4
|
+
* The obvious trick — hand the child the file's own descriptor as stdout/stderr
|
|
5
|
+
* so the kernel writes it with zero JS in the path — is POSIX-only. On Windows
|
|
6
|
+
* a numeric fd in `stdio` does not inherit the way it does on Unix (measured:
|
|
7
|
+
* the command exits 1 and the file stays empty), which is why the background-
|
|
8
|
+
* bash extensions that use it have no Windows story. This suite supports
|
|
9
|
+
* Windows, so we take the portable path: pipe stdout and stderr and write them
|
|
10
|
+
* into one log file ourselves.
|
|
11
|
+
*
|
|
12
|
+
* Losslessness then depends on draining the pipes, which we do on every chunk,
|
|
13
|
+
* and on not finalizing before the tail arrives: after the process exits we
|
|
14
|
+
* wait for both pipes to end, with a short grace timer so a quiet inherited
|
|
15
|
+
* handle (a Windows daemonized grandchild that never closes it) still releases.
|
|
16
|
+
*
|
|
17
|
+
* `detached` (POSIX) makes the child a process-group leader so its whole tree
|
|
18
|
+
* can be signalled (see kill.ts); `unref` keeps a running job from holding the
|
|
19
|
+
* host's event loop open. The job lives as long as the session does — it is
|
|
20
|
+
* killed on shutdown, not orphaned — so pipes owned by the parent are the right
|
|
21
|
+
* model. Zero dependencies: node:child_process + node:fs.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { createWriteStream } from "node:fs";
|
|
25
|
+
|
|
26
|
+
export interface Spawned {
|
|
27
|
+
pid: number | null;
|
|
28
|
+
/** Resolves once, when the process exits (after its output has drained) or fails to start. */
|
|
29
|
+
exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DRAIN_GRACE_MS = 150;
|
|
33
|
+
|
|
34
|
+
export function spawnToFile(
|
|
35
|
+
shell: string,
|
|
36
|
+
shellArgs: readonly string[],
|
|
37
|
+
command: string,
|
|
38
|
+
cwd: string,
|
|
39
|
+
env: NodeJS.ProcessEnv,
|
|
40
|
+
logPath: string,
|
|
41
|
+
): Spawned {
|
|
42
|
+
// Append so a re-attach or racing read never clips output already written.
|
|
43
|
+
const out = createWriteStream(logPath, { flags: "a" });
|
|
44
|
+
|
|
45
|
+
let child;
|
|
46
|
+
try {
|
|
47
|
+
child = spawn(shell, [...shellArgs, command], {
|
|
48
|
+
cwd,
|
|
49
|
+
env,
|
|
50
|
+
detached: process.platform !== "win32",
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
out.end();
|
|
56
|
+
return { pid: null, exit: Promise.resolve({ code: null, signal: null }) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pump = (s: NodeJS.ReadableStream | null) => {
|
|
60
|
+
s?.on("data", (chunk) => {
|
|
61
|
+
try {
|
|
62
|
+
out.write(chunk);
|
|
63
|
+
} catch {
|
|
64
|
+
// A closed sink must not crash the reader.
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
pump(child.stdout);
|
|
69
|
+
pump(child.stderr);
|
|
70
|
+
|
|
71
|
+
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
let info: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
|
74
|
+
let stdoutEnded = child.stdout === null;
|
|
75
|
+
let stderrEnded = child.stderr === null;
|
|
76
|
+
|
|
77
|
+
const finish = () => {
|
|
78
|
+
if (settled || !info) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
try {
|
|
81
|
+
out.end();
|
|
82
|
+
} catch {
|
|
83
|
+
// already closed
|
|
84
|
+
}
|
|
85
|
+
resolve(info);
|
|
86
|
+
};
|
|
87
|
+
const maybeFinish = () => {
|
|
88
|
+
if (info && stdoutEnded && stderrEnded) finish();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
child.stdout?.on("end", () => {
|
|
92
|
+
stdoutEnded = true;
|
|
93
|
+
maybeFinish();
|
|
94
|
+
});
|
|
95
|
+
child.stderr?.on("end", () => {
|
|
96
|
+
stderrEnded = true;
|
|
97
|
+
maybeFinish();
|
|
98
|
+
});
|
|
99
|
+
child.on("exit", (code, signal) => {
|
|
100
|
+
info = { code, signal };
|
|
101
|
+
maybeFinish();
|
|
102
|
+
// The pipes usually end right after exit; if one is held open by a
|
|
103
|
+
// detached grandchild, finalize anyway after a short grace.
|
|
104
|
+
const grace = setTimeout(() => {
|
|
105
|
+
stdoutEnded = true;
|
|
106
|
+
stderrEnded = true;
|
|
107
|
+
finish();
|
|
108
|
+
}, DRAIN_GRACE_MS);
|
|
109
|
+
grace.unref?.();
|
|
110
|
+
});
|
|
111
|
+
child.on("error", () => {
|
|
112
|
+
info = info ?? { code: null, signal: null };
|
|
113
|
+
stdoutEnded = true;
|
|
114
|
+
stderrEnded = true;
|
|
115
|
+
finish();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
child.unref();
|
|
120
|
+
return { pid: child.pid ?? null, exit };
|
|
121
|
+
}
|
package/src/tail.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the tail of a growing log without loading the whole file.
|
|
3
|
+
*
|
|
4
|
+
* A background command writes its stdout and stderr straight into a file (see
|
|
5
|
+
* spawn.ts), so the status tool must read it back cheaply — a chatty job can
|
|
6
|
+
* produce megabytes, and `readFileSync` on that just to show the last screen is
|
|
7
|
+
* how a poll turns into an OOM. So seek to the end and read a bounded window.
|
|
8
|
+
*
|
|
9
|
+
* The window can begin mid-character: a multibyte UTF-8 sequence split at the
|
|
10
|
+
* cut would decode to a replacement char, so when we did not start at byte 0 we
|
|
11
|
+
* drop the leading continuation bytes (0b10xxxxxx) until a real character
|
|
12
|
+
* boundary. Zero dependencies — node:fs only.
|
|
13
|
+
*/
|
|
14
|
+
import { openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
export interface Tail {
|
|
17
|
+
/** The decoded trailing text. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** True if bytes before the window were dropped. */
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
/** Total size of the file in bytes. */
|
|
22
|
+
bytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
export function readTail(path: string, maxBytes: number = DEFAULT_TAIL_BYTES): Tail {
|
|
28
|
+
let fd: number;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(path, "r");
|
|
31
|
+
} catch {
|
|
32
|
+
return { text: "", truncated: false, bytes: 0 };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const size = fstatSync(fd).size;
|
|
36
|
+
if (size === 0) return { text: "", truncated: false, bytes: 0 };
|
|
37
|
+
const start = size > maxBytes ? size - maxBytes : 0;
|
|
38
|
+
const len = size - start;
|
|
39
|
+
const buf = Buffer.allocUnsafe(len);
|
|
40
|
+
let read = 0;
|
|
41
|
+
while (read < len) {
|
|
42
|
+
const n = readSync(fd, buf, read, len - read, start + read);
|
|
43
|
+
if (n <= 0) break;
|
|
44
|
+
read += n;
|
|
45
|
+
}
|
|
46
|
+
let slice = buf.subarray(0, read);
|
|
47
|
+
if (start > 0) {
|
|
48
|
+
// Drop a leading partial UTF-8 char left by cutting mid-sequence.
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < slice.length && (slice[i]! & 0xc0) === 0x80) i++;
|
|
51
|
+
slice = slice.subarray(i);
|
|
52
|
+
}
|
|
53
|
+
return { text: slice.toString("utf8"), truncated: start > 0, bytes: size };
|
|
54
|
+
} catch {
|
|
55
|
+
return { text: "", truncated: false, bytes: 0 };
|
|
56
|
+
} finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Non-empty line count of a chunk of text. */
|
|
62
|
+
export function countLines(text: string): number {
|
|
63
|
+
if (!text) return 0;
|
|
64
|
+
return text.split("\n").filter((l) => l.trim() !== "").length;
|
|
65
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shapes for @pify/shell-background.
|
|
3
|
+
* No imports from pi packages: src/ typechecks and unit-tests standalone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type JobStatus = "running" | "done" | "failed" | "killed";
|
|
7
|
+
|
|
8
|
+
export interface Job {
|
|
9
|
+
/** Short session-monotonic id, e.g. "bg-1". */
|
|
10
|
+
id: string;
|
|
11
|
+
command: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
/** OS pid of the shell process; null before spawn or if spawn failed. */
|
|
14
|
+
pid: number | null;
|
|
15
|
+
status: JobStatus;
|
|
16
|
+
/** Process exit code, once finished. */
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
/** Terminating signal name, if the process was signalled. */
|
|
19
|
+
signal: string | null;
|
|
20
|
+
/** Absolute path of the merged stdout+stderr log file. */
|
|
21
|
+
logPath: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
endedAt: number | null;
|
|
24
|
+
/** True if it reached the background because it outran the auto-threshold, */
|
|
25
|
+
/** false if the caller asked for background up front. */
|
|
26
|
+
auto: boolean;
|
|
27
|
+
/** Whether the finished result has been delivered back to the conversation. */
|
|
28
|
+
delivered: boolean;
|
|
29
|
+
/** Set when we killed it (timeout/abort/shell_kill), so exit reads as killed. */
|
|
30
|
+
killedByUs?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A finished job is anything past running. */
|
|
38
|
+
export function isFinished(job: Job): boolean {
|
|
39
|
+
return job.status !== "running";
|
|
40
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aboveEditor box listing background jobs — running ones, and any that
|
|
3
|
+
* finished in the last 15s so a completion is visible before it disappears.
|
|
4
|
+
* Pure string builder; the extension wraps it in a pi-tui Text.
|
|
5
|
+
*/
|
|
6
|
+
import type { Job } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const WIDTH = 54;
|
|
9
|
+
const MAX_ROWS = 8;
|
|
10
|
+
|
|
11
|
+
export interface WidgetTheme {
|
|
12
|
+
fg(color: string, text: string): string;
|
|
13
|
+
bold(text: string): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function icon(job: Job): string {
|
|
17
|
+
switch (job.status) {
|
|
18
|
+
case "running":
|
|
19
|
+
return "⟳";
|
|
20
|
+
case "done":
|
|
21
|
+
return "✓";
|
|
22
|
+
case "failed":
|
|
23
|
+
return "✗";
|
|
24
|
+
default:
|
|
25
|
+
return "◼";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function color(job: Job): string {
|
|
30
|
+
switch (job.status) {
|
|
31
|
+
case "running":
|
|
32
|
+
return "warning";
|
|
33
|
+
case "done":
|
|
34
|
+
return "success";
|
|
35
|
+
case "failed":
|
|
36
|
+
return "error";
|
|
37
|
+
default:
|
|
38
|
+
return "dim";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function elapsed(job: Job, now: number): string {
|
|
43
|
+
const end = job.endedAt ?? now;
|
|
44
|
+
const s = Math.max(0, Math.round((end - job.startedAt) / 1000));
|
|
45
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clip(text: string, max: number): string {
|
|
49
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
50
|
+
return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildWidgetLines(jobs: Job[], theme: WidgetTheme, now: number): string[] {
|
|
54
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
55
|
+
const visible = jobs
|
|
56
|
+
.filter((j) => j.status === "running" || (j.endedAt ?? 0) > now - 15_000)
|
|
57
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
58
|
+
if (visible.length === 0) return [];
|
|
59
|
+
|
|
60
|
+
const running = visible.filter((j) => j.status === "running").length;
|
|
61
|
+
const title = ` ⚙ shell-bg · ${running} running `;
|
|
62
|
+
const hint = " /shell-bg ";
|
|
63
|
+
const pad = Math.max(1, WIDTH - title.length - hint.length);
|
|
64
|
+
const lines = [dim(`╭${title}${"─".repeat(pad)}${hint}╮`)];
|
|
65
|
+
|
|
66
|
+
const shown = visible.slice(-MAX_ROWS);
|
|
67
|
+
const hidden = visible.length - shown.length;
|
|
68
|
+
for (const j of shown) {
|
|
69
|
+
const c = color(j);
|
|
70
|
+
lines.push(
|
|
71
|
+
`${theme.fg(c, icon(j))} ${theme.fg(c, j.id)} ${dim(`· ${elapsed(j, now)}`)} ${dim(clip(j.command, 28))}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (hidden > 0) lines.push(dim(`│ … +${hidden} more`));
|
|
75
|
+
lines.push(dim(`╰${"─".repeat(WIDTH)}╯`));
|
|
76
|
+
return lines;
|
|
77
|
+
}
|