diff --git a/README.md b/README.md index e27e8c9..aca17b8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # pi-background-run -Run long shell commands (test suites, builds, linters) as detached background jobs -so your pi agent session stays unblocked and its context stays clean. Output lands -on disk — the full log plus a trailing exit marker — so nothing large ever enters -the conversation; the command returns immediately. When the job finishes, -pi-background-run **wakes the live agent session** so it proactively reads a -condensed digest of the results and continues — no polling, no human intervention. +Run genuinely asynchronous shell commands as detached background jobs so your pi +agent session stays unblocked and its context stays clean. Output lands on disk — +the full log plus a trailing exit marker — so nothing large enters the conversation; +the command returns immediately. Each job chooses whether completion wakes the +live agent always, only on failure, or never. Human toast and widget updates remain +enabled for every policy. Built as a [pi](https://github.com/earendil-works/pi-coding-agent) extension. No shell runner and no external daemon — the extension spawns the job in-process, -detects completion via the child `exit` event, and calls `pi.sendUserMessage` to wake -the agent. The log file is self-describing (full output + a trailing +detects completion via the child `exit` event, and conditionally calls +`pi.sendUserMessage` when the job's wake policy requests a model turn. The log +file is self-describing (full output + a trailing `__BGRUN_EXIT__=N` marker), so exit codes survive pi restarting. Two small pieces exist beyond the spawn: a 30s timer that only re-checks jobs whose live child handle is gone (reconstructed from a restart, or adopted from another session), and a @@ -33,12 +34,32 @@ Restart pi after install so the extension loads. | Tool | Purpose | | ------ | --------- | -| `bgrun` | Launch a command detached in the background. Optional `name` gives the job a short human-readable label. Returns `started: ` immediately. Wakes the session automatically on completion. | +| `bgrun` | Launch a command detached in the background. Optional `name` gives the job a short human-readable label. `wake` selects `never`, `failure`, or `always` model-turn delivery. Returns `started: ` immediately. | | `bgstatus` | Show job status. With an id: any job's state + exit code. Without: this session's running jobs (finished jobs hidden by default — pass `includeDone: true` or set `showCompletedJobs`). Other sessions' *running* jobs are listed only when `adoptForeignJobs` is enabled; finished foreign logs from the shared dir can also appear when finished jobs are included. | | `bgtail` | Read the newest lines of a job's log (default 40; it reads the log's **last 2 MB** — widen with `bytes`, max 64 MiB), **condensed for context**: ANSI escapes stripped, repeated lines collapsed, long lines and total size capped. First read = full last-N tail; repeat reads return **only lines appended since your last read** (delta tailing) — polling a running job never re-pays for lines already seen. Pass `raw: true` for the unprocessed last-N window (still advances the bookmark). | | `bggrep` | Regex search over the **last 2 MB** of a job's log (`bytes` widens the window, max 64 MiB): line-numbered matches, optional `context` lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. Resolves the job id to the configured jobs dir itself — no log path to reconstruct. `ctx_execute_file` can read the same file (it takes an absolute path; only your Read-deny rules apply), but it needs that path. Matching runs under a wall-clock budget ([Bounded matching](#bounded-matching)). With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). | | `bgclean` | Remove old job logs. **Default scope: this session's jobs only** — other sessions' logs are untouched — and it also drops stale per-project digest markers (`.bgrun-used-*`, `.digest-nudge-*`) in the session's jobs dir (markers are not session data). Pass `all: true` to sweep every shared jobs dir — under the project-local default that is the project's dir plus the machine-global one, while an explicit absolute `jobsDir` is swept alone — and do the same marker sweep across them. Retention: `cleanupDays` config (7 days); `days` must be a positive number (`days: 0` is rejected rather than purging everything). Never removes a running job's log. | +## Completion wake policy + +Every job accepts `wake: "never" | "failure" | "always"`: + +- `never` keeps model context quiet; completion still updates the toast/widget and persists status/logs. +- `failure` wakes only for a non-zero exit or spawn failure. +- `always` preserves the original behavior and wakes on every completion. + +Omitting `wake` uses `defaultWake` from layered configuration (`PI_BGRUN_WAKE` +overrides it). The package default remains `always` for backward compatibility; +users who want opt-in model turns can set `"defaultWake": "never"`. Per-job +policy is persisted in transcript entries and displayed by `bgstatus` after a +session reload. + +Use `always` for deployment/eval monitors whose completion requires immediate +follow-up, `failure` for long checks whose successful completion needs no model +turn, and `never` for independent work. Foreground execution remains the default +for routine focused commands; do not choose background execution from command +category alone. + ## Slash commands Human-facing mirrors of the read/clean tools, usable directly in the TUI @@ -64,7 +85,7 @@ wake messages) is the agent's workflow. ## How it works ```text -agent calls bgrun(command: "make test-short", name: "unit-tests") +agent calls bgrun(command: "gh run watch …", name: "deploy-monitor", wake: "always") → extension resolves log path: /--.log (default /.pi-bgrun/jobs/ in a repo, else ~/.pi-bgrun/jobs/) → spawn('sh', ['-c', , 'bgrun', ''], { stdio: ['ignore', logFd, logFd], detached: true }).unref() @@ -76,9 +97,9 @@ agent calls bgrun(command: "make test-short", name: "unit-tests") child 'exit' event fires: → extension records exit code, appends a done entry - → pi.sendUserMessage(wake) when idle (triggers a turn) - or pi.sendUserMessage(wake, { deliverAs: 'followUp' }) when busy - → ctx.ui.notify(...) — toast for the human + → when the per-job wake policy matches the outcome, pi.sendUserMessage(wake) + triggers a turn when idle or queues a follow-up when busy + → ctx.ui.notify(...) — toast for the human, regardless of wake policy → ctx.ui.setWidget("bgrun", ...) — updates/clears the live status widget ``` @@ -218,6 +239,7 @@ run locally (completed jobs visible, a scorecard on `bun test` runs). { "adoptForeignJobs": false, "showCompletedJobs": false, + "defaultWake": "always", "cleanupDays": 7, "maxLogBytes": 67108864, "globalAutoClean": true, @@ -317,6 +339,7 @@ Environment variables (same knobs, handy for one-off overrides): | `PI_BGRUN_GLOBAL_DIR` | `~/.pi-bgrun/jobs` | **Deprecated.** Overrides the machine-global jobs base — the fallback used only when the cwd has no project root (see [deprecation](#deprecated-machine-global-jobs-dir)). A leading `~` or `~/` is expanded to the home dir; `~user` is not. | | `PI_BGRUN_FOREIGN_JOBS` | `false` | Adopt other sessions' running jobs into this session's widget and job list. Adopted jobs are polled so they leave the widget when they finish. | | `PI_BGRUN_SHOW_COMPLETED` | `false` | Include finished jobs in `bgstatus` listings by default. | +| `PI_BGRUN_WAKE` | `always` | Default model-turn completion policy when a job omits `wake`: `never`, `failure`, or `always`. Per-job `wake` takes precedence. Toast/widget updates are unaffected. | | `PI_BGRUN_CLEANUP_DAYS` | `7` | Log retention for cleanup sweeps and the `bgclean` default. | | `PI_BGRUN_MAX_LOG_BYTES` | `67108864` (64 MiB) | Byte ceiling for a job's log (stdout+stderr). `0` disables it (unlimited). See [Log size ceiling](#log-size-ceiling). | | `PI_BGRUN_GLOBAL_AUTO_CLEAN` | `true` | Set `0`/`false` to disable the automatic orphan sweep (see below). | @@ -325,10 +348,11 @@ Environment variables (same knobs, handy for one-off overrides): ### Digest scorecard (opt-in) -Wake messages always lead with universal facts — exit code, duration, and the -command's own log line count (the internal exit marker is excluded). A project -can additionally opt into a **digest scorecard**: a one-line pass/fail summary -extracted from the log and appended to the wake. +When a job's wake policy requests a model turn, its message leads with universal +facts — exit code, duration, and the command's own log line count (the internal +exit marker is excluded). A project can additionally opt into a **digest +scorecard**: a one-line pass/fail summary extracted from the log and appended +to that wake. #### Job identity: name, type, command diff --git a/extension/index.test.ts b/extension/index.test.ts index bd82996..dd1093f 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -311,6 +311,27 @@ async function withJobsDir( // Default below Bun's 5s test timeout so a stuck wait rejects with a clear // message instead of racing the harness kill (a flake-masking failure mode). +function waitForLogExit( + logPath: string, + timeoutMs = 4000, +): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + try { + if (readFileSync(logPath, "utf8").includes("__BGRUN_EXIT__=")) + return resolve(); + } catch { + // The child may not have created/renamed the final log yet. + } + if (Date.now() - start > timeoutMs) + return reject(new Error(`timed out waiting for ${logPath} to finish`)); + setTimeout(tick, 50); + }; + tick(); + }); +} + function waitForWakes( wakes: CapturedWake[], count: number, @@ -412,6 +433,60 @@ test("bgrun: successful command writes log + exit marker and wakes with ✅", as }); }); +test("bgrun: wake never keeps model context quiet while persisting completion", async () => { + await withJobsDir(async (dir, h) => { + const { wakes, entries, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; + const res = await bgrun.execute( + "call-never", + { command: "echo quiet-success", wake: "never" }, + undefined, + undefined, + ctx, + ); + const text = res.content[0].text as string; + const id = text.match(/^started: ([^\n]+)/)![1]; + assert.match(text, /wake: never/); + assert.match(text, /without waking the agent/); + await waitForLogExit(join(dir, `${id}.log`)); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(wakes.length, 0); + const records = entries.filter((entry) => entry.data?.id === id); + assert.equal(records.length, 2, "running + done entries persisted"); + assert.ok(records.every((entry) => entry.data?.wake === "never")); + }); +}); + +test("bgrun: wake failure ignores success and wakes on non-zero exit", async () => { + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; + const success = await bgrun.execute( + "call-failure-success", + { command: "echo pass", wake: "failure" }, + undefined, + undefined, + ctx, + ); + const successId = (success.content[0].text as string).match( + /^started: ([^\n]+)/, + )![1]; + await waitForLogExit(join(dir, `${successId}.log`)); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(wakes.length, 0); + + await bgrun.execute( + "call-failure-error", + { command: "echo failed; exit 9", wake: "failure" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + assert.match(wakes[0].text, /exit 9/); + }); +}); + test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => { await withJobsDir(async (_dir, h) => { const { wakes, tools, ctx } = h; @@ -5215,7 +5290,7 @@ test("wake digest: invalid type entry dropped, other entries still work", async } }); -test("bgrun: type flows into the started result, entries, and resume reconstruction", async () => { +test("bgrun: type and wake policy survive entry persistence and reconstruction", async () => { const dir = mkTmp("pi-bgrun-test-"); process.env.PI_BGRUN_DIR = dir; try { @@ -5224,7 +5299,12 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct const bgrun = tools.get("bgrun")!; const res = await bgrun.execute( "call-ty1", - { command: "echo typed", name: "unit-tests", type: "Test" }, + { + command: "echo typed; exit 1", + name: "unit-tests", + type: "Test", + wake: "failure", + }, undefined, undefined, ctx, @@ -5235,12 +5315,15 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct assert.match(started, /^ {2}name: unit-tests$/m); // Types are lowercase-normalized so selection is an exact compare. assert.match(started, /^ {2}type: test$/m); + assert.match(started, /^ {2}wake: failure$/m); assert.equal((res.details as any).type, "test"); + assert.equal((res.details as any).wake, "failure"); await waitForWakes(wakes, 1); // The persisted done entry carries the type. const done = entries.filter((e) => e.customType === "bgrun-job").at(-1); assert.equal(done?.data?.type, "test"); + assert.equal(done?.data?.wake, "failure"); // Resume: a fresh instance reconstructs the in-memory map from entries. const { @@ -5266,6 +5349,8 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct "reconstructed record carries the type", ); assert.equal((status.details as any).type, "test"); + assert.match(text, /^ {2}wake: failure$/m); + assert.equal((status.details as any).wake, "failure"); } finally { delete process.env.PI_BGRUN_DIR; rmSync(dir, { recursive: true, force: true }); @@ -5668,10 +5753,15 @@ test("resolveConfig: env vars override config files", async () => { const userCfg = join(userDir, "pi-bgrun.json"); const prevDir = process.env.PI_BGRUN_DIR; const prevDays = process.env.PI_BGRUN_CLEANUP_DAYS; + const prevWake = process.env.PI_BGRUN_WAKE; delete process.env.PI_BGRUN_DIR; try { - writeFileSync(userCfg, JSON.stringify({ cleanupDays: 11 })); + writeFileSync( + userCfg, + JSON.stringify({ cleanupDays: 11, defaultWake: "failure" }), + ); process.env.PI_BGRUN_CLEANUP_DAYS = "3"; + process.env.PI_BGRUN_WAKE = "never"; const cfg = mod.resolveConfig({ cwd: proj, @@ -5679,10 +5769,13 @@ test("resolveConfig: env vars override config files", async () => { userConfigPath: userCfg, }); assert.equal(cfg.cleanupDays, 3, "env beats both config files"); + assert.equal(cfg.defaultWake, "never", "wake env beats config file"); } finally { if (prevDir !== undefined) process.env.PI_BGRUN_DIR = prevDir; if (prevDays === undefined) delete process.env.PI_BGRUN_CLEANUP_DAYS; else process.env.PI_BGRUN_CLEANUP_DAYS = prevDays; + if (prevWake === undefined) delete process.env.PI_BGRUN_WAKE; + else process.env.PI_BGRUN_WAKE = prevWake; rmSync(proj, { recursive: true, force: true }); rmSync(userDir, { recursive: true, force: true }); } diff --git a/extension/index.ts b/extension/index.ts index 1de3644..60539d2 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -1,14 +1,15 @@ /** * pi-bgrun — pi extension that runs long shell commands detached in the - * background and wakes the live agent session on completion. + * background and optionally wakes the live agent session on completion. * * Architecture: * - In-process spawn via child_process.spawn with stdio redirected to a log file * (detached + unref so the job survives pi crashing). * - The child wraps the command to append a trailing __BGRUN_EXIT__=N marker, * making the log self-describing — exit codes survive pi restarting. - * - Completion is the child 'exit' event, not a poller. The exit handler wakes - * the agent via pi.sendUserMessage (triggers a turn when idle; followUp when busy). + * - Completion is the child 'exit' event, not a poller. The exit handler follows + * the per-job wake policy before using pi.sendUserMessage; toast/widget updates + * remain unconditional. * - Job records persist via pi.appendEntry (survives same-session restart, * renders as a card in the transcript, does NOT enter LLM context). * - Live status widget above the editor while jobs are running. @@ -645,6 +646,21 @@ function logReadError(id: string, logPath: string): string { // //pi-bgrun.json (project, honored only when the project // is trusted), with PI_BGRUN_* env vars as overrides. +export type WakePolicy = "never" | "failure" | "always"; + +export function shouldWakeAgent( + policy: WakePolicy, + exitCode: number, +): boolean { + return policy === "always" || (policy === "failure" && exitCode !== 0); +} + +function normalizeWakePolicy(value: unknown): WakePolicy | undefined { + return value === "never" || value === "failure" || value === "always" + ? value + : undefined; +} + interface BgrunConfig { jobsDir: string; // True when jobsDir resolves inside the project root (the default in a @@ -659,6 +675,9 @@ interface BgrunConfig { // Include finished jobs in bgstatus listings by default. Default false — // completed jobs are noise; ask for them explicitly (bgstatus includeDone). showCompletedJobs: boolean; + // Default policy for injecting a completion message into the model turn. + // Human toast/widget updates are independent and always remain enabled. + defaultWake: WakePolicy; // Log retention for cleanup (auto-sweeps and the bgclean default). cleanupDays: number; // Byte ceiling for a job's log (stdout+stderr). A runaway job (`yes`, a spew @@ -693,6 +712,7 @@ interface BgrunConfigFile { jobsDir?: unknown; adoptForeignJobs?: unknown; showCompletedJobs?: unknown; + defaultWake?: unknown; cleanupDays?: unknown; maxLogBytes?: unknown; globalAutoClean?: unknown; @@ -1253,6 +1273,8 @@ export function resolveConfig(ctx?: { typeof merged.globalAutoClean === "boolean" ? merged.globalAutoClean : undefined; + const wakeFile = normalizeWakePolicy(merged.defaultWake); + const wakeEnv = normalizeWakePolicy(process.env.PI_BGRUN_WAKE); const dirFile = typeof merged.jobsDir === "string" && merged.jobsDir ? merged.jobsDir @@ -1324,6 +1346,9 @@ export function resolveConfig(ctx?: { parseBoolEnv(process.env.PI_BGRUN_SHOW_COMPLETED) ?? completedFile ?? false, + // Preserve the package's historical behavior unless the user/project opts + // into quieter defaults. Each bgrun call can still override this policy. + defaultWake: wakeEnv ?? wakeFile ?? "always", cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS, maxLogBytes: maxBytesEnv ?? maxBytesFile ?? DEFAULT_MAX_LOG_BYTES, globalAutoClean: @@ -1376,6 +1401,7 @@ interface JobRecord { cmd: string; name?: string; // optional human-readable label type?: string; // optional job type used for digest scorecard selection + wake: WakePolicy; // whether completion injects a model turn started: number; logPath: string; exitedAt?: number; @@ -1394,6 +1420,7 @@ interface BgrunJobEntryData { cmd: string; name?: string; type?: string; + wake?: WakePolicy; started: number; logPath: string; state: "running" | "done"; @@ -1408,6 +1435,7 @@ interface BgStatusDetails { cmd?: string; name?: string; type?: string; + wake?: WakePolicy; count?: number; recovered?: boolean; } @@ -1855,6 +1883,7 @@ export default function (pi: ExtensionAPI) { cmd: rec.cmd, name: rec.name, type: rec.type, + wake: rec.wake, started: rec.started, logPath: rec.logPath, state: "done", @@ -2007,6 +2036,7 @@ export default function (pi: ExtensionAPI) { cmd: d.cmd, name: d.name, type: d.type, + wake: d.wake ?? resolveConfig(ctx).defaultWake, started: d.started, logPath: d.logPath, exitedAt: d.exitedAt, @@ -2041,6 +2071,7 @@ export default function (pi: ExtensionAPI) { id: entry.id, pid: entry.pid ?? -1, cmd: "(started by another session)", + wake: "never", started: entry.birthtimeMs || Date.now(), logPath: entry.logPath, ctx, @@ -2082,18 +2113,18 @@ export default function (pi: ExtensionAPI) { name: "bgrun", label: "Run in Background", description: - "Run a long shell command detached in the background. Returns 'started: ' immediately. " + - "You will be woken automatically when the job finishes. Use this instead of bash for any command " + - "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " + - "short human-readable label used in the job id, status output, and wake messages, and `type` to " + - "select the project's digest scorecard.", + "Run a genuinely asynchronous shell command detached in the background. Returns 'started: ' immediately. " + + "Use this for deployment/CI monitoring, long evals, sustained observability, or work that must continue while the agent does something else—not merely because a command is a test, build, lint, query, or external request. " + + "Choose `wake` explicitly when the agent must resume on completion; human toast/widget updates always remain enabled. " + + "Optionally pass `name` for a short human-readable label and `type` to select the project's digest scorecard.", promptSnippet: - "Run a long command detached in the background; get woken on completion", + "Run a genuinely asynchronous command detached; choose whether completion should wake the agent", promptGuidelines: [ - "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.", - "Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.", - "When the project's digest config defines `type` entries, pass the matching `type` (e.g. type: 'test') so the wake selects the right scorecard; the vocabulary comes from the project's `.pi/pi-bgrun.json` digest entries.", - "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.", + "Default to foreground execution. Use bgrun only for genuinely asynchronous monitoring/concurrency or work known to be long-running; do not infer background execution from command category alone.", + "Set wake:'always' when continuation depends on completion (deploy/eval monitors), wake:'failure' when only failure needs attention, or wake:'never' for independent work.", + "Give every bgrun job a short name so it is recognizable in status output, the status widget, and notifications.", + "When the project's digest config defines `type` entries, pass the matching `type` so a waking job selects the right scorecard.", + "After bgrun returns a job id, continue other work; do not poll through model turns.", "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search or ctx_execute_file on the log path for whole-log analysis.", ], parameters: Type.Object({ @@ -2116,9 +2147,26 @@ export default function (pi: ExtensionAPI) { "`.pi/pi-bgrun.json`; when the project's digest config defines types, prefer passing the matching one.", }), ), + wake: Type.Optional( + Type.Union( + ["never", "failure", "always"].map((policy) => + Type.Literal(policy), + ), + { + description: + "Whether completion injects a model turn: never, only on failure, or always. " + + "When omitted, defaultWake from configuration applies. Human toast/widget updates are always shown.", + }, + ), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const { command, name: rawName, type: rawType } = params; + const { + command, + name: rawName, + type: rawType, + wake: rawWake, + } = params; if (!command || !command.trim()) { throw new Error("bgrun: command is required"); } @@ -2126,6 +2174,7 @@ export default function (pi: ExtensionAPI) { const type = sanitizeType(rawType); const cfg = resolveConfig(ctx); + const wake = normalizeWakePolicy(rawWake) ?? cfg.defaultWake; // Project-local logs are auto-ignored in .git/info/exclude (best-effort) // so they never pollute `git status`. Absolute dirs are left untouched. if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir); @@ -2215,6 +2264,7 @@ export default function (pi: ExtensionAPI) { cmd: command, name, type, + wake, started: Date.now(), logPath, child, @@ -2229,6 +2279,7 @@ export default function (pi: ExtensionAPI) { cmd: command, name, type, + wake, started: Date.now(), logPath, state: "running", @@ -2256,27 +2307,32 @@ export default function (pi: ExtensionAPI) { pid: rec.pid, cmd: rec.cmd, name: rec.name, + type: rec.type, + wake: rec.wake, started: rec.started, logPath: rec.logPath, state: "done", exitCode: -1, exitedAt: rec.exitedAt, }); - const namePrefix = rec.name ? `"${rec.name}" ` : ""; - const wake = - `❌ Background job ${namePrefix}\`${id}\` failed to start: ${err.message}\n` + - `Command: ${command}`; - try { - if (rec.ctx.isIdle()) pi.sendUserMessage(wake); - else pi.sendUserMessage(wake, { deliverAs: "followUp" }); - } catch { + if (shouldWakeAgent(rec.wake, -1)) { + const namePrefix = rec.name ? `"${rec.name}" ` : ""; + const wakeMessage = + `❌ Background job ${namePrefix}\`${id}\` failed to start: ${err.message}\n` + + `Command: ${command}`; try { - pi.sendUserMessage(wake, { deliverAs: "followUp" }); - } catch (e2) { - console.error( - `[pi-bgrun] wake failed for job ${id}:`, - (e2 as Error).message, - ); + if (rec.ctx.isIdle()) pi.sendUserMessage(wakeMessage); + else + pi.sendUserMessage(wakeMessage, { deliverAs: "followUp" }); + } catch { + try { + pi.sendUserMessage(wakeMessage, { deliverAs: "followUp" }); + } catch (e2) { + console.error( + `[pi-bgrun] wake failed for job ${id}:`, + (e2 as Error).message, + ); + } } } if (rec.ctx.hasUI) { @@ -2335,6 +2391,7 @@ export default function (pi: ExtensionAPI) { cmd: rec.cmd, name: rec.name, type: rec.type, + wake: rec.wake, started: rec.started, logPath, state: "done", @@ -2353,8 +2410,10 @@ export default function (pi: ExtensionAPI) { // that fails, times out, or prints // nothing appends nothing, and the exit code / universal part above are // never affected. + const willWake = shouldWakeAgent(rec.wake, exitCode); let digestBlock: { label: string; text: string } | undefined; - try { + if (willWake) { + try { // First matching entry wins, in config order. The label defaults to // the entry's label, the entry's type, a matched `match.name`, then // the entry's preset id (or "command"). @@ -2399,38 +2458,43 @@ export default function (pi: ExtensionAPI) { } } } - } catch (e) { - // Silent-fail: a broken digest never breaks a wake (ground rule 3). - console.error( - `[pi-bgrun] digest failed for job ${id}:`, - (e as Error).message, - ); + } catch (e) { + // Silent-fail: a broken digest never breaks a wake (ground rule 3). + console.error( + `[pi-bgrun] digest failed for job ${id}:`, + (e as Error).message, + ); + } } - // Wake the agent. - const namePrefix = rec.name ? `"${rec.name}" ` : ""; - let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`; - wake += `Command: ${command}\n`; - wake += `Stats: ${statsParts.join(", ")}\n`; - if (lastLine) wake += `Last output: ${lastLine}\n`; - if (digestBlock) { - wake += `digest (${digestBlock.label}): ${digestBlock.text}\n`; - } - wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`; - try { - if (rec.ctx.isIdle()) { - pi.sendUserMessage(wake); - } else { - pi.sendUserMessage(wake, { deliverAs: "followUp" }); + // Wake the agent only when this job's explicit/configured policy + // requires a model turn. Toast and widget updates below are always + // delivered independently. + if (willWake) { + const namePrefix = rec.name ? `"${rec.name}" ` : ""; + let wakeMessage = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`; + wakeMessage += `Command: ${command}\n`; + wakeMessage += `Stats: ${statsParts.join(", ")}\n`; + if (lastLine) wakeMessage += `Last output: ${lastLine}\n`; + if (digestBlock) { + wakeMessage += `digest (${digestBlock.label}): ${digestBlock.text}\n`; } - } catch { + wakeMessage += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`; try { - pi.sendUserMessage(wake, { deliverAs: "followUp" }); - } catch (e2) { - console.error( - `[pi-bgrun] wake failed for job ${id}:`, - (e2 as Error).message, - ); + if (rec.ctx.isIdle()) { + pi.sendUserMessage(wakeMessage); + } else { + pi.sendUserMessage(wakeMessage, { deliverAs: "followUp" }); + } + } catch { + try { + pi.sendUserMessage(wakeMessage, { deliverAs: "followUp" }); + } catch (e2) { + console.error( + `[pi-bgrun] wake failed for job ${id}:`, + (e2 as Error).message, + ); + } } } @@ -2455,13 +2519,18 @@ export default function (pi: ExtensionAPI) { const startedLines = [`started: ${id}`]; if (name) startedLines.push(` name: ${name}`); if (type) startedLines.push(` type: ${type}`); - startedLines.push( - ` log: ${logPath}`, - ` You'll be woken automatically when it finishes.`, - ); + startedLines.push(` wake: ${wake}`, ` log: ${logPath}`); + if (wake === "always") + startedLines.push(" You'll be woken when it finishes."); + else if (wake === "failure") + startedLines.push(" You'll be woken only if it fails."); + else + startedLines.push( + " Completion will update the toast/widget without waking the agent.", + ); return { content: [{ type: "text", text: startedLines.join("\n") }], - details: { id, name, type, logPath, pid: childPid }, + details: { id, name, type, wake, logPath, pid: childPid }, }; } finally { if (logFd !== undefined) closeSync(logFd); @@ -3211,6 +3280,7 @@ export default function (pi: ExtensionAPI) { const lines = [`${id}: ${state}${exitStr}`]; if (rec.name) lines.push(` name: ${rec.name}`); if (rec.type) lines.push(` type: ${rec.type}`); + lines.push(` wake: ${rec.wake}`); lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`); return { content: [{ type: "text", text: lines.join("\n") }], @@ -3221,6 +3291,7 @@ export default function (pi: ExtensionAPI) { cmd: rec.cmd, name: rec.name, type: rec.type, + wake: rec.wake, recovered: false, }, }; diff --git a/package.json b/package.json index efc97da..597c020 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pi-background-run", "version": "0.6.0", - "description": "Run long shell commands detached in the background for pi; get woken on completion. Output lands in a file; context stays clean.", + "description": "Run genuinely asynchronous shell commands detached in the background for pi, with explicit completion wake policies and bounded logs.", "type": "module", "engines": { "node": ">=20" diff --git a/skill/run-bg/SKILL.md b/skill/run-bg/SKILL.md index 0908ae2..0dc1be8 100644 --- a/skill/run-bg/SKILL.md +++ b/skill/run-bg/SKILL.md @@ -1,34 +1,35 @@ --- name: run-bg -description: Use when running any long or verbose shell command (make test, go test ./..., - make lint, builds) so output lands in a file instead of flooding context and the session - stays unblocked. Start the job, hand control back, check status later, and read only a - tail or a code-processed summary of the log. +description: Use for genuinely asynchronous shell work such as deployment or CI monitoring, + long evals, sustained observability, or commands that must continue while the agent does + other work. Choose whether completion wakes the model; do not use merely because a command + is a test, build, lint, query, or external request. --- # Run in Background (pi-bgrun) -Run long/verbose commands detached. Output → file. Context stays clean; the session -never blocks. The extension wakes this session automatically when the job finishes — -no polling. +Run genuinely asynchronous commands detached. Output → file and the session stays +unblocked. Human toast/widget updates always happen; the `wake` policy decides whether +completion also injects a model turn. Never poll through model turns. ## When to use -- Any command expected to run > ~30s OR emit > ~100 lines. -- Typical: `make test`, `go test ./...`, `make lint`, `make build`. -- Integration / infra suites (long-running, always background). +- Deployment, CI, or merge-queue monitoring that must trigger follow-up work. +- Long evals, sustained observability, installs, or integration suites that need to run while other work continues. +- Independent long-running work whose output should stay on disk. ## When NOT to use -- Commands that complete in < ~5s — the overhead isn't worth it. -- Short, quiet commands whose full output you actually need (`git status`). +- Do not select bgrun merely because a command is a test, build, lint, database query, or external request. +- Default to foreground execution for routine and focused checks. Reassess after a fast or fail-fast result. +- For verbose but quick commands, redirect raw output to a file and print a bounded summary instead of creating a background lifecycle. - Interactive commands (prompts, REPL, SSH) — bgrun detaches from the terminal. ## Tools | Action | Tool | |---|---| -| Start | `bgrun(command: "make test-short", name: "unit-tests", type: "test")` → `started: ` (name is an optional short label; use it so jobs are recognizable in `bgstatus`, the status widget, and wake messages) | +| Start | `bgrun(command: "gh run watch …", name: "deploy-monitor", wake: "always")` → `started: ` (`wake` is `never`, `failure`, or `always`) | | Status | `bgstatus()` for one job, or `bgstatus()` for this session's running jobs — finished jobs are hidden by default; pass `includeDone: true` to list them | | Tail | `bgtail(, 40)` — first read: last-40 tail; later reads: only lines appended since (delta tailing) | | Grep | `bggrep(, "pattern", context?)` — line-numbered matches, capped and condensed; default pattern = generic failure signatures (override when you know the format) | @@ -36,12 +37,14 @@ no polling. ## Workflow -1. **Start:** call `bgrun` with the command (and a short `name`, e.g. `name: "unit-tests"`). +1. **Start:** call `bgrun` with the command, a short `name`, and an intentional wake policy: + - `wake: "always"` when continuation depends on completion (deploy/eval monitors); + - `wake: "failure"` when success needs no model turn; + - `wake: "never"` for independent work. When the project's digest config defines `type` entries, also pass the - matching `type` (e.g. `type: "test"`) — like `name`, it helps the wake - select the right digest scorecard. Note the returned job-id. Continue other - work; you will be woken automatically when the job finishes. -2. **On wake:** check the exit status in the wake message first. + matching `type`; a digest is useful only for jobs that wake. Note the returned + job-id and continue other work. +2. **On wake (if requested):** check the exit status in the wake message first. - `exit: 0` → success. `bgtail` to confirm. - `exit: ` → failure. Analyze the log (see below). 3. **If you need to check before the wake (non-blocking):** call `bgstatus` with the job id.