pi-background 0.1.0 → 0.2.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 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 {
@@ -101,6 +104,36 @@ export default function (pi: ExtensionAPI) {
101
104
  };
102
105
  };
103
106
 
107
+ // One line per running job above the input, with its elapsed time and latest output
108
+ let ui: ExtensionContext["ui"] | undefined;
109
+ const running = () => [...jobs.values()].filter((job) => job.status === "running");
110
+ // "full" (the default), "compact" without the output, or "off", set with "background": { "jobs": ... } in pi's settings.json
111
+ const jobsLine = () => (pi.getSettings() as { background?: { jobs?: string } }).background?.jobs ?? "full";
112
+ const showJobs = () => {
113
+ if (!ui) return;
114
+ if (jobsLine() === "off" || running().length === 0) return ui.setWidget("background-jobs", undefined);
115
+ ui.setWidget("background-jobs", (tui, theme) => {
116
+ const timer = setInterval(() => tui.requestRender(), 1000);
117
+ return {
118
+ dispose: () => clearInterval(timer),
119
+ invalidate() {},
120
+ render(width: number) {
121
+ const live = running();
122
+ const lines = live.slice(0, MAX_SHOWN_JOBS).map((job) => {
123
+ const command = job.command.split("\n")[0];
124
+ const text =
125
+ theme.fg("accent", " ⏵ ") +
126
+ theme.fg("toolTitle", `${job.id} ${command.length > 40 ? `${command.slice(0, 39)}…` : command}`) +
127
+ theme.fg("dim", ` · ${elapsed(job)}${jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : ""}`);
128
+ return truncateToWidth(text, width);
129
+ });
130
+ if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
131
+ return lines;
132
+ },
133
+ };
134
+ });
135
+ };
136
+
104
137
  const find = (id: string): Job => {
105
138
  const job = jobs.get(id);
106
139
  if (!job) throw new Error(`No background job ${id}; known: ${[...jobs.keys()].join(", ") || "none"}`);
@@ -171,9 +204,13 @@ export default function (pi: ExtensionAPI) {
171
204
  const onData = (d: Buffer) => {
172
205
  out.write(d);
173
206
  watch?.(d);
207
+ // Progress bars redraw with \r, so the last segment of either kind of line break is the current state
208
+ 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);
209
+ if (last) job.lastLine = last;
174
210
  };
175
211
 
176
- ops.exec(params.command, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
212
+ // Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
213
+ ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
177
214
  .then(({ exitCode }) => {
178
215
  job.status = "exited";
179
216
  job.exitCode = exitCode;
@@ -184,6 +221,7 @@ export default function (pi: ExtensionAPI) {
184
221
  })
185
222
  .finally(() => {
186
223
  job.ended = Date.now();
224
+ showJobs();
187
225
  out.end(() => {
188
226
  // A kill from session shutdown has no session left to notify
189
227
  if (job.status === "killed" && !jobs.has(id)) return;
@@ -195,6 +233,8 @@ export default function (pi: ExtensionAPI) {
195
233
  });
196
234
  });
197
235
 
236
+ if (ctx.mode === "tui") ui = ctx.ui;
237
+ showJobs();
198
238
  return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
199
239
  },
200
240
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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": "Background jobs for pi that wake the agent when they finish or print what it waits for, plus a bash timeout that never kills background work, so pi doesn't hang on a command",
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",