pi-background 0.3.2 → 0.4.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,11 @@ 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`.
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.
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`. A job that has printed nothing for 10 minutes shows `no output for 12m30s` instead, so a stuck one stands out.
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. Its list shows running jobs first, then the 10 latest finished ones.
19
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
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.
21
+ - A regular shell command still running after 2 minutes moves to the background on its own, so a slow command Pi didn't expect never holds up the turn. It shows in the jobs line, and Pi hears back when it ends. Press `ctrl+b` to move a running command to the background sooner. If another extension already replaces Pi's bash, such as a sandbox, pi-background leaves it alone.
22
22
 
23
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".
24
24
 
@@ -1,17 +1,24 @@
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 } from "@earendil-works/pi-coding-agent";
5
- import { Key, matchesKey, stripTerminalSequences, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { type BashOperations, createBashToolDefinition, createLocalBashOperations, type ExtensionAPI, type ExtensionContext } 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;
10
- const BASH_DEFAULT_TIMEOUT = 120;
11
- const BASH_MAX_TIMEOUT = 600;
11
+ // A bash command still running after this long moves to the background, so it no longer blocks the turn
12
+ const AUTO_BACKGROUND_SECS = 120;
12
13
  const WAKE_DEBOUNCE_MS = 1000;
13
14
  const MAX_WAKES = 10;
14
15
  const MAX_SHOWN_JOBS = 3;
16
+ // A running job silent this long shows how long it has been quiet, since it may be stuck
17
+ const SILENT_AFTER_MS = 10 * 60_000;
18
+ // Finished jobs listed by /bg; older ones stay reachable as /bg <id>
19
+ const MAX_LISTED_FINISHED = 10;
20
+ // How long a bash command runs before the jobs line offers ctrl+b, so quick commands don't flash it
21
+ const HINT_AFTER_MS = 3000;
15
22
  // How long finished results wait for jobs started alongside them before Pi is woken anyway
16
23
  const GROUP_HOLD_MS = 2 * 60_000;
17
24
 
@@ -26,6 +33,8 @@ interface Job {
26
33
  ended?: number;
27
34
  // The latest output line, shown while the job runs
28
35
  lastLine?: string;
36
+ // When the job last printed anything
37
+ lastOutput?: number;
29
38
  // The turn that started the job; jobs from the same turn are reported together
30
39
  group: number;
31
40
  // Started with wake_on, so it may run indefinitely and doesn't hold back the others
@@ -41,9 +50,16 @@ function tail(path: string, lines: number): string {
41
50
  }
42
51
  }
43
52
 
53
+ /** 45s, 12m30s, or 1h20m once seconds no longer matter. */
54
+ function duration(ms: number): string {
55
+ const s = Math.round(ms / 1000);
56
+ if (s < 60) return `${s}s`;
57
+ if (s < 3600) return `${Math.floor(s / 60)}m${s % 60}s`;
58
+ return `${Math.floor(s / 3600)}h${Math.floor(s / 60) % 60}m`;
59
+ }
60
+
44
61
  function elapsed(job: Job): string {
45
- const s = Math.round(((job.ended ?? Date.now()) - job.started) / 1000);
46
- return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
62
+ return duration((job.ended ?? Date.now()) - job.started);
47
63
  }
48
64
 
49
65
  function state(job: Job): string {
@@ -65,32 +81,26 @@ function shownCommand(command: string): string {
65
81
  const shown = command
66
82
  .split("\n")[0]
67
83
  .replace(/^\s*(cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, "")
84
+ // Leading settings such as PYTHONPATH=/some/src, which the path shortening below would cut at their last /
85
+ .replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "")
68
86
  .replace(/^\S*\/(?=[^\s/]+(\s|$))/, "");
69
87
  return shown.length > 50 ? `${shown.slice(0, 49)}…` : shown;
70
88
  }
71
89
 
72
- /** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
73
- function startsBackgroundJob(command: string): boolean {
74
- return /&/.test(command.replace(/&&|[0-9]*>&[0-9-]*|&>>?|\|&/g, ""));
75
- }
76
-
77
90
  /** Compiles wake_on, accepting Python's (?i) prefix that models often write but JavaScript only takes as a flag. */
78
91
  function wakePattern(source: string): RegExp {
79
92
  const insensitive = source.startsWith("(?i)");
80
93
  return new RegExp(insensitive ? source.slice(4) : source, insensitive ? "i" : "");
81
94
  }
82
95
 
83
- export default function (pi: ExtensionAPI) {
84
- // Pi's bash has no default timeout, so a stuck or polling command blocks until interrupted; cap it like Claude Code
85
- // does. A timeout SIGKILLs the command's whole process group, which would include jobs it started with &, so a
86
- // command that backgrounds something is never timed out, even if the model asked for a timeout.
87
- pi.on("tool_call", async (event) => {
88
- if (event.toolName !== "bash") return;
89
- const input = event.input as { command?: string; timeout?: number };
90
- if (startsBackgroundJob(String(input.command ?? ""))) delete input.timeout;
91
- else input.timeout = Math.min(input.timeout ?? BASH_DEFAULT_TIMEOUT, BASH_MAX_TIMEOUT);
92
- });
96
+ /** Thrown by the bash operations below when a command moves to the background, carrying the job and its output so far. */
97
+ class MovedToBackground extends Error {
98
+ constructor(readonly job: { id: string; log: string; started: number }, readonly output: string) {
99
+ super(`moved to background as ${job.id}`);
100
+ }
101
+ }
93
102
 
103
+ export default function (pi: ExtensionAPI) {
94
104
  const jobs = new Map<string, Job>();
95
105
  const ops = createLocalBashOperations();
96
106
  let next = 1;
@@ -110,6 +120,14 @@ export default function (pi: ExtensionAPI) {
110
120
  [...jobs.values()].filter((other) => other !== job && other.group === job.group && other.status === "running" && !other.watched);
111
121
  const report = (job: Job, content: string, details: { headline: string; last: string; error: boolean }) => {
112
122
  const others = job.watched ? [] : runningInGroup(job);
123
+ // Only Pi stops jobs, with bg_kill, so it already knows; the message is added without waking it again
124
+ if (job.status === "killed") {
125
+ if (others.length === 0) {
126
+ clearTimeout(holds.get(job.group));
127
+ holds.delete(job.group);
128
+ }
129
+ return wake(content, details, false);
130
+ }
113
131
  // A failure wakes Pi straight away, so it can react before the rest of the group finishes
114
132
  if (others.length === 0 || details.error) {
115
133
  clearTimeout(holds.get(job.group));
@@ -179,19 +197,24 @@ export default function (pi: ExtensionAPI) {
179
197
  }
180
198
  redrawJobs();
181
199
  };
200
+ // Running bash commands that ctrl+b can move to the background now, by tool call id, once they have run for
201
+ // HINT_AFTER_MS. Shared so pi-polish can show the hint under the command's row instead of in the jobs line.
202
+ const hinted = new Map<string, () => void>();
203
+ (globalThis as any)[Symbol.for("pi-background.movable")] = hinted;
204
+ let stopCtrlB = () => {};
182
205
  const jobsWidget = (theme: any) => ({
183
206
  invalidate() {},
184
207
  render(width: number) {
185
208
  if (jobsLine() === "off") return [];
186
209
  const live = running().filter((job) => job !== viewing);
187
210
  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}` : ""}`);
211
+ const silent = Date.now() - (job.lastOutput ?? job.started);
212
+ const detail = silent >= SILENT_AFTER_MS ? ` · no output for ${duration(silent)}` : jobsLine() === "full" && job.lastLine ? ` · ${job.lastLine}` : "";
213
+ const text = theme.fg("accent", "⏵ ") + theme.fg("toolTitle", `${job.id} ${shownCommand(job.command)}`) + theme.fg("dim", ` · ${elapsed(job)}${detail}`);
192
214
  return truncateToWidth(text, width);
193
215
  });
194
216
  if (live.length > MAX_SHOWN_JOBS) lines.push(theme.fg("dim", ` and ${live.length - MAX_SHOWN_JOBS} more`));
217
+ if (hinted.size > 0 && !(globalThis as any)[Symbol.for("pi-polish.showsMoveHint")]) lines.push(theme.fg("dim", " ctrl+b to run in background"));
195
218
  return lines;
196
219
  },
197
220
  });
@@ -204,24 +227,179 @@ export default function (pi: ExtensionAPI) {
204
227
 
205
228
  // Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering: quiet grey once done,
206
229
  // with a dot only while running or when something failed
230
+ // " └ text" in dim, with long and extra lines wrapped under the text instead of at the left edge
231
+ const branch = (theme: any, text: string, head?: string) => ({
232
+ invalidate() {},
233
+ render: (width: number) => [
234
+ ...(head ? [truncateToWidth(head, width)] : []),
235
+ ...wrapTextWithAnsi(text, Math.max(10, width - 4)).map((line, i) => theme.fg("dim", `${i === 0 ? " └ " : " "}${line}`)),
236
+ ],
237
+ });
207
238
  pi.registerMessageRenderer("background-job", (message, { expanded }, theme) => {
208
239
  const d = (message.details ?? {}) as { headline?: string; last?: string; error?: boolean };
209
240
  const title = `Background ${d.headline ?? ""}`;
210
241
  const head = d.error ? theme.fg("error", "● ") + theme.fg("toolTitle", title) : ` ${theme.fg("dim", title)}`;
211
- const body = expanded ? String(message.content) : (d.last ?? "");
212
- return new Text(`${head}\n${theme.fg("dim", ` └ ${body.replace(/\n/g, "\n ")}`)}`, 0, 0);
242
+ return branch(theme, expanded ? String(message.content) : (d.last ?? ""), head);
243
+ });
244
+ // One line, like pi-polish's shell rows; expanded (ctrl+o) shows the whole text, wrapped under the row
245
+ const row = (theme: any, text: string, context: any, full = text) => ({
246
+ invalidate() {},
247
+ render(width: number) {
248
+ const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : undefined;
249
+ const mark = color ? theme.fg(color, "● ") : " ";
250
+ const paint = (line: string) => theme.fg(color ? "toolTitle" : "dim", line);
251
+ if (!context.expanded) return [mark + paint(truncateToWidth(text, Math.max(10, width - 2)))];
252
+ return wrapTextWithAnsi(full, Math.max(10, width - 2)).map((line, i) => (i === 0 ? mark : " ") + paint(line));
253
+ },
213
254
  });
214
- const row = (theme: any, text: string, context: any) => {
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);
217
- };
218
255
  const note = (theme: any, result: any, expanded: boolean) => {
219
256
  const text = result.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
220
257
  // Collapsed: first sentence only ("Started bg1"), without the log path
221
258
  const short = text.split("\n")[0].split(";")[0];
222
- return new Text(theme.fg("dim", ` └ ${(expanded ? text : short).replace(/\n/g, "\n ")}`), 0, 0);
259
+ return branch(theme, expanded ? text : short);
260
+ };
261
+
262
+ // Starts tracking a job: its output goes to a log and the jobs line, and Pi is told when run's promise settles.
263
+ // A bash command that moves to the background brings its start time and the output it printed before.
264
+ const startJob = (
265
+ command: string,
266
+ pattern: RegExp | undefined,
267
+ controller: AbortController,
268
+ run: (onData: (d: Buffer) => void) => Promise<{ exitCode: number | null }>,
269
+ earlier?: { started: number; output: Buffer; lastOutput?: number },
270
+ ) => {
271
+ const id = `bg${next++}`;
272
+ const dir = join(tmpdir(), "pi-bg");
273
+ mkdirSync(dir, { recursive: true });
274
+ const log = join(dir, `${process.pid}-${id}.log`);
275
+ const out = createWriteStream(log);
276
+ const job: Job = { id, command, log, started: earlier?.started ?? Date.now(), controller, status: "running", group: turn, watched: Boolean(pattern) };
277
+ jobs.set(id, job);
278
+
279
+ const watch = pattern ? watcher(job, pattern) : undefined;
280
+ const onData = (d: Buffer) => {
281
+ out.write(d);
282
+ watch?.(d);
283
+ job.lastOutput = Date.now();
284
+ const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
285
+ if (last) job.lastLine = last;
286
+ };
287
+ if (earlier?.output.length) {
288
+ onData(earlier.output);
289
+ job.lastOutput = earlier.lastOutput;
290
+ }
291
+
292
+ run(onData)
293
+ .then(({ exitCode }) => {
294
+ job.status = "exited";
295
+ job.exitCode = exitCode;
296
+ })
297
+ .catch((err: Error) => {
298
+ job.status = controller.signal.aborted ? "killed" : "failed";
299
+ out.write(`\n[bg] ${err.message}\n`);
300
+ })
301
+ .finally(() => {
302
+ job.ended = Date.now();
303
+ showJobs();
304
+ out.end(() => {
305
+ // A kill from session shutdown has no session left to notify
306
+ if (job.status === "killed" && !jobs.has(id)) return;
307
+ report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
308
+ headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
309
+ last: tail(log, 1),
310
+ error: job.status !== "exited" || job.exitCode !== 0,
311
+ });
312
+ });
313
+ });
314
+
315
+ showJobs();
316
+ return job;
223
317
  };
224
318
 
319
+ // Pi's bash, except that a command still running after AUTO_BACKGROUND_SECS becomes a background job
320
+ // instead of blocking the turn until it ends or times out. The command keeps running in the same
321
+ // process; its output so far goes to the job's log, and Pi gets the usual message when it exits.
322
+ const handoff = (local: BashOperations, toolCallId: string): BashOperations => ({
323
+ exec(command, cwd, options) {
324
+ // Pi's own check, which its exec would otherwise have made
325
+ if (options.timeout !== undefined && !(Number.isFinite(options.timeout) && options.timeout > 0)) {
326
+ return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
327
+ }
328
+ const started = Date.now();
329
+ // Its own signal, so a job outlives the turn; until then the turn's abort is passed on
330
+ const controller = new AbortController();
331
+ const forward = () => controller.abort();
332
+ if (options.signal?.aborted) controller.abort();
333
+ options.signal?.addEventListener("abort", forward, { once: true });
334
+ // The output so far goes to the job's log; only its last TAIL_BYTES are kept, like the log tails
335
+ let earlier = Buffer.alloc(0);
336
+ let lastOutput: number | undefined;
337
+ let sink = (d: Buffer) => {
338
+ earlier = Buffer.concat([earlier, d]).subarray(-TAIL_BYTES);
339
+ lastOutput = Date.now();
340
+ options.onData(d);
341
+ };
342
+ const env = { ...(options.env ?? process.env), PYTHONUNBUFFERED: "1" };
343
+ // A timeout moves the command to the background sooner rather than stopping it, so its work isn't lost
344
+ const after = Math.min(options.timeout ?? AUTO_BACKGROUND_SECS, AUTO_BACKGROUND_SECS);
345
+ const running = local.exec(command, cwd, { onData: (d) => sink(d), signal: controller.signal, env });
346
+ return new Promise((resolve, reject) => {
347
+ let timer: NodeJS.Timeout | undefined;
348
+ let hint: NodeJS.Timeout | undefined;
349
+ const settle = () => {
350
+ clearTimeout(timer);
351
+ clearTimeout(hint);
352
+ options.signal?.removeEventListener("abort", forward);
353
+ if (hinted.delete(toolCallId)) redrawJobs();
354
+ };
355
+ const move = () => {
356
+ settle();
357
+ const output = earlier;
358
+ const job = startJob(command, undefined, controller, (onData) => {
359
+ sink = onData;
360
+ return running;
361
+ }, { started, output, lastOutput });
362
+ reject(new MovedToBackground(job, output.toString("utf8")));
363
+ };
364
+ timer = setTimeout(move, after * 1000);
365
+ hint = setTimeout(() => {
366
+ hinted.set(toolCallId, move);
367
+ redrawJobs();
368
+ }, HINT_AFTER_MS);
369
+ running.then(resolve, reject).finally(settle);
370
+ });
371
+ },
372
+ });
373
+ // Only Pi's own bash is wrapped; one that another extension replaced, such as a sandbox's, is left alone. By
374
+ // session_start every extension has registered its tools, whichever order they loaded in.
375
+ let wrapped = false;
376
+ pi.on("session_start", async () => {
377
+ if (wrapped || !pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo.path.startsWith("builtin:")) return;
378
+ wrapped = true;
379
+ const pisBash = createBashToolDefinition(process.cwd());
380
+ pi.registerTool({
381
+ ...pisBash,
382
+ promptGuidelines: [
383
+ ...(pisBash.promptGuidelines ?? []),
384
+ `A bash command still running after ${AUTO_BACKGROUND_SECS} seconds, or after its timeout if that is shorter, moves to the background on its own, as if started with bg_run, and you get its job id instead of its output; it is not stopped. Still start a command you expect to take that long with bg_run, so you choose wake_on and the turn isn't held up first.`,
385
+ ],
386
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
387
+ // Built per call from the same settings Pi builds its own bash from
388
+ const { shellPath, shellCommandPrefix } = pi.getSettings();
389
+ const bash = createBashToolDefinition(ctx.cwd, { commandPrefix: shellCommandPrefix, operations: handoff(createLocalBashOperations({ shellPath }), toolCallId) });
390
+ try {
391
+ return await bash.execute(toolCallId, params, signal, onUpdate, ctx);
392
+ } catch (err) {
393
+ if (!(err instanceof MovedToBackground)) throw err;
394
+ const { id, log, started } = err.job;
395
+ const soFar = err.output.trimEnd().split("\n").slice(-TAIL_LINES).join("\n") || "(no output)";
396
+ const text = `Still running after ${duration(Date.now() - started)}, so it moved to the background as ${id}. You will be notified when it exits; check it with bg_status, or stop it with bg_kill if it shouldn't take this long.\nOutput so far:\n${soFar}\nlog: ${log}`;
397
+ return { content: [{ type: "text", text }], details: undefined };
398
+ }
399
+ },
400
+ });
401
+ });
402
+
225
403
  pi.registerTool({
226
404
  name: "bg_run",
227
405
  label: "Background",
@@ -236,71 +414,39 @@ export default function (pi: ExtensionAPI) {
236
414
  "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.",
237
415
  "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.",
238
416
  "Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
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.",
417
+ "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. Make it a pattern for that one event, which matches once, not a line the job prints for every item, file, epoch or step. To report a job's result, leave wake_on out: the exit message already brings the exit code and last lines.",
240
418
  "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.",
241
419
  "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.",
242
420
  "Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
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.`,
244
421
  "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.",
245
422
  "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.",
246
423
  ],
247
424
  // Runs after the edits in the same reply, so the job starts from the code pi just wrote
248
425
  executionMode: "sequential",
249
426
  renderShell: "self",
250
- renderCall: (args: any, theme, context) => row(theme, `Background $ ${String(args?.command ?? "").split("\n")[0]}`, context),
427
+ renderCall: (args: any, theme, context) => {
428
+ const command = String(args?.command ?? "");
429
+ // Collapsed, without leading settings such as PYTHONPATH=/some/src, like the jobs line; ctrl+o shows them
430
+ const short = shortCommand(command, args?.cwd ?? process.cwd()).replace(/^(\w+=("[^"]*"|'[^']*'|\S*)\s+)+/, "");
431
+ return row(theme, `Background $ ${short}`, context, `Background $ ${command.trim()}`);
432
+ },
251
433
  renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
252
434
  parameters: Type.Object({
253
435
  command: Type.String({ description: "Shell command to run" }),
254
436
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
255
437
  wake_on: Type.Optional(
256
438
  Type.String({
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.",
439
+ 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 ${MAX_WAKES} wake-ups per job.`,
258
440
  }),
259
441
  ),
260
442
  }),
261
443
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
262
444
  const pattern = params.wake_on ? wakePattern(params.wake_on) : undefined;
263
- const id = `bg${next++}`;
264
- const dir = join(tmpdir(), "pi-bg");
265
- mkdirSync(dir, { recursive: true });
266
- const log = join(dir, `${process.pid}-${id}.log`);
267
- const out = createWriteStream(log);
268
- const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running", group: turn, watched: Boolean(pattern) };
269
- jobs.set(id, job);
270
-
271
- const watch = pattern ? watcher(job, pattern) : undefined;
272
- const onData = (d: Buffer) => {
273
- out.write(d);
274
- watch?.(d);
275
- const last = d.toString("utf8").split("\n").map(cleanLine).filter((line) => line.trim()).at(-1)?.trim();
276
- if (last) job.lastLine = last;
277
- };
278
-
445
+ const controller = new AbortController();
279
446
  // Output goes to a pipe, where Python would buffer prints until it exits; unbuffered, the latest line and wake_on stay live
280
- ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
281
- .then(({ exitCode }) => {
282
- job.status = "exited";
283
- job.exitCode = exitCode;
284
- })
285
- .catch((err: Error) => {
286
- job.status = job.controller.signal.aborted ? "killed" : "failed";
287
- out.write(`\n[bg] ${err.message}\n`);
288
- })
289
- .finally(() => {
290
- job.ended = Date.now();
291
- showJobs();
292
- out.end(() => {
293
- // A kill from session shutdown has no session left to notify
294
- if (job.status === "killed" && !jobs.has(id)) return;
295
- report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
296
- headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
297
- last: tail(log, 1),
298
- error: job.status !== "exited" || job.exitCode !== 0,
299
- });
300
- });
301
- });
302
-
303
- showJobs();
447
+ const { id, log } = startJob(params.command, pattern, controller, (onData) =>
448
+ ops.exec(`export PYTHONUNBUFFERED=1; ${params.command}`, params.cwd ?? ctx.cwd, { onData, signal: controller.signal }),
449
+ );
304
450
  return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
305
451
  },
306
452
  });
@@ -343,60 +489,73 @@ export default function (pi: ExtensionAPI) {
343
489
  });
344
490
 
345
491
  // Shows a job's latest output in place of the input until esc, refreshed every second while it runs
492
+ const showJob = async (args: string, ctx: ExtensionContext) => {
493
+ if (!ctx.hasUI) return;
494
+ const all = [...jobs.values()].reverse();
495
+ if (all.length === 0) return ctx.ui.notify("No background jobs yet", "info");
496
+ // "/bg 2" works as well as "/bg bg2"
497
+ const wanted = args.trim().toLowerCase().replace(/^(\d+)$/, "bg$1");
498
+ let job = wanted ? jobs.get(wanted) : all.length === 1 ? all[0] : undefined;
499
+ if (wanted && !job) return ctx.ui.notify(`No background job ${args.trim()}; there's ${[...jobs.keys()].join(", ")}`, "warning");
500
+ if (!job) {
501
+ // Running jobs first, then the latest finished ones
502
+ const listed = [...all.filter((other) => other.status === "running"), ...all.filter((other) => other.status !== "running").slice(0, MAX_LISTED_FINISHED)];
503
+ // Matched by position, since the elapsed time in a label changes while the list is open
504
+ // Ids and commands padded to one width, so the states and times line up
505
+ const idWidth = Math.max(...listed.map((other) => other.id.length));
506
+ const commandWidth = Math.max(...listed.map((other) => shownCommand(other.command).length));
507
+ const options = listed.map((other) => `${other.id.padEnd(idWidth)} ${shownCommand(other.command).padEnd(commandWidth)} · ${state(other)} · ${elapsed(other)}`);
508
+ job = listed[options.indexOf((await ctx.ui.select("Background jobs", options)) ?? "")];
509
+ if (!job) return;
510
+ }
511
+ const shown = job;
512
+ viewing = shown;
513
+ const log = shown.log.replace(homedir(), "~");
514
+ try {
515
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
516
+ const timer = setInterval(() => tui.requestRender(), 1000);
517
+ return {
518
+ dispose: () => clearInterval(timer),
519
+ invalidate() {},
520
+ handleInput: (data: string) => {
521
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") done();
522
+ },
523
+ render(width: number) {
524
+ const color = shown.status === "running" ? "accent" : shown.status === "exited" && shown.exitCode === 0 ? "success" : "error";
525
+ const title = theme.fg(color, " ● ") + theme.fg("toolTitle", `${shown.id} ${shownCommand(shown.command)}`);
526
+ const status = theme.fg("muted", `${state(shown)} · ${elapsed(shown)} `);
527
+ const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(status));
528
+ // At most half the terminal, so the conversation above stays in view
529
+ const text = tail(shown.log, Math.max(3, Math.min(TAIL_LINES, Math.floor(tui.terminal.rows / 2))));
530
+ const output = text === "(no output)" ? [theme.fg("dim", theme.italic(" no output yet"))] : text.split("\n").map((line) => ` ${cleanLine(line)}`);
531
+ return [
532
+ theme.fg("borderMuted", "─".repeat(width)),
533
+ title + " ".repeat(gap) + status,
534
+ ...output,
535
+ theme.fg("dim", ` esc to close · ${log}`),
536
+ theme.fg("borderMuted", "─".repeat(width)),
537
+ ].map((line) => truncateToWidth(line, width));
538
+ },
539
+ };
540
+ });
541
+ } finally {
542
+ viewing = undefined;
543
+ }
544
+ };
346
545
  pi.registerCommand("bg", {
347
546
  description: "Show a background job's latest output",
348
547
  getArgumentCompletions: (prefix) => {
349
548
  const matching = [...jobs.values()].filter((job) => job.id.startsWith(prefix.trim().toLowerCase()));
350
549
  return matching.length ? matching.map((job) => ({ value: job.id, label: job.id, description: `${shownCommand(job.command)} · ${state(job)}` })) : null;
351
550
  },
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
- },
551
+ handler: showJob,
552
+ });
553
+ // "/bg3" without the space isn't a command, so pi would send it to the model as a message; open the job instead
554
+ pi.on("input", async (event, ctx) => {
555
+ const id = event.text.trim().match(/^\/(bg\d+)$/i)?.[1];
556
+ if (!id || !jobs.has(id.toLowerCase())) return { action: "continue" };
557
+ await showJob(id, ctx);
558
+ return { action: "handled" };
400
559
  });
401
560
 
402
561
  // After /reload or a resume, carry on after the ids already in the conversation so "bg3" keeps meaning one job
@@ -406,6 +565,13 @@ export default function (pi: ExtensionAPI) {
406
565
  redrawJobs = () => tui.requestRender();
407
566
  return jobsWidget(theme);
408
567
  });
568
+ // ctrl+b moves running bash commands to the background; otherwise it stays the editor's cursor-left
569
+ stopCtrlB();
570
+ stopCtrlB = ctx.ui.onTerminalInput((data) => {
571
+ if (hinted.size === 0 || !matchesKey(data, Key.ctrl("b"))) return undefined;
572
+ for (const move of [...hinted.values()]) move();
573
+ return { consume: true };
574
+ });
409
575
  }
410
576
  for (const entry of ctx.sessionManager.getBranch()) {
411
577
  const msg = entry.type === "message" ? (entry.message as any) : undefined;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",