pi-background 0.2.2 → 0.3.1
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 +8 -6
- package/extensions/background.ts +131 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-background
|
|
2
2
|
|
|
3
|
-
Lets [
|
|
3
|
+
Lets [Pi](https://pi.dev) run long commands in the background instead of waiting on them. Pi starts the job, carries on or hands back to you, and gets a message when the job finishes or prints something it's waiting for.
|
|
4
4
|
|
|
5
5
|
<picture>
|
|
6
6
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pringled/pi-extensions/main/assets/images/pi-background-dark.gif">
|
|
@@ -13,12 +13,14 @@ pi install npm:pi-background
|
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
- Long commands like downloads, builds, test suites and training runs go to the background, and
|
|
16
|
+
- Long commands like downloads, builds, test suites and training runs go to the background, and Pi hears back with the exit code and the end of the output when they finish.
|
|
17
17
|
- While a job runs, a line above the input shows it with its elapsed time and latest output, such as `⏵ bg1 python3 train.py · 4m12s · epoch 3/10 loss 0.41`.
|
|
18
|
-
-
|
|
19
|
-
-
|
|
18
|
+
- `/bg` shows a job's last 20 lines of output, updating while it runs (`/bg bg2` for a specific job). Press esc to close it.
|
|
19
|
+
- Pi can wait for a specific line, such as a dev server's `listening on` or a training run's `epoch 3`, without checking again and again.
|
|
20
|
+
- Jobs started together are reported together: when Pi starts several jobs at once, such as benchmarks to compare, it's woken once when the last one finishes instead of after each. You can keep chatting meanwhile, and a job that runs much longer doesn't hold the others back for more than 2 minutes.
|
|
21
|
+
- Regular commands time out after 2 minutes, unless Pi asks for longer (up to 10), so Pi never hangs on a stuck command. Commands that start their own background process with `&` are left alone.
|
|
20
22
|
|
|
21
|
-
You don't need to do anything differently:
|
|
23
|
+
You don't need to do anything differently: Pi decides when a job belongs in the background. You can also ask for it, for example "run the tests in the background and tell me when they're done".
|
|
22
24
|
|
|
23
25
|
## Settings
|
|
24
26
|
|
|
@@ -32,7 +34,7 @@ The running jobs line has three levels. Set one in `~/.pi/agent/settings.json`,
|
|
|
32
34
|
|
|
33
35
|
## Tools
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
Pi gets three tools for this: `bg_run` starts a job, `bg_status` shows how it's doing and its latest output, and `bg_kill` stops it. Jobs run with the same environment and sandbox as Pi's own shell commands, and stop when you close Pi.
|
|
36
38
|
|
|
37
39
|
## License
|
|
38
40
|
|
package/extensions/background.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Key, matchesKey, stripTerminalSequences, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
|
|
8
8
|
const TAIL_LINES = 20;
|
|
@@ -12,6 +12,8 @@ const BASH_MAX_TIMEOUT = 600;
|
|
|
12
12
|
const WAKE_DEBOUNCE_MS = 1000;
|
|
13
13
|
const MAX_WAKES = 10;
|
|
14
14
|
const MAX_SHOWN_JOBS = 3;
|
|
15
|
+
// How long finished results wait for jobs started alongside them before Pi is woken anyway
|
|
16
|
+
const GROUP_HOLD_MS = 2 * 60_000;
|
|
15
17
|
|
|
16
18
|
interface Job {
|
|
17
19
|
id: string;
|
|
@@ -24,6 +26,10 @@ interface Job {
|
|
|
24
26
|
ended?: number;
|
|
25
27
|
// The latest output line, shown while the job runs
|
|
26
28
|
lastLine?: string;
|
|
29
|
+
// The turn that started the job; jobs from the same turn are reported together
|
|
30
|
+
group: number;
|
|
31
|
+
// Started with wake_on, so it may run indefinitely and doesn't hold back the others
|
|
32
|
+
watched: boolean;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
function tail(path: string, lines: number): string {
|
|
@@ -40,17 +46,27 @@ function elapsed(job: Job): string {
|
|
|
40
46
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
function state(job: Job): string {
|
|
50
|
+
return job.status === "exited" ? `exit ${job.exitCode}` : job.status;
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
function summary(job: Job): string {
|
|
44
|
-
|
|
45
|
-
return `${job.id} [${state}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
|
|
54
|
+
return `${job.id} [${state(job)}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
/**
|
|
57
|
+
/** An output line as a terminal would last have shown it: without escape codes or control characters, and a progress bar redrawn with \r in its latest state only. */
|
|
58
|
+
function cleanLine(line: string): string {
|
|
59
|
+
const shown = stripTerminalSequences(line).split("\r").filter((part) => part.trim()).at(-1) ?? "";
|
|
60
|
+
return shown.replace(/\t/g, " ").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trimEnd();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The part of a command worth showing: its first line, without a leading cd, with a script path shortened to its name and at most 50 characters. */
|
|
49
64
|
function shownCommand(command: string): string {
|
|
50
|
-
|
|
65
|
+
const shown = command
|
|
51
66
|
.split("\n")[0]
|
|
52
67
|
.replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
|
|
53
68
|
.replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
|
|
69
|
+
return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
/** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
|
|
@@ -79,8 +95,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
79
95
|
const ops = createLocalBashOperations();
|
|
80
96
|
let next = 1;
|
|
81
97
|
|
|
82
|
-
const wake = (content: string, details: { headline: string; last: string; error: boolean }) =>
|
|
83
|
-
pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn
|
|
98
|
+
const wake = (content: string, details: { headline: string; last: string; error: boolean }, triggerTurn = true) =>
|
|
99
|
+
pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn, deliverAs: "followUp" });
|
|
100
|
+
|
|
101
|
+
// Jobs started in the same turn usually belong together, like several benchmarks to compare. While some of them
|
|
102
|
+
// still run, a successful one's result is added to the conversation without waking Pi, so it answers once at the
|
|
103
|
+
// end instead of after each job. A job that runs on too long doesn't hold the others back for more than GROUP_HOLD_MS.
|
|
104
|
+
let turn = 0;
|
|
105
|
+
pi.on("turn_start", async () => {
|
|
106
|
+
turn++;
|
|
107
|
+
});
|
|
108
|
+
const holds = new Map<number, NodeJS.Timeout>();
|
|
109
|
+
const runningInGroup = (job: Job) =>
|
|
110
|
+
[...jobs.values()].filter((other) => other !== job && other.group === job.group && other.status === "running" && !other.watched);
|
|
111
|
+
const report = (job: Job, content: string, details: { headline: string; last: string; error: boolean }) => {
|
|
112
|
+
const others = job.watched ? [] : runningInGroup(job);
|
|
113
|
+
// A failure wakes Pi straight away, so it can react before the rest of the group finishes
|
|
114
|
+
if (others.length === 0 || details.error) {
|
|
115
|
+
clearTimeout(holds.get(job.group));
|
|
116
|
+
holds.delete(job.group);
|
|
117
|
+
return wake(content, details);
|
|
118
|
+
}
|
|
119
|
+
wake(`${content}\n(Still running from the same turn: ${others.map((other) => other.id).join(", ")}; you'll be woken when they finish.)`, details, false);
|
|
120
|
+
if (holds.has(job.group)) return;
|
|
121
|
+
holds.set(job.group, setTimeout(() => {
|
|
122
|
+
holds.delete(job.group);
|
|
123
|
+
const still = runningInGroup(job);
|
|
124
|
+
if (still.length === 0) return;
|
|
125
|
+
wake(`Background jobs from the same turn are still running after ${GROUP_HOLD_MS / 60_000} minutes: ${still.map((other) => other.id).join(", ")}. The results of the ones that finished are above; you'll get a message when each of these finishes.`, {
|
|
126
|
+
headline: `${still.map((other) => other.id).join(", ")} still running`,
|
|
127
|
+
last: "",
|
|
128
|
+
error: false,
|
|
129
|
+
});
|
|
130
|
+
}, GROUP_HOLD_MS));
|
|
131
|
+
};
|
|
84
132
|
|
|
85
133
|
// Wakes the agent with output lines matching pattern, batched per second and capped per job
|
|
86
134
|
const watcher = (job: Job, pattern: RegExp) => {
|
|
@@ -115,6 +163,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
163
|
// One line per running job above the input, with its elapsed time and latest output
|
|
116
164
|
let ui: ExtensionContext["ui"] | undefined;
|
|
117
165
|
const running = () => [...jobs.values()].filter((job) => job.status === "running");
|
|
166
|
+
// The job open in /bg, left out of the jobs line meanwhile so it isn't shown twice
|
|
167
|
+
let viewing: Job | undefined;
|
|
118
168
|
// "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
|
|
119
169
|
const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
|
|
120
170
|
const showJobs = () => {
|
|
@@ -126,12 +176,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
126
176
|
dispose: () => clearInterval(timer),
|
|
127
177
|
invalidate() {},
|
|
128
178
|
render(width: number) {
|
|
129
|
-
const live = running();
|
|
179
|
+
const live = running().filter((job) => job !== viewing);
|
|
130
180
|
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
131
|
-
const command = shownCommand(job.command);
|
|
132
181
|
const text =
|
|
133
182
|
theme.fg("accent", " ⏵ ") +
|
|
134
|
-
theme.fg("toolTitle", `${job.id} ${
|
|
183
|
+
theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
|
|
135
184
|
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
136
185
|
return truncateToWidth(text, width);
|
|
137
186
|
});
|
|
@@ -209,15 +258,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
209
258
|
mkdirSync(dir, { recursive: true });
|
|
210
259
|
const log = join(dir, `${process.pid}-${id}.log`);
|
|
211
260
|
const out = createWriteStream(log);
|
|
212
|
-
const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running" };
|
|
261
|
+
const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running", group: turn, watched: Boolean(pattern) };
|
|
213
262
|
jobs.set(id, job);
|
|
214
263
|
|
|
215
264
|
const watch = pattern ? watcher(job, pattern) : undefined;
|
|
216
265
|
const onData = (d: Buffer) => {
|
|
217
266
|
out.write(d);
|
|
218
267
|
watch?.(d);
|
|
219
|
-
|
|
220
|
-
const last = d.toString("utf8").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").split(/\r\n|\r|\n/).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
268
|
+
const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
|
|
221
269
|
if (last) job.lastLine = last;
|
|
222
270
|
};
|
|
223
271
|
|
|
@@ -237,7 +285,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
285
|
out.end(() => {
|
|
238
286
|
// A kill from session shutdown has no session left to notify
|
|
239
287
|
if (job.status === "killed" && !jobs.has(id)) return;
|
|
240
|
-
|
|
288
|
+
report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
|
|
241
289
|
headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
|
|
242
290
|
last: tail(log, 1),
|
|
243
291
|
error: job.status !== "exited" || job.exitCode !== 0,
|
|
@@ -288,7 +336,75 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
336
|
},
|
|
289
337
|
});
|
|
290
338
|
|
|
339
|
+
// Shows a job's latest output in place of the input until esc, refreshed every second while it runs
|
|
340
|
+
pi.registerCommand("bg", {
|
|
341
|
+
description: "Show a background job's latest output",
|
|
342
|
+
getArgumentCompletions: (prefix) => {
|
|
343
|
+
const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
|
|
344
|
+
return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
|
|
345
|
+
},
|
|
346
|
+
handler: async (args, ctx) => {
|
|
347
|
+
if (!ctx.hasUI) return;
|
|
348
|
+
const all = [...jobs.values()].reverse();
|
|
349
|
+
if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
|
|
350
|
+
// "/bg 2" works as well as "/bg bg2"
|
|
351
|
+
const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
|
|
352
|
+
let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
|
|
353
|
+
if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
|
|
354
|
+
if (!job) {
|
|
355
|
+
// Matched by position, since the elapsed time in a label changes while the list is open
|
|
356
|
+
const options = all.map((other) => `${other.id} ${shownCommand(other.command)} · ${state(other)} · ${elapsed(other)}`);
|
|
357
|
+
job = all[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
|
|
358
|
+
if (!job) return;
|
|
359
|
+
}
|
|
360
|
+
const shown = job;
|
|
361
|
+
viewing = shown;
|
|
362
|
+
const log = shown.log.replace(homedir(), "~");
|
|
363
|
+
try {
|
|
364
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
365
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
366
|
+
return {
|
|
367
|
+
dispose: () => clearInterval(timer),
|
|
368
|
+
invalidate() {},
|
|
369
|
+
handleInput: (data: string) => {
|
|
370
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
|
|
371
|
+
},
|
|
372
|
+
render(width: number) {
|
|
373
|
+
const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
|
|
374
|
+
const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
|
|
375
|
+
const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
|
|
376
|
+
const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
|
|
377
|
+
// At most half the terminal, so the conversation above stays in view
|
|
378
|
+
const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
|
|
379
|
+
const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
|
|
380
|
+
return [
|
|
381
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
382
|
+
title + " ".repeat(gap) + status,
|
|
383
|
+
...output,
|
|
384
|
+
theme.fg("dim", ` esc to close · ${log}`),
|
|
385
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
386
|
+
].map((line) => truncateToWidth(line, width));
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
viewing = undefined;
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
|
|
397
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
398
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
399
|
+
const msg = entry.type === "message" ? (entry.message as any) : undefined;
|
|
400
|
+
const n = Number(/^bg(\d+)$/.exec(msg?.role === "toolResult" && msg.toolName === "bg_run" ? String(msg.details?.id) : "")?.[1]);
|
|
401
|
+
if (n >= next) next = n + 1;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
291
405
|
pi.on("session_shutdown", async () => {
|
|
406
|
+
for (const timer of holds.values()) clearTimeout(timer);
|
|
407
|
+
holds.clear();
|
|
292
408
|
for (const job of jobs.values()) {
|
|
293
409
|
if (job.status === "running") job.controller.abort();
|
|
294
410
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-background",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Thomas van Dongen",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"README.md",
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
|
-
"description": "Lets
|
|
16
|
+
"description": "Lets Pi run long commands in the background and wakes it when they finish, so it never blocks or hangs",
|
|
17
17
|
"keywords": [
|
|
18
18
|
"pi-package",
|
|
19
19
|
"pi",
|