pi-background 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/extensions/background.ts +43 -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 {
|
|
@@ -101,6 +104,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
104
|
};
|
|
102
105
|
};
|
|
103
106
|
|
|
107
|
+
// One line per running job above the input, with its elapsed time and latest output
|
|
108
|
+
let ui: ExtensionContext["ui"] | undefined;
|
|
109
|
+
const running = () => [...jobs.values()].filter((job) => job.status === "running");
|
|
110
|
+
// "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
|
|
111
|
+
const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
|
|
112
|
+
const showJobs = () => {
|
|
113
|
+
if (!ui) return;
|
|
114
|
+
if (jobsLine() === "off" || running().length === 0) return ui.setWidget("background-jobs", undefined);
|
|
115
|
+
ui.setWidget("background-jobs", (tui, theme) => {
|
|
116
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
117
|
+
return {
|
|
118
|
+
dispose: () => clearInterval(timer),
|
|
119
|
+
invalidate() {},
|
|
120
|
+
render(width: number) {
|
|
121
|
+
const live = running();
|
|
122
|
+
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
123
|
+
const command = job.command.split("\n")[0];
|
|
124
|
+
const text =
|
|
125
|
+
theme.fg("accent", " ⏵ ") +
|
|
126
|
+
theme.fg("toolTitle", `${job.id} ${command.length > 40 ? `${command.slice(0, 39)}…` : command}`) +
|
|
127
|
+
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
128
|
+
return truncateToWidth(text, width);
|
|
129
|
+
});
|
|
130
|
+
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
131
|
+
return lines;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
104
137
|
const find = (id: string): Job => {
|
|
105
138
|
const job = jobs.get(id);
|
|
106
139
|
if (!job) throw new Error(`No background job ${id}; known: ${[...jobs.keys()].join(", ") || "none"}`);
|
|
@@ -171,9 +204,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
171
204
|
const onData = (d: Buffer) => {
|
|
172
205
|
out.write(d);
|
|
173
206
|
watch?.(d);
|
|
207
|
+
// Progress bars redraw with \r, so the last segment of either kind of line break is the current state
|
|
208
|
+
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);
|
|
209
|
+
if (last) job.lastLine = last;
|
|
174
210
|
};
|
|
175
211
|
|
|
176
|
-
|
|
212
|
+
// Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
|
|
213
|
+
ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
|
|
177
214
|
.then(({ exitCode }) => {
|
|
178
215
|
job.status = "exited";
|
|
179
216
|
job.exitCode = exitCode;
|
|
@@ -184,6 +221,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
221
|
})
|
|
185
222
|
.finally(() => {
|
|
186
223
|
job.ended = Date.now();
|
|
224
|
+
showJobs();
|
|
187
225
|
out.end(() => {
|
|
188
226
|
// A kill from session shutdown has no session left to notify
|
|
189
227
|
if (job.status === "killed" && !jobs.has(id)) return;
|
|
@@ -195,6 +233,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
233
|
});
|
|
196
234
|
});
|
|
197
235
|
|
|
236
|
+
if (ctx.mode === "tui") ui = ctx.ui;
|
|
237
|
+
showJobs();
|
|
198
238
|
return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
|
|
199
239
|
},
|
|
200
240
|
});
|