pi-background 0.1.1 → 0.2.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
@@ -14,11 +14,22 @@ 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`.
17
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.
18
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.
19
20
 
20
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".
21
22
 
23
+ ## Settings
24
+
25
+ The running jobs line has three levels. Set one in `~/.pi/agent/settings.json`, for example `"background": { "jobs": "compact" }`.
26
+
27
+ | `jobs` | What it shows |
28
+ | --- | --- |
29
+ | `full` (default) | `⏵ bg1 python3 train.py · 4m12s · epoch 3/10 loss 0.41` |
30
+ | `compact` | `⏵ bg1 python3 train.py · 4m12s` |
31
+ | `off` | Nothing |
32
+
22
33
  ## Tools
23
34
 
24
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.
@@ -1,8 +1,8 @@
1
1
  import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { createLocalBashOperations, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { Text } from "@earendil-works/pi-tui";
4
+ import { createLocalBashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
7
 
8
8
  const TAIL_LINES = 20;
@@ -11,6 +11,7 @@ const BASH_DEFAULT_TIMEOUT = 120;
11
11
  const BASH_MAX_TIMEOUT = 600;
12
12
  const WAKE_DEBOUNCE_MS = 1000;
13
13
  const MAX_WAKES = 10;
14
+ const MAX_SHOWN_JOBS = 3;
14
15
 
15
16
  interface Job {
16
17
  id: string;
@@ -21,6 +22,8 @@ interface Job {
21
22
  status: "running" | "exited" | "killed" | "failed";
22
23
  exitCode?: number | null;
23
24
  ended?: number;
25
+ // The latest output line, shown while the job runs
26
+ lastLine?: string;
24
27
  }
25
28
 
26
29
  function tail(path: string, lines: number): string {
@@ -42,6 +45,14 @@ function summary(job: Job): string {
42
45
  return `${job.id} [${state}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
43
46
  }
44
47
 
48
+ /** The part of a command worth showing: its first line, without a leading cd and with a script path shortened to its name. */
49
+ function shownCommand(command: string): string {
50
+ return command
51
+ .split("\n")[0]
52
+ .replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
53
+ .replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
54
+ }
55
+
45
56
  /** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
46
57
  function startsBackgroundJob(command: string): boolean {
47
58
  return /&/.test(command.replace(/&&|[0-9]*>&[0-9-]*|&>>?|\|&/g, ""));
@@ -101,6 +112,36 @@ export default function (pi: ExtensionAPI) {
101
112
  };
102
113
  };
103
114
 
115
+ // One line per running job above the input, with its elapsed time and latest output
116
+ let ui: ExtensionContext["ui"] | undefined;
117
+ const running = () => [...jobs.values()].filter((job) => job.status === "running");
118
+ // "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
119
+ const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
120
+ const showJobs = () => {
121
+ if (!ui) return;
122
+ if (jobsLine() === "off" || running().length === 0) return ui.setWidget("background-jobs", undefined);
123
+ ui.setWidget("background-jobs", (tui, theme) => {
124
+ const timer = setInterval(() => tui.requestRender(), 1000);
125
+ return {
126
+ dispose: () => clearInterval(timer),
127
+ invalidate() {},
128
+ render(width: number) {
129
+ const live = running();
130
+ const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
131
+ const command = shownCommand(job.command);
132
+ const text =
133
+ theme.fg("accent", " ⏵ ") +
134
+ theme.fg("toolTitle", `${job.id} ${command.length > 50 ? `${command.slice(0, 49)}…` : command}`) +
135
+ theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
136
+ return truncateToWidth(text, width);
137
+ });
138
+ if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
139
+ return lines;
140
+ },
141
+ };
142
+ });
143
+ };
144
+
104
145
  const find = (id: string): Job => {
105
146
  const job = jobs.get(id);
106
147
  if (!job) throw new Error(`No background job ${id}; known: ${[...jobs.keys()].join(", ") || "none"}`);
@@ -140,6 +181,7 @@ export default function (pi: ExtensionAPI) {
140
181
  "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.",
141
182
  "Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
142
183
  "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.",
184
+ "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.",
143
185
  "Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
144
186
  `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.`,
145
187
  "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.",
@@ -171,9 +213,13 @@ export default function (pi: ExtensionAPI) {
171
213
  const onData = (d: Buffer) => {
172
214
  out.write(d);
173
215
  watch?.(d);
216
+ // Progress bars redraw with \r, so the last segment of either kind of line break is the current state
217
+ 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);
218
+ if (last) job.lastLine = last;
174
219
  };
175
220
 
176
- ops.exec(params.command, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
221
+ // Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
222
+ ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
177
223
  .then(({ exitCode }) => {
178
224
  job.status = "exited";
179
225
  job.exitCode = exitCode;
@@ -184,6 +230,7 @@ export default function (pi: ExtensionAPI) {
184
230
  })
185
231
  .finally(() => {
186
232
  job.ended = Date.now();
233
+ showJobs();
187
234
  out.end(() => {
188
235
  // A kill from session shutdown has no session left to notify
189
236
  if (job.status === "killed" && !jobs.has(id)) return;
@@ -195,6 +242,8 @@ export default function (pi: ExtensionAPI) {
195
242
  });
196
243
  });
197
244
 
245
+ if (ctx.mode === "tui") ui = ctx.ui;
246
+ showJobs();
198
247
  return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
199
248
  },
200
249
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",