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 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.
@@ -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, type ExtensionContext } from "@earendil-works/pi-coding-agent";
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
- const state = job.status === "exited" ? `exit ${job.exitCode}` : job.status;
51
- 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}`;
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 and with a script path shortened to its name. */
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
- return command
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
- if (!ui) return;
160
- if (jobsLine() === "off" || running().length === 0) return ui.setWidget("background-jobs", undefined);
161
- ui.setWidget("background-jobs", (tui, theme) => {
162
- const timer = setInterval(() => tui.requestRender(), 1000);
163
- return {
164
- dispose: () => clearInterval(timer),
165
- invalidate() {},
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 head = theme.fg(d.error ? "error" : "success", "● ") + theme.fg("toolTitle", `Background ${d.headline ?? ""}`);
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" : "success";
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
- "To react when a bg_run job prints something specific while it keeps running (a server is ready, an epoch finished, an error appeared), pass wake_on with a regex instead of polling.",
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; pass wake_on matching the lines you want to see, such as results or Traceback.",
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' or 'epoch \\d+'. Matches within a second are batched; at most 10 wake-ups per job.",
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
- // Progress bars redraw with \r, so the last segment of either kind of line break is the current state
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()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",