pi-background 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas van Dongen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # pi-background
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ <picture>
6
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pringled/pi-extensions/main/assets/images/pi-background-dark.gif">
7
+ <img src="https://raw.githubusercontent.com/Pringled/pi-extensions/main/assets/images/pi-background-light.gif" alt="pi-background demo">
8
+ </picture>
9
+
10
+ ```bash
11
+ pi install npm:pi-background
12
+ ```
13
+
14
+ ## Features
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.
17
+ - 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
+ - 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
+ 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
+ ## Tools
23
+
24
+ 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.
25
+
26
+ ## License
27
+
28
+ MIT
@@ -0,0 +1,245 @@
1
+ import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
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";
6
+ import { Type } from "typebox";
7
+
8
+ const TAIL_LINES = 20;
9
+ const TAIL_BYTES = 64 * 1024;
10
+ const BASH_DEFAULT_TIMEOUT = 120;
11
+ const BASH_MAX_TIMEOUT = 600;
12
+ const WAKE_DEBOUNCE_MS = 1000;
13
+ const MAX_WAKES = 10;
14
+
15
+ interface Job {
16
+ id: string;
17
+ command: string;
18
+ log: string;
19
+ started: number;
20
+ controller: AbortController;
21
+ status: "running" | "exited" | "killed" | "failed";
22
+ exitCode?: number | null;
23
+ ended?: number;
24
+ }
25
+
26
+ function tail(path: string, lines: number): string {
27
+ try {
28
+ const text = readFileSync(path).subarray(-TAIL_BYTES).toString("utf8");
29
+ return text.trimEnd().split("\n").slice(-lines).join("\n") || "(no output)";
30
+ } catch {
31
+ return "(no output)";
32
+ }
33
+ }
34
+
35
+ function elapsed(job: Job): string {
36
+ const s = Math.round(((job.ended ?? Date.now()) - job.started) / 1000);
37
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
38
+ }
39
+
40
+ function summary(job: Job): string {
41
+ const state = job.status === "exited" ? `exit ${job.exitCode}` : job.status;
42
+ return `${job.id} [${state}, ${elapsed(job)}] ${job.command}\n log: ${job.log}`;
43
+ }
44
+
45
+ /** True when a command starts a job with a bare & (not &&, 2>&1, &> or |&). */
46
+ function startsBackgroundJob(command: string): boolean {
47
+ return /&/.test(command.replace(/&&|[0-9]*>&[0-9-]*|&>>?|\|&/g, ""));
48
+ }
49
+
50
+ /** Compiles wake_on, accepting Python's (?i) prefix that models often write but JavaScript only takes as a flag. */
51
+ function wakePattern(source: string): RegExp {
52
+ const insensitive = source.startsWith("(?i)");
53
+ return new RegExp(insensitive ? source.slice(4) : source, insensitive ? "i" : "");
54
+ }
55
+
56
+ export default function (pi: ExtensionAPI) {
57
+ // Pi's bash has no default timeout, so a stuck or polling command blocks until interrupted; cap it like Claude Code
58
+ // does. A timeout SIGKILLs the command's whole process group, which would include jobs it started with &, so a
59
+ // command that backgrounds something is never timed out, even if the model asked for a timeout.
60
+ pi.on("tool_call", async (event) => {
61
+ if (event.toolName !== "bash") return;
62
+ const input = event.input as { command?: string; timeout?: number };
63
+ if (startsBackgroundJob(String(input.command ?? ""))) delete input.timeout;
64
+ else input.timeout = Math.min(input.timeout ?? BASH_DEFAULT_TIMEOUT, BASH_MAX_TIMEOUT);
65
+ });
66
+
67
+ const jobs = new Map<string, Job>();
68
+ const ops = createLocalBashOperations();
69
+ let next = 1;
70
+
71
+ const wake = (content: string, details: { headline: string; last: string; error: boolean }) =>
72
+ pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn: true, deliverAs: "followUp" });
73
+
74
+ // Wakes the agent with output lines matching pattern, batched per second and capped per job
75
+ const watcher = (job: Job, pattern: RegExp) => {
76
+ let partial = "";
77
+ let pending: string[] = [];
78
+ let timer: NodeJS.Timeout | undefined;
79
+ let wakes = 0;
80
+ const flush = () => {
81
+ timer = undefined;
82
+ // Once the job has ended, its final message carries the log tail instead
83
+ if (job.status !== "running") return;
84
+ wakes++;
85
+ const capped = wakes === MAX_WAKES ? `\n(Reached ${MAX_WAKES} wake-ups; further matches are ignored, use bg_status to check.)` : "";
86
+ wake(`Background job ${job.id} output matched /${pattern.source}/ (still running):\n${pending.slice(-TAIL_LINES).join("\n")}${capped}`, {
87
+ headline: `${job.id} matched /${pattern.source}/`,
88
+ last: pending[pending.length - 1] ?? "",
89
+ error: false,
90
+ });
91
+ pending = [];
92
+ };
93
+ return (data: Buffer) => {
94
+ if (wakes >= MAX_WAKES) return;
95
+ const lines = (partial + data.toString("utf8")).split(/\r?\n|\r/);
96
+ partial = lines.pop() ?? "";
97
+ const hits = lines.filter((line) => pattern.test(line));
98
+ if (hits.length === 0) return;
99
+ pending.push(...hits);
100
+ timer ??= setTimeout(flush, WAKE_DEBOUNCE_MS);
101
+ };
102
+ };
103
+
104
+ const find = (id: string): Job => {
105
+ const job = jobs.get(id);
106
+ if (!job) throw new Error(`No background job ${id}; known: ${[...jobs.keys()].join(", ") || "none"}`);
107
+ return job;
108
+ };
109
+
110
+ // Compact, Claude-Code-style rows instead of pi's default boxed tool and message rendering
111
+ pi.registerMessageRenderer("background-job", (message, { expanded }, theme) => {
112
+ const d = (message.details ?? {}) as { headline?: string; last?: string; error?: boolean };
113
+ const head = theme.fg(d.error ? "error" : "success", "● ") + theme.fg("toolTitle", `Background ${d.headline ?? ""}`);
114
+ const body = expanded ? String(message.content) : (d.last ?? "");
115
+ return new Text(`${head}\n${theme.fg("dim", ` └ ${body.replace(/\n/g, "\n ")}`)}`, 0, 0);
116
+ });
117
+ const row = (theme: any, text: string, context: any) => {
118
+ const color = context.isError ? "error" : context.executionStarted && context.isPartial ? "accent" : "success";
119
+ return new Text(theme.fg(color, "● ") + theme.fg("toolTitle", text), 0, 0);
120
+ };
121
+ const note = (theme: any, result: any, expanded: boolean) => {
122
+ const text = result.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
123
+ // Collapsed: first sentence only ("Started bg1"), without the log path
124
+ const short = text.split("\n")[0].split(";")[0];
125
+ return new Text(theme.fg("dim", ` └ ${(expanded ? text : short).replace(/\n/g, "\n ")}`), 0, 0);
126
+ };
127
+
128
+ pi.registerTool({
129
+ name: "bg_run",
130
+ label: "Background",
131
+ description:
132
+ "Start a shell command in the background and return immediately with a job id. Output goes to a log file. " +
133
+ "When the command exits you automatically receive a message with its exit code and the last log lines, so do not poll or sleep waiting for it.",
134
+ promptSnippet: "Run long shell commands (downloads, builds, long test suites) in the background",
135
+ promptGuidelines: [
136
+ "Use bg_run instead of bash for any command that may take longer than about 30 seconds, in particular: downloading large files, datasets, dumps or model weights; " +
137
+ "installing heavy packages (torch, tensorflow, CUDA libraries, transformers, large requirement sets) or creating environments with them; cloning large repositories; " +
138
+ "builds, compiles and docker builds; full test suites of non-trivial projects; training, evaluation, benchmark and data-processing scripts; and anything that sleeps, waits or polls.",
139
+ "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.",
140
+ "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
+ "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
+ "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.",
143
+ "Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
144
+ `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
+ "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.",
146
+ "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.",
147
+ ],
148
+ renderShell: "self",
149
+ renderCall: (args: any, theme, context) => row(theme, `Background $ ${String(args?.command ?? "").split("\n")[0]}`, context),
150
+ renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
151
+ parameters: Type.Object({
152
+ command: Type.String({ description: "Shell command to run" }),
153
+ cwd: Type.Optional(Type.String({ description: "Working directory (defaults to the session cwd)" })),
154
+ wake_on: Type.Optional(
155
+ Type.String({
156
+ 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.",
157
+ }),
158
+ ),
159
+ }),
160
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
161
+ const pattern = params.wake_on ? wakePattern(params.wake_on) : undefined;
162
+ const id = `bg${next++}`;
163
+ const dir = join(tmpdir(), "pi-bg");
164
+ mkdirSync(dir, { recursive: true });
165
+ const log = join(dir, `${process.pid}-${id}.log`);
166
+ const out = createWriteStream(log);
167
+ const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running" };
168
+ jobs.set(id, job);
169
+
170
+ const watch = pattern ? watcher(job, pattern) : undefined;
171
+ const onData = (d: Buffer) => {
172
+ out.write(d);
173
+ watch?.(d);
174
+ };
175
+
176
+ ops.exec(params.command, params.cwd ?? ctx.cwd, { onData, signal: job.controller.signal })
177
+ .then(({ exitCode }) => {
178
+ job.status = "exited";
179
+ job.exitCode = exitCode;
180
+ })
181
+ .catch((err: Error) => {
182
+ job.status = job.controller.signal.aborted ? "killed" : "failed";
183
+ out.write(`\n[bg] ${err.message}\n`);
184
+ })
185
+ .finally(() => {
186
+ job.ended = Date.now();
187
+ out.end(() => {
188
+ // A kill from session shutdown has no session left to notify
189
+ if (job.status === "killed" && !jobs.has(id)) return;
190
+ wake(`Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
191
+ headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
192
+ last: tail(log, 1),
193
+ error: job.status !== "exited" || job.exitCode !== 0,
194
+ });
195
+ });
196
+ });
197
+
198
+ return { content: [{ type: "text", text: `Started ${id}; log: ${log}. You will be notified when it exits.` }], details: { id, log } };
199
+ },
200
+ });
201
+
202
+ pi.registerTool({
203
+ name: "bg_status",
204
+ label: "Background status",
205
+ description: "List background jobs, or show one job's state and the tail of its log.",
206
+ renderShell: "self",
207
+ renderCall: (args: any, theme, context) => row(theme, `Background status ${args?.id ?? "(all)"}`, context),
208
+ renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
209
+ parameters: Type.Object({
210
+ id: Type.Optional(Type.String({ description: "Job id such as bg1; omit to list all jobs" })),
211
+ lines: Type.Optional(Type.Number({ description: `Log lines to show (default ${TAIL_LINES})` })),
212
+ }),
213
+ async execute(_toolCallId, params) {
214
+ if (!params.id) {
215
+ const text = [...jobs.values()].map(summary).join("\n") || "No background jobs.";
216
+ return { content: [{ type: "text", text }], details: {} };
217
+ }
218
+ const job = find(params.id);
219
+ return { content: [{ type: "text", text: `${summary(job)}\n${tail(job.log, params.lines ?? TAIL_LINES)}` }], details: {} };
220
+ },
221
+ });
222
+
223
+ pi.registerTool({
224
+ name: "bg_kill",
225
+ label: "Background kill",
226
+ description: "Stop a running background job and its child processes.",
227
+ renderShell: "self",
228
+ renderCall: (args: any, theme, context) => row(theme, `Background kill ${args?.id ?? ""}`, context),
229
+ renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
230
+ parameters: Type.Object({ id: Type.String({ description: "Job id such as bg1" }) }),
231
+ async execute(_toolCallId, params) {
232
+ const job = find(params.id);
233
+ if (job.status !== "running") return { content: [{ type: "text", text: `${job.id} already ${job.status}` }], details: {} };
234
+ job.controller.abort();
235
+ return { content: [{ type: "text", text: `Stopping ${job.id}` }], details: {} };
236
+ },
237
+ });
238
+
239
+ pi.on("session_shutdown", async () => {
240
+ for (const job of jobs.values()) {
241
+ if (job.status === "running") job.controller.abort();
242
+ }
243
+ jobs.clear();
244
+ });
245
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "author": "Thomas van Dongen",
6
+ "type": "module",
7
+ "homepage": "https://github.com/pringled/pi-extensions/tree/main/packages/pi-background#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/pringled/pi-extensions/issues"
10
+ },
11
+ "files": [
12
+ "extensions",
13
+ "README.md",
14
+ "LICENSE"
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",
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi",
20
+ "pi-coding-agent",
21
+ "background",
22
+ "jobs",
23
+ "timeout"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/pringled/pi-extensions.git",
28
+ "directory": "packages/pi-background"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions/background.ts"
33
+ ],
34
+ "image": "https://raw.githubusercontent.com/Pringled/pi-extensions/main/assets/images/pi-background-dark.gif"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*",
39
+ "typebox": "*"
40
+ }
41
+ }