pi-background 0.3.1 → 0.3.3
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/extensions/background.ts +73 -38
- package/extensions/command.ts +22 -0
- package/package.json +1 -1
package/extensions/background.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
|
5
|
-
import { Key, matchesKey, stripTerminalSequences,
|
|
4
|
+
import { createLocalBashOperations, type ExtensionAPI } 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;
|
|
@@ -65,6 +66,8 @@ function shownCommand(command: string): string {
|
|
|
65
66
|
const shown = command
|
|
66
67
|
.split("\n")[0]
|
|
67
68
|
.replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
|
|
69
|
+
// Leading settings such as PYTHONPATH=/some/src, which the path shortening below would cut at their last /
|
|
70
|
+
.replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "")
|
|
68
71
|
.replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
|
|
69
72
|
return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
|
|
70
73
|
}
|
|
@@ -161,35 +164,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
164
|
};
|
|
162
165
|
|
|
163
166
|
// One line per running job above the input, with its elapsed time and latest output
|
|
164
|
-
let ui: ExtensionContext["ui"] | undefined;
|
|
165
167
|
const running = () => [...jobs.values()].filter((job) => job.status === "running");
|
|
166
168
|
// The job open in /bg, left out of the jobs line meanwhile so it isn't shown twice
|
|
167
169
|
let viewing: Job | undefined;
|
|
168
170
|
// "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
|
|
169
171
|
const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
|
|
172
|
+
// The widget is set once per session and redrawn in place: setting it again would move it below the widgets other
|
|
173
|
+
// extensions added since, while running jobs belong at the top, next to what Pi is doing now
|
|
174
|
+
let redrawJobs = () => {};
|
|
175
|
+
let ticker: NodeJS.Timeout | undefined;
|
|
170
176
|
const showJobs = () => {
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
render(width: number) {
|
|
179
|
-
const live = running().filter((job) => job !== viewing);
|
|
180
|
-
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
181
|
-
const text =
|
|
182
|
-
theme.fg("accent", " ⏵ ") +
|
|
183
|
-
theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
|
|
184
|
-
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
185
|
-
return truncateToWidth(text, width);
|
|
186
|
-
});
|
|
187
|
-
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
188
|
-
return lines;
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
});
|
|
177
|
+
// A running job's elapsed time and latest output change every second
|
|
178
|
+
if (running().length > 0) ticker ??= setInterval(() => redrawJobs(), 1000);
|
|
179
|
+
else {
|
|
180
|
+
clearInterval(ticker);
|
|
181
|
+
ticker = undefined;
|
|
182
|
+
}
|
|
183
|
+
redrawJobs();
|
|
192
184
|
};
|
|
185
|
+
const jobsWidget = (theme: any) => ({
|
|
186
|
+
invalidate() {},
|
|
187
|
+
render(width: number) {
|
|
188
|
+
if (jobsLine() === "off") return [];
|
|
189
|
+
const live = running().filter((job) => job !== viewing);
|
|
190
|
+
const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
|
|
191
|
+
const text =
|
|
192
|
+
theme.fg("accent", "⏵ ") +
|
|
193
|
+
theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
|
|
194
|
+
theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
|
|
195
|
+
return truncateToWidth(text, width);
|
|
196
|
+
});
|
|
197
|
+
if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
|
|
198
|
+
return lines;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
193
201
|
|
|
194
202
|
const find = (id: string): Job => {
|
|
195
203
|
const job = jobs.get(id);
|
|
@@ -197,22 +205,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
197
205
|
return job;
|
|
198
206
|
};
|
|
199
207
|
|
|
200
|
-
// Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering
|
|
208
|
+
// Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering: quiet grey once done,
|
|
209
|
+
// with a dot only while running or when something failed
|
|
210
|
+
// " └ text" in dim, with long and extra lines wrapped under the text instead of at the left edge
|
|
211
|
+
const branch = (theme: any, text: string, head?: string) => ({
|
|
212
|
+
invalidate() {},
|
|
213
|
+
render: (width: number) => [
|
|
214
|
+
...(head ? [truncateToWidth(head, width)] : []),
|
|
215
|
+
...wrapTextWithAnsi(text, Math.max(10, width - 4)).map((line, i) => theme.fg("dim", `${i === 0 ? " └ " : " "}${line}`)),
|
|
216
|
+
],
|
|
217
|
+
});
|
|
201
218
|
pi.registerMessageRenderer("background-job", (message, { expanded }, theme) => {
|
|
202
219
|
const d = (message.details ?? {}) as { headline?: string; last?: string; error?: boolean };
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
return
|
|
220
|
+
const title = `Background ${d.headline ?? ""}`;
|
|
221
|
+
const head = d.error ? theme.fg("error", "● ") + theme.fg("toolTitle", title) : ` ${theme.fg("dim", title)}`;
|
|
222
|
+
return branch(theme, expanded ? String(message.content) : (d.last ?? ""), head);
|
|
223
|
+
});
|
|
224
|
+
// One line, like pi-polish's shell rows; expanded (ctrl+o) shows the whole text, wrapped under the row
|
|
225
|
+
const row = (theme: any, text: string, context: any, full = text) => ({
|
|
226
|
+
invalidate() {},
|
|
227
|
+
render(width: number) {
|
|
228
|
+
const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : undefined;
|
|
229
|
+
const mark = color ? theme.fg(color, "● ") : " ";
|
|
230
|
+
const paint = (line: string) => theme.fg(color ? "toolTitle" : "dim", line);
|
|
231
|
+
if (!context.expanded) return [mark + paint(truncateToWidth(text, Math.max(10, width - 2)))];
|
|
232
|
+
return wrapTextWithAnsi(full, Math.max(10, width - 2)).map((line, i) => (i === 0 ? mark : " ") + paint(line));
|
|
233
|
+
},
|
|
206
234
|
});
|
|
207
|
-
const row = (theme: any, text: string, context: any) => {
|
|
208
|
-
const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : "success";
|
|
209
|
-
return new Text(theme.fg(color, "● ") + theme.fg("toolTitle", text), 0, 0);
|
|
210
|
-
};
|
|
211
235
|
const note = (theme: any, result: any, expanded: boolean) => {
|
|
212
236
|
const text = result.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
|
|
213
237
|
// Collapsed: first sentence only ("Started bg1"), without the log path
|
|
214
238
|
const short = text.split("\n")[0].split(";")[0];
|
|
215
|
-
return
|
|
239
|
+
return branch(theme, expanded ? text : short);
|
|
216
240
|
};
|
|
217
241
|
|
|
218
242
|
pi.registerTool({
|
|
@@ -229,25 +253,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
253
|
"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.",
|
|
230
254
|
"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.",
|
|
231
255
|
"Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
|
|
232
|
-
"
|
|
256
|
+
"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.",
|
|
233
257
|
"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.",
|
|
234
258
|
"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.",
|
|
235
259
|
"Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
|
|
236
260
|
`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.`,
|
|
237
261
|
"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.",
|
|
238
|
-
"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
|
|
262
|
+
"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.",
|
|
239
263
|
],
|
|
240
264
|
// Runs after the edits in the same reply, so the job starts from the code pi just wrote
|
|
241
265
|
executionMode: "sequential",
|
|
242
266
|
renderShell: "self",
|
|
243
|
-
renderCall: (args: any, theme, context) =>
|
|
267
|
+
renderCall: (args: any, theme, context) => {
|
|
268
|
+
const command = String(args?.command ?? "");
|
|
269
|
+
// Collapsed, without leading settings such as PYTHONPATH=/some/src, like the jobs line; ctrl+o shows them
|
|
270
|
+
const short = shortCommand(command, args?.cwd ?? process.cwd()).replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "");
|
|
271
|
+
return row(theme, `Background $ ${short}`, context, `Background $ ${command.trim()}`);
|
|
272
|
+
},
|
|
244
273
|
renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
|
|
245
274
|
parameters: Type.Object({
|
|
246
275
|
command: Type.String({ description: "Shell command to run" }),
|
|
247
276
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
|
|
248
277
|
wake_on: Type.Optional(
|
|
249
278
|
Type.String({
|
|
250
|
-
description: "Regex; also wake you while the job runs whenever an output line matches, e.g. 'listening on|ready'
|
|
279
|
+
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.",
|
|
251
280
|
}),
|
|
252
281
|
),
|
|
253
282
|
}),
|
|
@@ -293,7 +322,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
293
322
|
});
|
|
294
323
|
});
|
|
295
324
|
|
|
296
|
-
if (ctx.mode === "tui") ui = ctx.ui;
|
|
297
325
|
showJobs();
|
|
298
326
|
return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
|
|
299
327
|
},
|
|
@@ -395,6 +423,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
395
423
|
|
|
396
424
|
// After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
|
|
397
425
|
pi.on("session_start", async (_event, ctx) => {
|
|
426
|
+
if (ctx.mode === "tui") {
|
|
427
|
+
ctx.ui.setWidget("background-jobs", (tui, theme) => {
|
|
428
|
+
redrawJobs = () => tui.requestRender();
|
|
429
|
+
return jobsWidget(theme);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
398
432
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
399
433
|
const msg = entry.type === "message" ? (entry.message as any) : undefined;
|
|
400
434
|
const n = Number(/^bg(\d+)$/.exec(msg?.role === "toolResult" && msg.toolName === "bg_run" ? String(msg.details?.id) : "")?.[1]);
|
|
@@ -403,6 +437,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
403
437
|
});
|
|
404
438
|
|
|
405
439
|
pi.on("session_shutdown", async () => {
|
|
440
|
+
clearInterval(ticker);
|
|
406
441
|
for (const timer of holds.values()) clearTimeout(timer);
|
|
407
442
|
holds.clear();
|
|
408
443
|
for (const job of jobs.values()) {
|
|
@@ -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
|
+
}
|