pi-background 0.3.0 → 0.3.2
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 +1 -0
- package/extensions/background.ts +129 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ pi install npm:pi-background
|
|
|
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
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
|
+
- `/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.
|
|
18
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.
|
|
19
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.
|
|
20
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.
|
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
|
-
import { createLocalBashOperations, type ExtensionAPI
|
|
5
|
-
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { createLocalBashOperations, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
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;
|
|
@@ -46,17 +46,27 @@ function elapsed(job: Job): string {
|
|
|
46
46
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function state(job: Job): string {
|
|
50
|
+
return job.status === "exited" ? `exit ${job.exitCode}` : job.status;
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
function summary(job: Job): string {
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
return `${job.id} [${state(job)}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
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();
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
/** The part of a command worth showing: its first line, without a leading cd
|
|
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. */
|
|
55
64
|
function shownCommand(command: string): string {
|
|
56
|
-
|
|
65
|
+
const shown = command
|
|
57
66
|
.split("\n")[0]
|
|
58
67
|
.replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
|
|
59
68
|
.replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
|
|
69
|
+
return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
|
|
@@ -151,34 +161,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
151
161
|
};
|
|
152
162
|
|
|
153
163
|
// One line per running job above the input, with its elapsed time and latest output
|
|
154
|
-
let ui: ExtensionContext["ui"] | undefined;
|
|
155
164
|
const running = () => [...jobs.values()].filter((job) => job.status === "running");
|
|
165
|
+
// The job open in /bg, left out of the jobs line meanwhile so it isn't shown twice
|
|
166
|
+
let viewing: Job | undefined;
|
|
156
167
|
// "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
|
|
157
168
|
const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
|
|
169
|
+
// The widget is set once per session and redrawn in place: setting it again would move it below the widgets other
|
|
170
|
+
// extensions added since, while running jobs belong at the top, next to what Pi is doing now
|
|
171
|
+
let redrawJobs = () => {};
|
|
172
|
+
let ticker: NodeJS.Timeout | undefined;
|
|
158
173
|
const showJobs = () => {
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
render(width: number) {
|
|
167
|
-
const live = running();
|
|
168
|
-
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
169
|
-
const command = shownCommand(job.command);
|
|
170
|
-
const text =
|
|
171
|
-
theme.fg("accent", " ⏵ ") +
|
|
172
|
-
theme.fg("toolTitle", `${job.id} ${command.length > 50 ? `${command.slice(0, 49)}…` : command}`) +
|
|
173
|
-
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
174
|
-
return truncateToWidth(text, width);
|
|
175
|
-
});
|
|
176
|
-
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
177
|
-
return lines;
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
});
|
|
174
|
+
// A running job's elapsed time and latest output change every second
|
|
175
|
+
if (running().length > 0) ticker ??= setInterval(() => redrawJobs(), 1000);
|
|
176
|
+
else {
|
|
177
|
+
clearInterval(ticker);
|
|
178
|
+
ticker = undefined;
|
|
179
|
+
}
|
|
180
|
+
redrawJobs();
|
|
181
181
|
};
|
|
182
|
+
const jobsWidget = (theme: any) => ({
|
|
183
|
+
invalidate() {},
|
|
184
|
+
render(width: number) {
|
|
185
|
+
if (jobsLine() === "off") return [];
|
|
186
|
+
const live = running().filter((job) => job !== viewing);
|
|
187
|
+
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
188
|
+
const text =
|
|
189
|
+
theme.fg("accent", "⏵ ") +
|
|
190
|
+
theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
|
|
191
|
+
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
192
|
+
return truncateToWidth(text, width);
|
|
193
|
+
});
|
|
194
|
+
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
195
|
+
return lines;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
182
198
|
|
|
183
199
|
const find = (id: string): Job => {
|
|
184
200
|
const job = jobs.get(id);
|
|
@@ -186,16 +202,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
186
202
|
return job;
|
|
187
203
|
};
|
|
188
204
|
|
|
189
|
-
// Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering
|
|
205
|
+
// Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering: quiet grey once done,
|
|
206
|
+
// with a dot only while running or when something failed
|
|
190
207
|
pi.registerMessageRenderer("background-job", (message, { expanded }, theme) => {
|
|
191
208
|
const d = (message.details ?? {}) as { headline?: string; last?: string; error?: boolean };
|
|
192
|
-
const
|
|
209
|
+
const title = `Background ${d.headline ?? ""}`;
|
|
210
|
+
const head = d.error ? theme.fg("error", "● ") + theme.fg("toolTitle", title) : ` ${theme.fg("dim", title)}`;
|
|
193
211
|
const body = expanded ? String(message.content) : (d.last ?? "");
|
|
194
212
|
return new Text(`${head}\n${theme.fg("dim", ` └ ${body.replace(/\n/g, "\n ")}`)}`, 0, 0);
|
|
195
213
|
});
|
|
196
214
|
const row = (theme: any, text: string, context: any) => {
|
|
197
|
-
const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" :
|
|
198
|
-
return new Text(theme.fg(color, "● ") + theme.fg("toolTitle", text), 0, 0);
|
|
215
|
+
const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : undefined;
|
|
216
|
+
return new Text(color ? theme.fg(color, "● ") + theme.fg("toolTitle", text) : ` ${theme.fg("dim", text)}`, 0, 0);
|
|
199
217
|
};
|
|
200
218
|
const note = (theme: any, result: any, expanded: boolean) => {
|
|
201
219
|
const text = result.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
|
|
@@ -218,13 +236,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
236
|
"When unsure whether a command is quick, prefer bg_run: a quick job just reports back sooner, while a long bash command blocks the user until it finishes.",
|
|
219
237
|
"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.",
|
|
220
238
|
"Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
|
|
221
|
-
"
|
|
239
|
+
"Pass wake_on only when you must act before a bg_run job ends, such as a server becoming ready to test against, or when the user asked to follow its progress. To report a job's result, leave wake_on out: the exit message already brings the exit code and last lines, so waking on every epoch or step only adds turns.",
|
|
222
240
|
"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.",
|
|
223
241
|
"bg_run starts only after the other tool calls in the same reply have finished, so editing a file and starting a job that uses it in one reply is safe.",
|
|
224
242
|
"Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
|
|
225
243
|
`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.`,
|
|
226
244
|
"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.",
|
|
227
|
-
"Unless the user explicitly asks otherwise, start long-running processes (training or evaluation runs, servers, watchers) with bg_run, not with & or nohup in bash followed by sleep
|
|
245
|
+
"Unless the user explicitly asks otherwise, start long-running processes (training or evaluation runs, servers, watchers) with bg_run, not with & or nohup in bash followed by sleep.",
|
|
228
246
|
],
|
|
229
247
|
// Runs after the edits in the same reply, so the job starts from the code pi just wrote
|
|
230
248
|
executionMode: "sequential",
|
|
@@ -236,7 +254,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
254
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
|
|
237
255
|
wake_on: Type.Optional(
|
|
238
256
|
Type.String({
|
|
239
|
-
description: "Regex; also wake you while the job runs whenever an output line matches, e.g. 'listening on|ready'
|
|
257
|
+
description: "Regex; also wake you while the job runs whenever an output line matches, e.g. 'listening on|ready'. Matches within a second are batched; at most 10 wake-ups per job.",
|
|
240
258
|
}),
|
|
241
259
|
),
|
|
242
260
|
}),
|
|
@@ -254,8 +272,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
254
272
|
const onData = (d: Buffer) => {
|
|
255
273
|
out.write(d);
|
|
256
274
|
watch?.(d);
|
|
257
|
-
|
|
258
|
-
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);
|
|
275
|
+
const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
|
|
259
276
|
if (last) job.lastLine = last;
|
|
260
277
|
};
|
|
261
278
|
|
|
@@ -283,7 +300,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
283
300
|
});
|
|
284
301
|
});
|
|
285
302
|
|
|
286
|
-
if (ctx.mode === "tui") ui = ctx.ui;
|
|
287
303
|
showJobs();
|
|
288
304
|
return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
|
|
289
305
|
},
|
|
@@ -326,7 +342,80 @@ export default function (pi: ExtensionAPI) {
|
|
|
326
342
|
},
|
|
327
343
|
});
|
|
328
344
|
|
|
345
|
+
// Shows a job's latest output in place of the input until esc, refreshed every second while it runs
|
|
346
|
+
pi.registerCommand("bg", {
|
|
347
|
+
description: "Show a background job's latest output",
|
|
348
|
+
getArgumentCompletions: (prefix) => {
|
|
349
|
+
const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
|
|
350
|
+
return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
|
|
351
|
+
},
|
|
352
|
+
handler: async (args, ctx) => {
|
|
353
|
+
if (!ctx.hasUI) return;
|
|
354
|
+
const all = [...jobs.values()].reverse();
|
|
355
|
+
if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
|
|
356
|
+
// "/bg 2" works as well as "/bg bg2"
|
|
357
|
+
const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
|
|
358
|
+
let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
|
|
359
|
+
if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
|
|
360
|
+
if (!job) {
|
|
361
|
+
// Matched by position, since the elapsed time in a label changes while the list is open
|
|
362
|
+
const options = all.map((other) => `${other.id} ${shownCommand(other.command)} · ${state(other)} · ${elapsed(other)}`);
|
|
363
|
+
job = all[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
|
|
364
|
+
if (!job) return;
|
|
365
|
+
}
|
|
366
|
+
const shown = job;
|
|
367
|
+
viewing = shown;
|
|
368
|
+
const log = shown.log.replace(homedir(), "~");
|
|
369
|
+
try {
|
|
370
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
371
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
372
|
+
return {
|
|
373
|
+
dispose: () => clearInterval(timer),
|
|
374
|
+
invalidate() {},
|
|
375
|
+
handleInput: (data: string) => {
|
|
376
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
|
|
377
|
+
},
|
|
378
|
+
render(width: number) {
|
|
379
|
+
const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
|
|
380
|
+
const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
|
|
381
|
+
const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
|
|
382
|
+
const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
|
|
383
|
+
// At most half the terminal, so the conversation above stays in view
|
|
384
|
+
const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
|
|
385
|
+
const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
|
|
386
|
+
return [
|
|
387
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
388
|
+
title + " ".repeat(gap) + status,
|
|
389
|
+
...output,
|
|
390
|
+
theme.fg("dim", ` esc to close · ${log}`),
|
|
391
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
392
|
+
].map((line) => truncateToWidth(line, width));
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
} finally {
|
|
397
|
+
viewing = undefined;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
|
|
403
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
404
|
+
if (ctx.mode === "tui") {
|
|
405
|
+
ctx.ui.setWidget("background-jobs", (tui, theme) => {
|
|
406
|
+
redrawJobs = () => tui.requestRender();
|
|
407
|
+
return jobsWidget(theme);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
411
|
+
const msg = entry.type === "message" ? (entry.message as any) : undefined;
|
|
412
|
+
const n = Number(/^bg(\d+)$/.exec(msg?.role === "toolResult" && msg.toolName === "bg_run" ? String(msg.details?.id) : "")?.[1]);
|
|
413
|
+
if (n >= next) next = n + 1;
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
329
417
|
pi.on("session_shutdown", async () => {
|
|
418
|
+
clearInterval(ticker);
|
|
330
419
|
for (const timer of holds.values()) clearTimeout(timer);
|
|
331
420
|
holds.clear();
|
|
332
421
|
for (const job of jobs.values()) {
|