pi-background 0.1.1 → 0.2.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 +11 -0
- package/extensions/background.ts +52 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,11 +14,22 @@ pi install npm:pi-background
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
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
|
+
- 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`.
|
|
17
18
|
- 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.
|
|
18
19
|
- 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.
|
|
19
20
|
|
|
20
21
|
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".
|
|
21
22
|
|
|
23
|
+
## Settings
|
|
24
|
+
|
|
25
|
+
The running jobs line has three levels. Set one in `~/.pi/agent/settings.json`, for example `"background": { "jobs": "compact" }`.
|
|
26
|
+
|
|
27
|
+
| `jobs` | What it shows |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `full` (default) | `⏵ bg1 python3 train.py · 4m12s · epoch 3/10 loss 0.41` |
|
|
30
|
+
| `compact` | `⏵ bg1 python3 train.py · 4m12s` |
|
|
31
|
+
| `off` | Nothing |
|
|
32
|
+
|
|
22
33
|
## Tools
|
|
23
34
|
|
|
24
35
|
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.
|
package/extensions/background.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { createLocalBashOperations, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
|
|
8
8
|
const TAIL_LINES = 20;
|
|
@@ -11,6 +11,7 @@ const BASH_DEFAULT_TIMEOUT = 120;
|
|
|
11
11
|
const BASH_MAX_TIMEOUT = 600;
|
|
12
12
|
const WAKE_DEBOUNCE_MS = 1000;
|
|
13
13
|
const MAX_WAKES = 10;
|
|
14
|
+
const MAX_SHOWN_JOBS = 3;
|
|
14
15
|
|
|
15
16
|
interface Job {
|
|
16
17
|
id: string;
|
|
@@ -21,6 +22,8 @@ interface Job {
|
|
|
21
22
|
status: "running" | "exited" | "killed" | "failed";
|
|
22
23
|
exitCode?: number | null;
|
|
23
24
|
ended?: number;
|
|
25
|
+
// The latest output line, shown while the job runs
|
|
26
|
+
lastLine?: string;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function tail(path: string, lines: number): string {
|
|
@@ -42,6 +45,14 @@ function summary(job: Job): string {
|
|
|
42
45
|
return `${job.id} [${state}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/** The part of a command worth showing: its first line, without a leading cd and with a script path shortened to its name. */
|
|
49
|
+
function shownCommand(command: string): string {
|
|
50
|
+
return command
|
|
51
|
+
.split("\n")[0]
|
|
52
|
+
.replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
|
|
53
|
+
.replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
|
|
46
57
|
function startsBackgroundJob(command: string): boolean {
|
|
47
58
|
return /&/.test(command.replace(/&&|[0-9]*>&[0-9-]*|&>>?|\|&/g, ""));
|
|
@@ -101,6 +112,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
112
|
};
|
|
102
113
|
};
|
|
103
114
|
|
|
115
|
+
// One line per running job above the input, with its elapsed time and latest output
|
|
116
|
+
let ui: ExtensionContext["ui"] | undefined;
|
|
117
|
+
const running = () => [...jobs.values()].filter((job) => job.status === "running");
|
|
118
|
+
// "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
|
|
119
|
+
const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
|
|
120
|
+
const showJobs = () => {
|
|
121
|
+
if (!ui) return;
|
|
122
|
+
if (jobsLine() === "off" || running().length === 0) return ui.setWidget("background-jobs", undefined);
|
|
123
|
+
ui.setWidget("background-jobs", (tui, theme) => {
|
|
124
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
125
|
+
return {
|
|
126
|
+
dispose: () => clearInterval(timer),
|
|
127
|
+
invalidate() {},
|
|
128
|
+
render(width: number) {
|
|
129
|
+
const live = running();
|
|
130
|
+
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
131
|
+
const command = shownCommand(job.command);
|
|
132
|
+
const text =
|
|
133
|
+
theme.fg("accent", " ⏵ ") +
|
|
134
|
+
theme.fg("toolTitle", `${job.id} ${command.length > 50 ? `${command.slice(0, 49)}…` : command}`) +
|
|
135
|
+
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
136
|
+
return truncateToWidth(text, width);
|
|
137
|
+
});
|
|
138
|
+
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
139
|
+
return lines;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
104
145
|
const find = (id: string): Job => {
|
|
105
146
|
const job = jobs.get(id);
|
|
106
147
|
if (!job) throw new Error(`No background job ${id}; known: ${[...jobs.keys()].join(", ") || "none"}`);
|
|
@@ -140,6 +181,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
181
|
"After bg_run, continue with independent work or end your turn; a message with the exit code and log tail arrives automatically when the job exits, so never sleep, poll or tail the log to wait for it.",
|
|
141
182
|
"Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
|
|
142
183
|
"To react when a bg_run job prints something specific while it keeps running (a server is ready, an epoch finished, an error appeared), pass wake_on with a regex instead of polling.",
|
|
184
|
+
"Don't redirect a bg_run command's output to a file (> file, 2>&1, nohup): bg_run already saves all of it to the job's log, and redirecting hides the job's progress from the user and from wake_on. If the output should be kept, tell the user where the job's log is.",
|
|
143
185
|
"Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
|
|
144
186
|
`bash commands time out after ${BASH_DEFAULT_TIMEOUT / 60} minutes unless you pass timeout (at most ${BASH_MAX_TIMEOUT / 60} minutes); run anything that may take longer with bg_run instead.`,
|
|
145
187
|
"Unless the user explicitly asks for that bash command, never wait, poll or sleep in bash (while/until loops with sleep, watch, repeated status checks), not even as part of a larger command; start the wait with bg_run and pass wake_on to be woken when the awaited output appears.",
|
|
@@ -171,9 +213,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
171
213
|
const onData = (d: Buffer) => {
|
|
172
214
|
out.write(d);
|
|
173
215
|
watch?.(d);
|
|
216
|
+
// Progress bars redraw with \r, so the last segment of either kind of line break is the current state
|
|
217
|
+
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);
|
|
218
|
+
if (last) job.lastLine = last;
|
|
174
219
|
};
|
|
175
220
|
|
|
176
|
-
|
|
221
|
+
// Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
|
|
222
|
+
ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
|
|
177
223
|
.then(({ exitCode }) => {
|
|
178
224
|
job.status = "exited";
|
|
179
225
|
job.exitCode = exitCode;
|
|
@@ -184,6 +230,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
230
|
})
|
|
185
231
|
.finally(() => {
|
|
186
232
|
job.ended = Date.now();
|
|
233
|
+
showJobs();
|
|
187
234
|
out.end(() => {
|
|
188
235
|
// A kill from session shutdown has no session left to notify
|
|
189
236
|
if (job.status === "killed" && !jobs.has(id)) return;
|
|
@@ -195,6 +242,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
242
|
});
|
|
196
243
|
});
|
|
197
244
|
|
|
245
|
+
if (ctx.mode === "tui") ui = ctx.ui;
|
|
246
|
+
showJobs();
|
|
198
247
|
return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
|
|
199
248
|
},
|
|
200
249
|
});
|