pi-background 0.2.2 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-background
2
2
 
3
- Lets [pi](https://pi.dev) run long commands in the background instead of waiting on them. pi starts the job, carries on or hands back to you, and gets a message when the job finishes or prints something it's waiting for.
3
+ Lets [Pi](https://pi.dev) run long commands in the background instead of waiting on them. Pi starts the job, carries on or hands back to you, and gets a message when the job finishes or prints something it's waiting for.
4
4
 
5
5
  <picture>
6
6
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pringled/pi-extensions/main/assets/images/pi-background-dark.gif">
@@ -13,12 +13,14 @@ pi install npm:pi-background
13
13
 
14
14
  ## Features
15
15
 
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.
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
- - 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
- - 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.
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.
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
+ - 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
+ - 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.
20
22
 
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".
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".
22
24
 
23
25
  ## Settings
24
26
 
@@ -32,7 +34,7 @@ The running jobs line has three levels. Set one in `~/.pi/agent/settings.json`,
32
34
 
33
35
  ## Tools
34
36
 
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.
37
+ 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.
36
38
 
37
39
  ## License
38
40
 
@@ -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
4
  import { createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
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;
@@ -12,6 +12,8 @@ const BASH_MAX_TIMEOUT = 600;
12
12
  const WAKE_DEBOUNCE_MS = 1000;
13
13
  const MAX_WAKES = 10;
14
14
  const MAX_SHOWN_JOBS = 3;
15
+ // How long finished results wait for jobs started alongside them before Pi is woken anyway
16
+ const GROUP_HOLD_MS = 2 * 60_000;
15
17
 
16
18
  interface Job {
17
19
  id: string;
@@ -24,6 +26,10 @@ interface Job {
24
26
  ended?: number;
25
27
  // The latest output line, shown while the job runs
26
28
  lastLine?: string;
29
+ // The turn that started the job; jobs from the same turn are reported together
30
+ group: number;
31
+ // Started with wake_on, so it may run indefinitely and doesn't hold back the others
32
+ watched: boolean;
27
33
  }
28
34
 
29
35
  function tail(path: string, lines: number): string {
@@ -40,17 +46,27 @@ function elapsed(job: Job): string {
40
46
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
41
47
  }
42
48
 
49
+ function state(job: Job): string {
50
+ return job.status === "exited" ? `exit ${job.exitCode}` : job.status;
51
+ }
52
+
43
53
  function summary(job: Job): string {
44
- const state = job.status === "exited" ? `exit ${job.exitCode}` : job.status;
45
- return `${job.id} [${state}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
54
+ return `${job.id} [${state(job)}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
46
55
  }
47
56
 
48
- /** The part of a command worth showing: its first line, without a leading cd and with a script path shortened to its name. */
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();
61
+ }
62
+
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. */
49
64
  function shownCommand(command: string): string {
50
- return command
65
+ const shown = command
51
66
  .split("\n")[0]
52
67
  .replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
53
68
  .replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
69
+ return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
54
70
  }
55
71
 
56
72
  /** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
@@ -79,8 +95,40 @@ export default function (pi: ExtensionAPI) {
79
95
  const ops = createLocalBashOperations();
80
96
  let next = 1;
81
97
 
82
- const wake = (content: string, details: { headline: string; last: string; error: boolean }) =>
83
- pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn: true, deliverAs: "followUp" });
98
+ const wake = (content: string, details: { headline: string; last: string; error: boolean }, triggerTurn = true) =>
99
+ pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn, deliverAs: "followUp" });
100
+
101
+ // Jobs started in the same turn usually belong together, like several benchmarks to compare. While some of them
102
+ // still run, a successful one's result is added to the conversation without waking Pi, so it answers once at the
103
+ // end instead of after each job. A job that runs on too long doesn't hold the others back for more than GROUP_HOLD_MS.
104
+ let turn = 0;
105
+ pi.on("turn_start", async () => {
106
+ turn++;
107
+ });
108
+ const holds = new Map<number, NodeJS.Timeout>();
109
+ const runningInGroup = (job: Job) =>
110
+ [...jobs.values()].filter((other) => other !== job && other.group === job.group && other.status === "running" && !other.watched);
111
+ const report = (job: Job, content: string, details: { headline: string; last: string; error: boolean }) => {
112
+ const others = job.watched ? [] : runningInGroup(job);
113
+ // A failure wakes Pi straight away, so it can react before the rest of the group finishes
114
+ if (others.length === 0 || details.error) {
115
+ clearTimeout(holds.get(job.group));
116
+ holds.delete(job.group);
117
+ return wake(content, details);
118
+ }
119
+ wake(`${content}\n(Still running from the same turn: ${others.map((other) => other.id).join(", ")}; you'll be woken when they finish.)`, details, false);
120
+ if (holds.has(job.group)) return;
121
+ holds.set(job.group, setTimeout(() => {
122
+ holds.delete(job.group);
123
+ const still = runningInGroup(job);
124
+ if (still.length === 0) return;
125
+ wake(`Background jobs from the same turn are still running after ${GROUP_HOLD_MS / 60_000} minutes: ${still.map((other) => other.id).join(", ")}. The results of the ones that finished are above; you'll get a message when each of these finishes.`, {
126
+ headline: `${still.map((other) => other.id).join(", ")} still running`,
127
+ last: "",
128
+ error: false,
129
+ });
130
+ }, GROUP_HOLD_MS));
131
+ };
84
132
 
85
133
  // Wakes the agent with output lines matching pattern, batched per second and capped per job
86
134
  const watcher = (job: Job, pattern: RegExp) => {
@@ -115,6 +163,8 @@ export default function (pi: ExtensionAPI) {
115
163
  // One line per running job above the input, with its elapsed time and latest output
116
164
  let ui: ExtensionContext["ui"] | undefined;
117
165
  const running = () => [...jobs.values()].filter((job) => job.status === "running");
166
+ // The job open in /bg, left out of the jobs line meanwhile so it isn't shown twice
167
+ let viewing: Job | undefined;
118
168
  // "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
119
169
  const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
120
170
  const showJobs = () => {
@@ -126,12 +176,11 @@ export default function (pi: ExtensionAPI) {
126
176
  dispose: () => clearInterval(timer),
127
177
  invalidate() {},
128
178
  render(width: number) {
129
- const live = running();
179
+ const live = running().filter((job) => job !== viewing);
130
180
  const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
131
- const command = shownCommand(job.command);
132
181
  const text =
133
182
  theme.fg("accent", " ⏵ ") +
134
- theme.fg("toolTitle", `${job.id} ${command.length > 50 ? `${command.slice(0, 49)}…` : command}`) +
183
+ theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) +
135
184
  theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
136
185
  return truncateToWidth(text, width);
137
186
  });
@@ -209,15 +258,14 @@ export default function (pi: ExtensionAPI) {
209
258
  mkdirSync(dir, { recursive: true });
210
259
  const log = join(dir, `${process.pid}-${id}.log`);
211
260
  const out = createWriteStream(log);
212
- const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running" };
261
+ const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running", group: turn, watched: Boolean(pattern) };
213
262
  jobs.set(id, job);
214
263
 
215
264
  const watch = pattern ? watcher(job, pattern) : undefined;
216
265
  const onData = (d: Buffer) => {
217
266
  out.write(d);
218
267
  watch?.(d);
219
- // Progress bars redraw with \r, so the last segment of either kind of line break is the current state
220
- 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);
268
+ const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
221
269
  if (last) job.lastLine = last;
222
270
  };
223
271
 
@@ -237,7 +285,7 @@ export default function (pi: ExtensionAPI) {
237
285
  out.end(() => {
238
286
  // A kill from session shutdown has no session left to notify
239
287
  if (job.status === "killed" && !jobs.has(id)) return;
240
- wake(`Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
288
+ report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
241
289
  headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
242
290
  last: tail(log, 1),
243
291
  error: job.status !== "exited" || job.exitCode !== 0,
@@ -288,7 +336,75 @@ export default function (pi: ExtensionAPI) {
288
336
  },
289
337
  });
290
338
 
339
+ // Shows a job's latest output in place of the input until esc, refreshed every second while it runs
340
+ pi.registerCommand("bg", {
341
+ description: "Show a background job's latest output",
342
+ getArgumentCompletions: (prefix) => {
343
+ const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
344
+ return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
345
+ },
346
+ handler: async (args, ctx) => {
347
+ if (!ctx.hasUI) return;
348
+ const all = [...jobs.values()].reverse();
349
+ if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
350
+ // "/bg 2" works as well as "/bg bg2"
351
+ const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
352
+ let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
353
+ if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
354
+ if (!job) {
355
+ // Matched by position, since the elapsed time in a label changes while the list is open
356
+ const options = all.map((other) => `${other.id} ${shownCommand(other.command)} · ${state(other)} · ${elapsed(other)}`);
357
+ job = all[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
358
+ if (!job) return;
359
+ }
360
+ const shown = job;
361
+ viewing = shown;
362
+ const log = shown.log.replace(homedir(), "~");
363
+ try {
364
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
365
+ const timer = setInterval(() => tui.requestRender(), 1000);
366
+ return {
367
+ dispose: () => clearInterval(timer),
368
+ invalidate() {},
369
+ handleInput: (data: string) => {
370
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
371
+ },
372
+ render(width: number) {
373
+ const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
374
+ const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
375
+ const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
376
+ const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
377
+ // At most half the terminal, so the conversation above stays in view
378
+ const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
379
+ const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
380
+ return [
381
+ theme.fg("borderMuted", "─".repeat(width)),
382
+ title + " ".repeat(gap) + status,
383
+ ...output,
384
+ theme.fg("dim", ` esc to close · ${log}`),
385
+ theme.fg("borderMuted", "─".repeat(width)),
386
+ ].map((line) => truncateToWidth(line, width));
387
+ },
388
+ };
389
+ });
390
+ } finally {
391
+ viewing = undefined;
392
+ }
393
+ },
394
+ });
395
+
396
+ // After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
397
+ pi.on("session_start", async (_event, ctx) => {
398
+ for (const entry of ctx.sessionManager.getBranch()) {
399
+ const msg = entry.type === "message" ? (entry.message as any) : undefined;
400
+ const n = Number(/^bg(\d+)$/.exec(msg?.role === "toolResult" && msg.toolName === "bg_run" ? String(msg.details?.id) : "")?.[1]);
401
+ if (n >= next) next = n + 1;
402
+ }
403
+ });
404
+
291
405
  pi.on("session_shutdown", async () => {
406
+ for (const timer of holds.values()) clearTimeout(timer);
407
+ holds.clear();
292
408
  for (const job of jobs.values()) {
293
409
  if (job.status === "running") job.controller.abort();
294
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
- "description": "Lets pi run long commands in the background and wakes it when they finish, so it never blocks or hangs",
16
+ "description": "Lets Pi run long commands in the background and wakes it when they finish, so it never blocks or hangs",
17
17
  "keywords": [
18
18
  "pi-package",
19
19
  "pi",