Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ 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.

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
shell runner and no external daemon — the extension spawns the job in-process
and uses the child `exit` event while that extension generation remains active.
On `/reload` or session shutdown it detaches generation-bound callbacks; the
replacement extension reconciles completion from the self-describing log and
never invokes stale Pi APIs. The log contains full output plus 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
Expand Down Expand Up @@ -74,12 +76,17 @@ agent calls bgrun(command: "make test-short", name: "unit-tests")
→ records job in-memory + appends a bgrun-job entry to the session
→ returns "started: <job-id>"

child 'exit' event fires:
child 'exit' event fires while the same extension generation is active:
→ 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
→ ctx.ui.setWidget("bgrun", ...) — updates/clears the live status widget

/reload or session shutdown happens first:
→ old generation invalidates itself and detaches child listeners
→ detached process continues writing its log and exit marker
→ active replacement generation reconstructs and persists completion once
```

The child writes the log directly via its own stdout fd (no pipe to pi), so the job
Expand Down Expand Up @@ -198,7 +205,9 @@ the dir is shared by every pi session working in that checkout — that sharing
enables cross-session job lookup, session-restart reconstruction, and
per-project cleanup. By default each session only *tracks its own jobs*: the
widget and `bgstatus` listings show this session's running jobs, and finished
jobs are hidden (ask for them explicitly with `bgstatus includeDone: true`).
jobs are hidden (ask for them explicitly with `bgstatus includeDone: true`). The
extension also emits `bgrun:status` with `{ running, tracked }`, allowing a
custom footer to replace the widget by setting `showWidget: false`.
Jobs started by other sessions can still be inspected by id, but they don't
clutter your widget.

Expand All @@ -218,6 +227,7 @@ run locally (completed jobs visible, a scorecard on `bun test` runs).
{
"adoptForeignJobs": false,
"showCompletedJobs": false,
"showWidget": true,
"cleanupDays": 7,
"maxLogBytes": 67108864,
"globalAutoClean": true,
Expand Down Expand Up @@ -317,6 +327,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_SHOW_WIDGET` | `true` | Render the built-in multiline widget. Set false when a footer consumes `bgrun:status`. |
| `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). |
Expand Down
75 changes: 75 additions & 0 deletions extension/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ type UsedExtensionAPI = Pick<
| "registerEntryRenderer"
| "sendUserMessage"
| "appendEntry"
| "events"
>;

// Tools as the tests consume them: real metadata types from ToolDefinition, but
Expand Down Expand Up @@ -173,6 +174,7 @@ interface FakePiHandles {
pi: ExtensionAPI;
wakes: CapturedWake[];
entries: CapturedEntry[];
jobStatuses: Array<{ running: number; tracked: number }>;
tools: Map<string, FakeTool>;
commands: Map<string, FakeCommand>;
entryRenderers: Map<string, FakeRenderer>;
Expand All @@ -193,6 +195,7 @@ function makeFakePi(
const entries: CapturedEntry[] = opts.priorEntries
? [...opts.priorEntries]
: [];
const jobStatuses: Array<{ running: number; tracked: number }> = [];
const tools = new Map<string, FakeTool>();
const commands = new Map<string, FakeCommand>();
const entryRenderers = new Map<string, FakeRenderer>();
Expand All @@ -210,6 +213,12 @@ function makeFakePi(
// compile-time drift guard. `as ExtensionAPI` below is the unavoidable seam
// (the fake is deliberately partial); the *shapes* here are the real ones.
const used: UsedExtensionAPI = {
events: {
emit(name: string, data: unknown) {
if (name === "bgrun:status")
jobStatuses.push(data as { running: number; tracked: number });
},
} as ExtensionAPI["events"],
sendUserMessage(content, options) {
wakes.push({
text: content as string,
Expand Down Expand Up @@ -256,6 +265,7 @@ function makeFakePi(
pi,
wakes,
entries,
jobStatuses,
tools,
commands,
entryRenderers,
Expand Down Expand Up @@ -334,6 +344,19 @@ function waitForWakes(
});
}

async function waitForLogExit(logPath: string, timeoutMs = 4000): Promise<void> {
const start = Date.now();
while (Date.now() - start <= timeoutMs) {
try {
if (/__BGRUN_EXIT__=-?\d+/.test(readFileSync(logPath, "utf8"))) return;
} catch {
// log may not exist yet
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`timed out waiting for exit marker in ${logPath}`);
}

test("bgrun: exit marker survives commands with # and explicit exit codes", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
process.env.PI_BGRUN_DIR = dir;
Expand Down Expand Up @@ -412,6 +435,23 @@ test("bgrun: successful command writes log + exit marker and wakes with ✅", as
});
});

test("bgrun: emits compact running-job status for footer integrations", async () => {
await withJobsDir(async (_dir, h) => {
const { jobStatuses, tools, ctx } = h;
const bgrun = tools.get("bgrun")!;
await bgrun.execute(
"footer-status",
{ command: "sleep 0.1" },
undefined,
undefined,
ctx,
);
assert.equal(jobStatuses.at(-1)?.running, 1);
await new Promise((resolve) => setTimeout(resolve, 175));
assert.equal(jobStatuses.at(-1)?.running, 0);
});
});

test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => {
await withJobsDir(async (_dir, h) => {
const { wakes, tools, ctx } = h;
Expand Down Expand Up @@ -5272,6 +5312,41 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct
}
});

test("session_shutdown: detached completion is reconciled by the active session without stale callbacks", async () => {
await withJobsDir(async (dir, h) => {
const { entries, wakes, tools, ctx, fireSessionShutdown } = h;
const bgrun = tools.get("bgrun")!;
const result = await bgrun.execute(
"reload-race",
{ command: "sleep 0.15; echo after-reload", wake: "always" },
undefined,
undefined,
ctx,
);
const id = (result.content[0].text as string).match(/^started: ([^\n]+)/)![1];
const logPath = join(dir, `${id}.log`);

await fireSessionShutdown();
await waitForLogExit(logPath);
await new Promise((resolve) => setTimeout(resolve, 25));

assert.equal(wakes.length, 0, "disposed generation did not wake the agent");
assert.equal(
entries.filter((entry) => entry.data?.id === id).length,
1,
"disposed generation persisted only the running entry",
);

const replacement = makeFakePi({ priorEntries: entries });
await loadExtension(replacement.pi);
await replacement.fireSessionStart();
const records = replacement.entries.filter((entry) => entry.data?.id === id);
assert.equal(records.length, 2, "active generation reconciled completion once");
assert.equal(records[1].data?.state, "done");
assert.equal(records[1].data?.exitCode, 0);
});
});

test("session_shutdown: sweeps this session's old logs and does not throw", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
process.env.PI_BGRUN_DIR = dir;
Expand Down
69 changes: 63 additions & 6 deletions extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,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;
// Render the built-in multiline status widget. Integrations can disable it
// and consume the `bgrun:status` event in a compact footer instead.
showWidget: boolean;
// 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
Expand Down Expand Up @@ -693,6 +696,7 @@ interface BgrunConfigFile {
jobsDir?: unknown;
adoptForeignJobs?: unknown;
showCompletedJobs?: unknown;
showWidget?: unknown;
cleanupDays?: unknown;
maxLogBytes?: unknown;
globalAutoClean?: unknown;
Expand Down Expand Up @@ -1249,6 +1253,8 @@ export function resolveConfig(ctx?: {
typeof merged.showCompletedJobs === "boolean"
? merged.showCompletedJobs
: undefined;
const widgetFile =
typeof merged.showWidget === "boolean" ? merged.showWidget : undefined;
const globalCleanFile =
typeof merged.globalAutoClean === "boolean"
? merged.globalAutoClean
Expand Down Expand Up @@ -1324,6 +1330,8 @@ export function resolveConfig(ctx?: {
parseBoolEnv(process.env.PI_BGRUN_SHOW_COMPLETED) ??
completedFile ??
false,
showWidget:
parseBoolEnv(process.env.PI_BGRUN_SHOW_WIDGET) ?? widgetFile ?? true,
cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
maxLogBytes: maxBytesEnv ?? maxBytesFile ?? DEFAULT_MAX_LOG_BYTES,
globalAutoClean:
Expand Down Expand Up @@ -1382,6 +1390,8 @@ interface JobRecord {
exitCode?: number;
donePersisted?: boolean; // done entry already appended to the transcript
child?: ReturnType<typeof spawn>; // absent for adopted (fs-discovered) jobs
exitListener?: (code: number | null, signal: NodeJS.Signals | null) => void;
errorListener?: (error: Error) => void;
ctx: ExtensionContext; // captured at tool-call time for isIdle() in the exit handler
adopted?: boolean; // true when discovered from the jobs dir (another session's job)
}
Expand Down Expand Up @@ -1414,6 +1424,9 @@ interface BgStatusDetails {

export default function (pi: ExtensionAPI) {
const jobs = new Map<string, JobRecord>();
// Extension APIs and contexts are generation-bound. Detached processes and
// their logs outlive /reload; callbacks registered by this generation do not.
let disposed = false;
// bgtail's delta-tailing bookmarks: one entry per job id ever tailed, holding
// the high-water mark of what the caller has already had the opportunity to
// see. Declared here, ahead of the cleanup helpers, so removing a log can
Expand Down Expand Up @@ -1560,13 +1573,19 @@ export default function (pi: ExtensionAPI) {
ctx: ExtensionContext,
opts: { persistRevalidate?: boolean } = {},
): void {
if (!ctx.hasUI) return;
// Reconciliation is lifecycle state, not a UI side effect. Headless/RPC
// sessions must persist terminal evidence too.
revalidateStaleJobs({ persist: opts.persistRevalidate ?? true });
const running: JobRecord[] = [];
for (const rec of jobs.values()) {
if (rec.exitCode === undefined) running.push(rec);
}
if (running.length === 0) {
pi.events.emit("bgrun:status", {
running: running.length,
tracked: jobs.size,
});
if (!ctx.hasUI) return;
if (!resolveConfig(ctx).showWidget || running.length === 0) {
ctx.ui.setWidget("bgrun", undefined);
return;
}
Expand Down Expand Up @@ -1848,6 +1867,7 @@ export default function (pi: ExtensionAPI) {
// accurate view, but asking for status must not append transcript cards.
// The stale poller / session_start re-run with persistence and reconcile.
function persistDoneEntry(rec: JobRecord, exit: number): void {
if (disposed) return;
rec.donePersisted = true;
pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
id: rec.id,
Expand Down Expand Up @@ -1907,6 +1927,7 @@ export default function (pi: ExtensionAPI) {
function ensureStalePoller(ctx: ExtensionContext): void {
if (stalePoller !== undefined || !hasUnsupervisedRunning()) return;
stalePoller = setInterval(() => {
if (disposed) return;
revalidateStaleJobs();
updateWidget(ctx);
if (!hasUnsupervisedRunning()) stopStalePoller();
Expand Down Expand Up @@ -1980,6 +2001,11 @@ export default function (pi: ExtensionAPI) {
// ── session_start: reconstruct Map from entries + auto-cleanup ────────────

pi.on("session_start", async (_event, ctx) => {
disposed = false;
// A single extension instance can observe a session switch. Never carry
// another session's in-memory ownership into the new session.
jobs.clear();
tailBookmarks.clear();
// Reconstruct the in-memory Map from this session's bgrun-job entries.
// Only the current session's entries are visible; jobs from other sessions
// remain discoverable via the filesystem scan in bgstatus.
Expand Down Expand Up @@ -2066,7 +2092,21 @@ export default function (pi: ExtensionAPI) {
});

pi.on("session_shutdown", async (_event, ctx) => {
// Invalidate before any cleanup. A child may complete while shutdown is in
// progress, but this generation must never call Pi APIs afterward.
disposed = true;
stopStalePoller();
for (const rec of jobs.values()) {
if (!rec.child) continue;
if (rec.exitListener) rec.child.removeListener("exit", rec.exitListener);
if (rec.errorListener) rec.child.removeListener("error", rec.errorListener);
// Avoid an unhandled late spawn error after detaching our generation-
// bound listener. Completion itself remains authoritative in the log.
rec.child.on("error", () => {});
delete rec.child;
delete rec.exitListener;
delete rec.errorListener;
}
// Sweep old logs on the way out. Throttled via the .last-clean marker so
// restart-heavy workflows don't sweep more than once per cleanupDays.
try {
Expand Down Expand Up @@ -2118,6 +2158,9 @@ export default function (pi: ExtensionAPI) {
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (disposed) {
throw new Error("bgrun: extension session is shutting down; retry after reload");
}
const { command, name: rawName, type: rawType } = params;
if (!command || !command.trim()) {
throw new Error("bgrun: command is required");
Expand Down Expand Up @@ -2237,6 +2280,7 @@ export default function (pi: ExtensionAPI) {
updateWidget(ctx);

const finishSpawnFailure = (err: Error) => {
if (disposed) return;
const rec = jobs.get(id);
if (!rec || rec.exitCode !== undefined) return;
rec.exitedAt = Date.now();
Expand Down Expand Up @@ -2289,7 +2333,11 @@ export default function (pi: ExtensionAPI) {
};

// ── exit handler: record exit, persist done entry, wake, notify, widget ─
child.on("exit", async (code, signal) => {
const exitListener = async (
code: number | null,
signal: NodeJS.Signals | null,
) => {
if (disposed) return;
const rec = jobs.get(id);
if (!rec) return;
// A spawn that emitted 'error' first already finalized this job; a
Expand Down Expand Up @@ -2407,6 +2455,10 @@ export default function (pi: ExtensionAPI) {
);
}

// A digest awaits an external child. Shutdown/reload may happen in
// that gap; the old generation must make no further Pi API calls.
if (disposed) return;

// Wake the agent.
const namePrefix = rec.name ? `"${rec.name}" ` : "";
let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`;
Expand Down Expand Up @@ -2445,12 +2497,17 @@ export default function (pi: ExtensionAPI) {

// Update/clear the widget.
updateWidget(rec.ctx);
});
};

child.on("error", (err) => {
const errorListener = (err: Error) => {
if (disposed) return;
console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message);
finishSpawnFailure(err);
});
};
record.exitListener = exitListener;
record.errorListener = errorListener;
child.on("exit", exitListener);
child.on("error", errorListener);

const startedLines = [`started: ${id}`];
if (name) startedLines.push(` name: ${name}`);
Expand Down