pi-background 0.3.2 → 0.4.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 +3 -3
- package/extensions/background.ts +290 -124
- package/extensions/command.ts +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,11 +14,11 @@ 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`.
|
|
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.
|
|
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`. A job that has printed nothing for 10 minutes shows `no output for 12m30s` instead, so a stuck one stands out.
|
|
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. Its list shows running jobs first, then the 10 latest finished ones.
|
|
19
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
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
|
-
-
|
|
21
|
+
- A regular shell command still running after 2 minutes moves to the background on its own, so a slow command Pi didn't expect never holds up the turn. It shows in the jobs line, and Pi hears back when it ends. Press `ctrl+b` to move a running command to the background sooner. If another extension already replaces Pi's bash, such as a sandbox, pi-background leaves it alone.
|
|
22
22
|
|
|
23
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".
|
|
24
24
|
|
package/extensions/background.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { createLocalBashOperations, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Key, matchesKey, stripTerminalSequences,
|
|
4
|
+
import { type BashOperations, createBashToolDefinition, createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
|
+
import { shortCommand } from "./command.ts";
|
|
7
8
|
|
|
8
9
|
const TAIL_LINES = 20;
|
|
9
10
|
const TAIL_BYTES = 64 * 1024;
|
|
10
|
-
|
|
11
|
-
const
|
|
11
|
+
// A bash command still running after this long moves to the background, so it no longer blocks the turn
|
|
12
|
+
const AUTO_BACKGROUND_SECS = 120;
|
|
12
13
|
const WAKE_DEBOUNCE_MS = 1000;
|
|
13
14
|
const MAX_WAKES = 10;
|
|
14
15
|
const MAX_SHOWN_JOBS = 3;
|
|
16
|
+
// A running job silent this long shows how long it has been quiet, since it may be stuck
|
|
17
|
+
const SILENT_AFTER_MS = 10 * 60_000;
|
|
18
|
+
// Finished jobs listed by /bg; older ones stay reachable as /bg <id>
|
|
19
|
+
const MAX_LISTED_FINISHED = 10;
|
|
20
|
+
// How long a bash command runs before the jobs line offers ctrl+b, so quick commands don't flash it
|
|
21
|
+
const HINT_AFTER_MS = 3000;
|
|
15
22
|
// How long finished results wait for jobs started alongside them before Pi is woken anyway
|
|
16
23
|
const GROUP_HOLD_MS = 2 * 60_000;
|
|
17
24
|
|
|
@@ -26,6 +33,8 @@ interface Job {
|
|
|
26
33
|
ended?: number;
|
|
27
34
|
// The latest output line, shown while the job runs
|
|
28
35
|
lastLine?: string;
|
|
36
|
+
// When the job last printed anything
|
|
37
|
+
lastOutput?: number;
|
|
29
38
|
// The turn that started the job; jobs from the same turn are reported together
|
|
30
39
|
group: number;
|
|
31
40
|
// Started with wake_on, so it may run indefinitely and doesn't hold back the others
|
|
@@ -41,9 +50,16 @@ function tail(path: string, lines: number): string {
|
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
/** 45s, 12m30s, or 1h20m once seconds no longer matter. */
|
|
54
|
+
function duration(ms: number): string {
|
|
55
|
+
const s = Math.round(ms / 1000);
|
|
56
|
+
if (s < 60) return `${s}s`;
|
|
57
|
+
if (s < 3600) return `${Math.floor(s / 60)}m${s % 60}s`;
|
|
58
|
+
return `${Math.floor(s / 3600)}h${Math.floor(s / 60) % 60}m`;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
function elapsed(job: Job): string {
|
|
45
|
-
|
|
46
|
-
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
|
|
62
|
+
return duration((job.ended ?? Date.now()) - job.started);
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
function state(job: Job): string {
|
|
@@ -65,32 +81,26 @@ function shownCommand(command: string): string {
|
|
|
65
81
|
const shown = command
|
|
66
82
|
.split("\n")[0]
|
|
67
83
|
.replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
|
|
84
|
+
// Leading settings such as PYTHONPATH=/some/src, which the path shortening below would cut at their last /
|
|
85
|
+
.replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "")
|
|
68
86
|
.replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
|
|
69
87
|
return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
/** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
|
|
73
|
-
function startsBackgroundJob(command: string): boolean {
|
|
74
|
-
return /&/.test(command.replace(/&&|[0-9]*>&[0-9-]*|&>>?|\|&/g, ""));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
90
|
/** Compiles wake_on, accepting Python's (?i) prefix that models often write but JavaScript only takes as a flag. */
|
|
78
91
|
function wakePattern(source: string): RegExp {
|
|
79
92
|
const insensitive = source.startsWith("(?i)");
|
|
80
93
|
return new RegExp(insensitive ? source.slice(4) : source, insensitive ? "i" : "");
|
|
81
94
|
}
|
|
82
95
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const input = event.input as { command?: string; timeout?: number };
|
|
90
|
-
if (startsBackgroundJob(String(input.command ?? ""))) delete input.timeout;
|
|
91
|
-
else input.timeout = Math.min(input.timeout ?? BASH_DEFAULT_TIMEOUT, BASH_MAX_TIMEOUT);
|
|
92
|
-
});
|
|
96
|
+
/** Thrown by the bash operations below when a command moves to the background, carrying the job and its output so far. */
|
|
97
|
+
class MovedToBackground extends Error {
|
|
98
|
+
constructor(readonly job: { id: string; log: string; started: number }, readonly output: string) {
|
|
99
|
+
super(`moved to background as ${job.id}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
93
102
|
|
|
103
|
+
export default function (pi: ExtensionAPI) {
|
|
94
104
|
const jobs = new Map<string, Job>();
|
|
95
105
|
const ops = createLocalBashOperations();
|
|
96
106
|
let next = 1;
|
|
@@ -110,6 +120,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
110
120
|
[...jobs.values()].filter((other) => other !== job && other.group === job.group && other.status === "running" && !other.watched);
|
|
111
121
|
const report = (job: Job, content: string, details: { headline: string; last: string; error: boolean }) => {
|
|
112
122
|
const others = job.watched ? [] : runningInGroup(job);
|
|
123
|
+
// Only Pi stops jobs, with bg_kill, so it already knows; the message is added without waking it again
|
|
124
|
+
if (job.status === "killed") {
|
|
125
|
+
if (others.length === 0) {
|
|
126
|
+
clearTimeout(holds.get(job.group));
|
|
127
|
+
holds.delete(job.group);
|
|
128
|
+
}
|
|
129
|
+
return wake(content, details, false);
|
|
130
|
+
}
|
|
113
131
|
// A failure wakes Pi straight away, so it can react before the rest of the group finishes
|
|
114
132
|
if (others.length === 0 || details.error) {
|
|
115
133
|
clearTimeout(holds.get(job.group));
|
|
@@ -179,19 +197,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
197
|
}
|
|
180
198
|
redrawJobs();
|
|
181
199
|
};
|
|
200
|
+
// Running bash commands that ctrl+b can move to the background now, by tool call id, once they have run for
|
|
201
|
+
// HINT_AFTER_MS. Shared so pi-polish can show the hint under the command's row instead of in the jobs line.
|
|
202
|
+
const hinted = new Map<string, () => void>();
|
|
203
|
+
(globalThis as any)[Symbol.for("pi-background.movable")] = hinted;
|
|
204
|
+
let stopCtrlB = () => {};
|
|
182
205
|
const jobsWidget = (theme: any) => ({
|
|
183
206
|
invalidate() {},
|
|
184
207
|
render(width: number) {
|
|
185
208
|
if (jobsLine() === "off") return [];
|
|
186
209
|
const live = running().filter((job) => job !== viewing);
|
|
187
210
|
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
211
|
+
const silent = Date.now() - (job.lastOutput ?? job.started);
|
|
212
|
+
const detail = silent >= SILENT_AFTER_MS ? ` · no output for ${duration(silent)}` : jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : "";
|
|
213
|
+
const text = theme.fg("accent", "⏵ ") + theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) + theme.fg("dim", ` · ${elapsed(job)}${detail}`);
|
|
192
214
|
return truncateToWidth(text, width);
|
|
193
215
|
});
|
|
194
216
|
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
217
|
+
if (hinted.size > 0 && !(globalThis as any)[Symbol.for("pi-polish.showsMoveHint")]) lines.push(theme.fg("dim", " ctrl+b to run in background"));
|
|
195
218
|
return lines;
|
|
196
219
|
},
|
|
197
220
|
});
|
|
@@ -204,24 +227,179 @@ export default function (pi: ExtensionAPI) {
|
|
|
204
227
|
|
|
205
228
|
// Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering: quiet grey once done,
|
|
206
229
|
// with a dot only while running or when something failed
|
|
230
|
+
// " └ text" in dim, with long and extra lines wrapped under the text instead of at the left edge
|
|
231
|
+
const branch = (theme: any, text: string, head?: string) => ({
|
|
232
|
+
invalidate() {},
|
|
233
|
+
render: (width: number) => [
|
|
234
|
+
...(head ? [truncateToWidth(head, width)] : []),
|
|
235
|
+
...wrapTextWithAnsi(text, Math.max(10, width - 4)).map((line, i) => theme.fg("dim", `${i === 0 ? " └ " : " "}${line}`)),
|
|
236
|
+
],
|
|
237
|
+
});
|
|
207
238
|
pi.registerMessageRenderer("background-job", (message, { expanded }, theme) => {
|
|
208
239
|
const d = (message.details ?? {}) as { headline?: string; last?: string; error?: boolean };
|
|
209
240
|
const title = `Background ${d.headline ?? ""}`;
|
|
210
241
|
const head = d.error ? theme.fg("error", "● ") + theme.fg("toolTitle", title) : ` ${theme.fg("dim", title)}`;
|
|
211
|
-
|
|
212
|
-
|
|
242
|
+
return branch(theme, expanded ? String(message.content) : (d.last ?? ""), head);
|
|
243
|
+
});
|
|
244
|
+
// One line, like pi-polish's shell rows; expanded (ctrl+o) shows the whole text, wrapped under the row
|
|
245
|
+
const row = (theme: any, text: string, context: any, full = text) => ({
|
|
246
|
+
invalidate() {},
|
|
247
|
+
render(width: number) {
|
|
248
|
+
const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : undefined;
|
|
249
|
+
const mark = color ? theme.fg(color, "● ") : " ";
|
|
250
|
+
const paint = (line: string) => theme.fg(color ? "toolTitle" : "dim", line);
|
|
251
|
+
if (!context.expanded) return [mark + paint(truncateToWidth(text, Math.max(10, width - 2)))];
|
|
252
|
+
return wrapTextWithAnsi(full, Math.max(10, width - 2)).map((line, i) => (i === 0 ? mark : " ") + paint(line));
|
|
253
|
+
},
|
|
213
254
|
});
|
|
214
|
-
const row = (theme: any, text: string, context: any) => {
|
|
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);
|
|
217
|
-
};
|
|
218
255
|
const note = (theme: any, result: any, expanded: boolean) => {
|
|
219
256
|
const text = result.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
|
|
220
257
|
// Collapsed: first sentence only ("Started bg1"), without the log path
|
|
221
258
|
const short = text.split("\n")[0].split(";")[0];
|
|
222
|
-
return
|
|
259
|
+
return branch(theme, expanded ? text : short);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Starts tracking a job: its output goes to a log and the jobs line, and Pi is told when run's promise settles.
|
|
263
|
+
// A bash command that moves to the background brings its start time and the output it printed before.
|
|
264
|
+
const startJob = (
|
|
265
|
+
command: string,
|
|
266
|
+
pattern: RegExp | undefined,
|
|
267
|
+
controller: AbortController,
|
|
268
|
+
run: (onData: (d: Buffer) => void) => Promise<{ exitCode: number | null }>,
|
|
269
|
+
earlier?: { started: number; output: Buffer; lastOutput?: number },
|
|
270
|
+
) => {
|
|
271
|
+
const id = `bg${next++}`;
|
|
272
|
+
const dir = join(tmpdir(), "pi-bg");
|
|
273
|
+
mkdirSync(dir, { recursive: true });
|
|
274
|
+
const log = join(dir, `${process.pid}-${id}.log`);
|
|
275
|
+
const out = createWriteStream(log);
|
|
276
|
+
const job: Job = { id, command, log, started: earlier?.started ?? Date.now(), controller, status: "running", group: turn, watched: Boolean(pattern) };
|
|
277
|
+
jobs.set(id, job);
|
|
278
|
+
|
|
279
|
+
const watch = pattern ? watcher(job, pattern) : undefined;
|
|
280
|
+
const onData = (d: Buffer) => {
|
|
281
|
+
out.write(d);
|
|
282
|
+
watch?.(d);
|
|
283
|
+
job.lastOutput = Date.now();
|
|
284
|
+
const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
|
|
285
|
+
if (last) job.lastLine = last;
|
|
286
|
+
};
|
|
287
|
+
if (earlier?.output.length) {
|
|
288
|
+
onData(earlier.output);
|
|
289
|
+
job.lastOutput = earlier.lastOutput;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
run(onData)
|
|
293
|
+
.then(({ exitCode }) => {
|
|
294
|
+
job.status = "exited";
|
|
295
|
+
job.exitCode = exitCode;
|
|
296
|
+
})
|
|
297
|
+
.catch((err: Error) => {
|
|
298
|
+
job.status = controller.signal.aborted ? "killed" : "failed";
|
|
299
|
+
out.write(`\n[bg] ${err.message}\n`);
|
|
300
|
+
})
|
|
301
|
+
.finally(() => {
|
|
302
|
+
job.ended = Date.now();
|
|
303
|
+
showJobs();
|
|
304
|
+
out.end(() => {
|
|
305
|
+
// A kill from session shutdown has no session left to notify
|
|
306
|
+
if (job.status === "killed" && !jobs.has(id)) return;
|
|
307
|
+
report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
|
|
308
|
+
headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
|
|
309
|
+
last: tail(log, 1),
|
|
310
|
+
error: job.status !== "exited" || job.exitCode !== 0,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
showJobs();
|
|
316
|
+
return job;
|
|
223
317
|
};
|
|
224
318
|
|
|
319
|
+
// Pi's bash, except that a command still running after AUTO_BACKGROUND_SECS becomes a background job
|
|
320
|
+
// instead of blocking the turn until it ends or times out. The command keeps running in the same
|
|
321
|
+
// process; its output so far goes to the job's log, and Pi gets the usual message when it exits.
|
|
322
|
+
const handoff = (local: BashOperations, toolCallId: string): BashOperations => ({
|
|
323
|
+
exec(command, cwd, options) {
|
|
324
|
+
// Pi's own check, which its exec would otherwise have made
|
|
325
|
+
if (options.timeout !== undefined && !(Number.isFinite(options.timeout) && options.timeout > 0)) {
|
|
326
|
+
return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
|
|
327
|
+
}
|
|
328
|
+
const started = Date.now();
|
|
329
|
+
// Its own signal, so a job outlives the turn; until then the turn's abort is passed on
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const forward = () => controller.abort();
|
|
332
|
+
if (options.signal?.aborted) controller.abort();
|
|
333
|
+
options.signal?.addEventListener("abort", forward, { once: true });
|
|
334
|
+
// The output so far goes to the job's log; only its last TAIL_BYTES are kept, like the log tails
|
|
335
|
+
let earlier = Buffer.alloc(0);
|
|
336
|
+
let lastOutput: number | undefined;
|
|
337
|
+
let sink = (d: Buffer) => {
|
|
338
|
+
earlier = Buffer.concat([earlier, d]).subarray(-TAIL_BYTES);
|
|
339
|
+
lastOutput = Date.now();
|
|
340
|
+
options.onData(d);
|
|
341
|
+
};
|
|
342
|
+
const env = { ...(options.env ?? process.env), PYTHONUNBUFFERED: "1" };
|
|
343
|
+
// A timeout moves the command to the background sooner rather than stopping it, so its work isn't lost
|
|
344
|
+
const after = Math.min(options.timeout ?? AUTO_BACKGROUND_SECS, AUTO_BACKGROUND_SECS);
|
|
345
|
+
const running = local.exec(command, cwd, { onData: (d) => sink(d), signal: controller.signal, env });
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
let timer: NodeJS.Timeout | undefined;
|
|
348
|
+
let hint: NodeJS.Timeout | undefined;
|
|
349
|
+
const settle = () => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
clearTimeout(hint);
|
|
352
|
+
options.signal?.removeEventListener("abort", forward);
|
|
353
|
+
if (hinted.delete(toolCallId)) redrawJobs();
|
|
354
|
+
};
|
|
355
|
+
const move = () => {
|
|
356
|
+
settle();
|
|
357
|
+
const output = earlier;
|
|
358
|
+
const job = startJob(command, undefined, controller, (onData) => {
|
|
359
|
+
sink = onData;
|
|
360
|
+
return running;
|
|
361
|
+
}, { started, output, lastOutput });
|
|
362
|
+
reject(new MovedToBackground(job, output.toString("utf8")));
|
|
363
|
+
};
|
|
364
|
+
timer = setTimeout(move, after * 1000);
|
|
365
|
+
hint = setTimeout(() => {
|
|
366
|
+
hinted.set(toolCallId, move);
|
|
367
|
+
redrawJobs();
|
|
368
|
+
}, HINT_AFTER_MS);
|
|
369
|
+
running.then(resolve, reject).finally(settle);
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
// Only Pi's own bash is wrapped; one that another extension replaced, such as a sandbox's, is left alone. By
|
|
374
|
+
// session_start every extension has registered its tools, whichever order they loaded in.
|
|
375
|
+
let wrapped = false;
|
|
376
|
+
pi.on("session_start", async () => {
|
|
377
|
+
if (wrapped || !pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo.path.startsWith("builtin:")) return;
|
|
378
|
+
wrapped = true;
|
|
379
|
+
const pisBash = createBashToolDefinition(process.cwd());
|
|
380
|
+
pi.registerTool({
|
|
381
|
+
...pisBash,
|
|
382
|
+
promptGuidelines: [
|
|
383
|
+
...(pisBash.promptGuidelines ?? []),
|
|
384
|
+
`A bash command still running after ${AUTO_BACKGROUND_SECS} seconds, or after its timeout if that is shorter, moves to the background on its own, as if started with bg_run, and you get its job id instead of its output; it is not stopped. Still start a command you expect to take that long with bg_run, so you choose wake_on and the turn isn't held up first.`,
|
|
385
|
+
],
|
|
386
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
387
|
+
// Built per call from the same settings Pi builds its own bash from
|
|
388
|
+
const { shellPath, shellCommandPrefix } = pi.getSettings();
|
|
389
|
+
const bash = createBashToolDefinition(ctx.cwd, { commandPrefix: shellCommandPrefix, operations: handoff(createLocalBashOperations({ shellPath }), toolCallId) });
|
|
390
|
+
try {
|
|
391
|
+
return await bash.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (!(err instanceof MovedToBackground)) throw err;
|
|
394
|
+
const { id, log, started } = err.job;
|
|
395
|
+
const soFar = err.output.trimEnd().split("\n").slice(-TAIL_LINES).join("\n") || "(no output)";
|
|
396
|
+
const text = `Still running after ${duration(Date.now() - started)}, so it moved to the background as ${id}. You will be notified when it exits; check it with bg_status, or stop it with bg_kill if it shouldn't take this long.\nOutput so far:\n${soFar}\nlog: ${log}`;
|
|
397
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
225
403
|
pi.registerTool({
|
|
226
404
|
name: "bg_run",
|
|
227
405
|
label: "Background",
|
|
@@ -236,71 +414,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
414
|
"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.",
|
|
237
415
|
"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.",
|
|
238
416
|
"Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
|
|
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
|
|
417
|
+
"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. Make it a pattern for that one event, which matches once, not a line the job prints for every item, file, epoch or step. To report a job's result, leave wake_on out: the exit message already brings the exit code and last lines.",
|
|
240
418
|
"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.",
|
|
241
419
|
"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.",
|
|
242
420
|
"Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
|
|
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.`,
|
|
244
421
|
"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.",
|
|
245
422
|
"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.",
|
|
246
423
|
],
|
|
247
424
|
// Runs after the edits in the same reply, so the job starts from the code pi just wrote
|
|
248
425
|
executionMode: "sequential",
|
|
249
426
|
renderShell: "self",
|
|
250
|
-
renderCall: (args: any, theme, context) =>
|
|
427
|
+
renderCall: (args: any, theme, context) => {
|
|
428
|
+
const command = String(args?.command ?? "");
|
|
429
|
+
// Collapsed, without leading settings such as PYTHONPATH=/some/src, like the jobs line; ctrl+o shows them
|
|
430
|
+
const short = shortCommand(command, args?.cwd ?? process.cwd()).replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "");
|
|
431
|
+
return row(theme, `Background $ ${short}`, context, `Background $ ${command.trim()}`);
|
|
432
|
+
},
|
|
251
433
|
renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
|
|
252
434
|
parameters: Type.Object({
|
|
253
435
|
command: Type.String({ description: "Shell command to run" }),
|
|
254
436
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
|
|
255
437
|
wake_on: Type.Optional(
|
|
256
438
|
Type.String({
|
|
257
|
-
description:
|
|
439
|
+
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 ${MAX_WAKES} wake-ups per job.`,
|
|
258
440
|
}),
|
|
259
441
|
),
|
|
260
442
|
}),
|
|
261
443
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
262
444
|
const pattern = params.wake_on ? wakePattern(params.wake_on) : undefined;
|
|
263
|
-
const
|
|
264
|
-
const dir = join(tmpdir(), "pi-bg");
|
|
265
|
-
mkdirSync(dir, { recursive: true });
|
|
266
|
-
const log = join(dir, `${process.pid}-${id}.log`);
|
|
267
|
-
const out = createWriteStream(log);
|
|
268
|
-
const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running", group: turn, watched: Boolean(pattern) };
|
|
269
|
-
jobs.set(id, job);
|
|
270
|
-
|
|
271
|
-
const watch = pattern ? watcher(job, pattern) : undefined;
|
|
272
|
-
const onData = (d: Buffer) => {
|
|
273
|
-
out.write(d);
|
|
274
|
-
watch?.(d);
|
|
275
|
-
const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
|
|
276
|
-
if (last) job.lastLine = last;
|
|
277
|
-
};
|
|
278
|
-
|
|
445
|
+
const controller = new AbortController();
|
|
279
446
|
// Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
|
|
280
|
-
|
|
281
|
-
.
|
|
282
|
-
|
|
283
|
-
job.exitCode = exitCode;
|
|
284
|
-
})
|
|
285
|
-
.catch((err: Error) => {
|
|
286
|
-
job.status = job.controller.signal.aborted ? "killed" : "failed";
|
|
287
|
-
out.write(`\n[bg] ${err.message}\n`);
|
|
288
|
-
})
|
|
289
|
-
.finally(() => {
|
|
290
|
-
job.ended = Date.now();
|
|
291
|
-
showJobs();
|
|
292
|
-
out.end(() => {
|
|
293
|
-
// A kill from session shutdown has no session left to notify
|
|
294
|
-
if (job.status === "killed" && !jobs.has(id)) return;
|
|
295
|
-
report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
|
|
296
|
-
headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
|
|
297
|
-
last: tail(log, 1),
|
|
298
|
-
error: job.status !== "exited" || job.exitCode !== 0,
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
showJobs();
|
|
447
|
+
const { id, log } = startJob(params.command, pattern, controller, (onData) =>
|
|
448
|
+
ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: controller.signal }),
|
|
449
|
+
);
|
|
304
450
|
return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
|
|
305
451
|
},
|
|
306
452
|
});
|
|
@@ -343,60 +489,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
343
489
|
});
|
|
344
490
|
|
|
345
491
|
// Shows a job's latest output in place of the input until esc, refreshed every second while it runs
|
|
492
|
+
const showJob = async (args: string, ctx: ExtensionContext) => {
|
|
493
|
+
if (!ctx.hasUI) return;
|
|
494
|
+
const all = [...jobs.values()].reverse();
|
|
495
|
+
if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
|
|
496
|
+
// "/bg 2" works as well as "/bg bg2"
|
|
497
|
+
const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
|
|
498
|
+
let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
|
|
499
|
+
if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
|
|
500
|
+
if (!job) {
|
|
501
|
+
// Running jobs first, then the latest finished ones
|
|
502
|
+
const listed = [...all.filter((other) => other.status === "running"), ...all.filter((other) => other.status !== "running").slice(0, MAX_LISTED_FINISHED)];
|
|
503
|
+
// Matched by position, since the elapsed time in a label changes while the list is open
|
|
504
|
+
// Ids and commands padded to one width, so the states and times line up
|
|
505
|
+
const idWidth = Math.max(...listed.map((other) => other.id.length));
|
|
506
|
+
const commandWidth = Math.max(...listed.map((other) => shownCommand(other.command).length));
|
|
507
|
+
const options = listed.map((other) => `${other.id.padEnd(idWidth)} ${shownCommand(other.command).padEnd(commandWidth)} · ${state(other)} · ${elapsed(other)}`);
|
|
508
|
+
job = listed[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
|
|
509
|
+
if (!job) return;
|
|
510
|
+
}
|
|
511
|
+
const shown = job;
|
|
512
|
+
viewing = shown;
|
|
513
|
+
const log = shown.log.replace(homedir(), "~");
|
|
514
|
+
try {
|
|
515
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
516
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
517
|
+
return {
|
|
518
|
+
dispose: () => clearInterval(timer),
|
|
519
|
+
invalidate() {},
|
|
520
|
+
handleInput: (data: string) => {
|
|
521
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
|
|
522
|
+
},
|
|
523
|
+
render(width: number) {
|
|
524
|
+
const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
|
|
525
|
+
const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
|
|
526
|
+
const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
|
|
527
|
+
const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
|
|
528
|
+
// At most half the terminal, so the conversation above stays in view
|
|
529
|
+
const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
|
|
530
|
+
const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
|
|
531
|
+
return [
|
|
532
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
533
|
+
title + " ".repeat(gap) + status,
|
|
534
|
+
...output,
|
|
535
|
+
theme.fg("dim", ` esc to close · ${log}`),
|
|
536
|
+
theme.fg("borderMuted", "─".repeat(width)),
|
|
537
|
+
].map((line) => truncateToWidth(line, width));
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
} finally {
|
|
542
|
+
viewing = undefined;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
346
545
|
pi.registerCommand("bg", {
|
|
347
546
|
description: "Show a background job's latest output",
|
|
348
547
|
getArgumentCompletions: (prefix) => {
|
|
349
548
|
const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
|
|
350
549
|
return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
|
|
351
550
|
},
|
|
352
|
-
handler:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
},
|
|
551
|
+
handler: showJob,
|
|
552
|
+
});
|
|
553
|
+
// "/bg3" without the space isn't a command, so pi would send it to the model as a message; open the job instead
|
|
554
|
+
pi.on("input", async (event, ctx) => {
|
|
555
|
+
const id = event.text.trim().match(/^\/(bg\d+)$/i)?.[1];
|
|
556
|
+
if (!id || !jobs.has(id.toLowerCase())) return { action: "continue" };
|
|
557
|
+
await showJob(id, ctx);
|
|
558
|
+
return { action: "handled" };
|
|
400
559
|
});
|
|
401
560
|
|
|
402
561
|
// After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
|
|
@@ -406,6 +565,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
406
565
|
redrawJobs = () => tui.requestRender();
|
|
407
566
|
return jobsWidget(theme);
|
|
408
567
|
});
|
|
568
|
+
// ctrl+b moves running bash commands to the background; otherwise it stays the editor's cursor-left
|
|
569
|
+
stopCtrlB();
|
|
570
|
+
stopCtrlB = ctx.ui.onTerminalInput((data) => {
|
|
571
|
+
if (hinted.size === 0 || !matchesKey(data, Key.ctrl("b"))) return undefined;
|
|
572
|
+
for (const move of [...hinted.values()]) move();
|
|
573
|
+
return { consume: true };
|
|
574
|
+
});
|
|
409
575
|
}
|
|
410
576
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
411
577
|
const msg = entry.type === "message" ? (entry.message as any) : undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Real paths, so a cd through a symlink such as /tmp still counts as cwd
|
|
5
|
+
const real = (path: string) => {
|
|
6
|
+
try {
|
|
7
|
+
return realpathSync(path);
|
|
8
|
+
} catch {
|
|
9
|
+
return path;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** A shell command's first line, without leading cds into cwd; another folder shows as its last part when the path is absolute. */
|
|
14
|
+
export function shortCommand(command: string, cwd: string): string {
|
|
15
|
+
const rest = command.trim().replace(/^(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)[ \t]*(?:&&|;|\n)\s*)+/, (prefix) => {
|
|
16
|
+
const [, quoted, separator] = [...prefix.matchAll(/cd\s+("[^"]*"|'[^']*'|\S+)[ \t]*(&&|;|\n)/g)].at(-1)!;
|
|
17
|
+
const dir = quoted.replace(/^["']|["']$/g, "");
|
|
18
|
+
if (real(resolve(cwd, dir)) === real(resolve(cwd))) return "";
|
|
19
|
+
return `cd ${isAbsolute(dir) ? `…/${basename(dir)}` : quoted}${separator === ";" ? ";" : " &&"} `;
|
|
20
|
+
});
|
|
21
|
+
return rest.split("\n")[0];
|
|
22
|
+
}
|