Run long shell commands (test suites, builds, linters) as detached background jobs so your 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.
Built as an extension for both pi
and oh-my-pi (omp). 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
__BGRUN_EXIT__=N marker), so exit codes survive the agent 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
.last-clean marker that throttles the global orphan sweep (the
session-scoped sweep is unthrottled).
pi install npm:pi-background-run # pi
omp plugin install npm:pi-background-run # oh-my-piThe scoped alias @stablekernel/pi-background-run is the same package (permanent
namespace claim, published in lockstep). Prefer the unscoped name; the alias is
not deprecated, so both stay installable and receive every release.
Restart the agent after install so the extension loads.
One extension serves both hosts. Paths in this README are written against two host-supplied names:
| Name | pi | oh-my-pi |
|---|---|---|
$AGENT_DIR — the user-level agent directory |
~/.pi/agent |
~/.omp/agent (profile-aware) |
$CONFIG_DIR — the project config directory |
.pi |
.omp |
The host also decides several details, all handled internally:
- Tool visibility.
ompmounts any tool that does not opt out as anxd://device (write xd://bgrun {…}); bgrun declaresloadMode: "essential"so all six tools stay directly callable, exactly as on pi. - Tool guidance. pi reads the tool definition's
promptSnippet/promptGuidelinesinto the system prompt; omp reads onlydescription, so on omp the same bullets are folded into the description instead of being dropped. - Job cards. pi renders an
bgrun-jobcard per job in the transcript. omp has no entry renderer (pi.appendEntryrecords are never rendered — it renders onlypi.sendMessageentries), so on omp the job card is skipped. The entries are still persisted on both hosts, and the index is rebuilt from them onsession_startand on omp'ssession_switch/session_branch(which is how omp announces/new,/resume, a fork and a tree branch). The card's job — "what is running, and how did the last one end" — is covered on both hosts by the editor panel and the status line (see What the human sees) at no context cost and without an entry renderer; the transcript still anchors each job through thebgruntool card and the wake message, andbgstatushas the full history on demand. - Host-managed background jobs. omp backgrounds long
bashcalls itself and exposes its in-process job list to extensions (getAsyncJobSnapshot), so bgrun reports those jobs alongside its own — in the panel, the status line andbgstatus(see What the human sees) — and reads back the artifact the host spills a truncated job's full output to. pi has no job manager, no snapshot API and no session artifacts, so every one of those paths degrades to bgrun-only. Nothing here is ever adopted, cancelled or cleaned by bgrun: those jobs are the host's, andhubis how the agent steers them. - Diagnostics. Warnings and errors go to
pi.loggerwhen the host has one (omp writes~/.omp/logs/omp.<date>.<pid>.log; the TUI owns the terminal, so a raw stderr write would corrupt it) and to the console on pi. Anything the agent may need to act on is not left there: the digest type-mismatch note rides the wake instead (see Multiple scorecards).
| Tool | Purpose |
|---|---|
bgrun |
Launch a command detached in the background. Optional name gives the job a short human-readable label. Returns started: <job-id> immediately. Wakes the session automatically on completion. |
bgstatus |
Show job status. With an id: any job's state + exit code — a bg_N id (the host's own background job on omp) is answered with its state, where its output went and how to cancel it, rather than "not found". Without: this session's running jobs (finished jobs hidden by default — pass includeDone: true or set showCompletedJobs), then the host's background jobs under their own heading. 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 + 4 KiB of wrapper overhead), condensed for context: ANSI escapes stripped, repeated lines collapsed (a run of 3+ folds into one line carrying its [xN] count; a pair is kept as two lines — a fold needs its count to stay legible), long lines and total size capped. First read = full last-N tail; repeat reads return only lines appended since your last read (delta tailing), so a line already shown is never sent twice. While the job is still running it returns its state and nothing else — launching was the task, the wake is the next input; peek: true reads the live log anyway. A repeat read asking for more lines than you have already been shown is a widening, not a poll: it returns the lines above your current coverage rather than the whole window again — coverage is a trailing run, so a line seen before an intervening gap can repeat. Pass raw: true for the unprocessed last-N window (still advances the bookmark). On omp, a native bg_N job's spilled output is read the same way, stamped as the host's file. |
bggrep |
Regex search over the last 2 MB of a job's log (bytes widens the window, max 64 MiB + 4 KiB of wrapper overhead; on omp, a native bg_N job's spilled output is searchable the same way): line-numbered matches, optional context lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. JavaScript regex syntax: a leading (?i)/(?m)/(?s) group is accepted and translated to the equivalent flags ((?i)error searches case-insensitively); a scoped group ((?i:error)) is native anywhere; a bare flag group anywhere else is rejected with an explanation — JS RegExp has no unscoped inline flags. Resolves the job id to the configured jobs dir itself — no log path to reconstruct. While the job is still running it returns its state and nothing else; peek: true searches the live log anyway. 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). 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. |
bgkill |
Stop a running job. Optional force: true sends SIGKILL instead of SIGTERM. Signals the job's whole process group (the child is spawned detached, so the command and anything it started go together; where a platform cannot signal groups, it falls back to the single pid and says so). Refuses an id that already finished, one that is not a bgrun job (bg_N belongs to the host — hub cancel), another session's job unless includeForeign: true, and a pid that cannot be the job's — gone, unusable, or recycled onto an unrelated process. It reports the signal it sent, not a guess at the outcome: the authoritative result is the wake, or the stale-check for a foreign job. |
Human-facing mirrors of the read/clean tools, usable directly in the TUI
without asking the agent (registered via pi.registerCommand — a separate
registration from the agent tools above, which is why tools alone never show
up as / commands):
| Command | Purpose |
|---|---|
/bgstatus [id] [done] |
One job's status by id, or the session listing (done/all includes finished jobs). |
/bgtail <id> [lines] |
Tail a job's log (condensed, same as the tool). |
/bgclean [days] [all] |
Remove old logs — session-scoped by default; all sweeps every session's. |
/bgkill <id> [force] |
Stop a running job (its process group; force sends SIGKILL). |
/bgrun is deliberately not a command — starting jobs (and reacting to their
wake messages) is the agent's workflow.
- Deprecated: the machine-global jobs dir (
PI_BGRUN_GLOBAL_DIR,~/.pi-bgrun/jobs) — see deprecation. Supported until a future major. bgkillshipped:bgkill <id>stops a running job (process group;force: truefor SIGKILL). The by-hand equivalent is stillkill -- -<pid>, where the pid is the last---separated segment of the job id (e.g.unit-tests-1726680000-12345→ pid12345); it is not shown as a separate field inbgstatusoutput.bgwait— not implemented; the wake mechanism makes blocking on a job unnecessary in the normal flow.- A job started inside a subagent (
task/evalchild) wakes that child, not the parent. Its completion wake is addressed to the session that spawned it, so a long job outliving the child's run wakes nobody. The log is not lost: it lands in the shared jobs dir, sobgstatus <id>/bgtail <id>read it from any session in the project. Start long jobs from the main session (bgrunis available to subagents on both hosts; this is about where the wake lands). oh-my-pi's own async machinery would fix it, but it is not reachable from an extension:ctxexposes only a read-onlygetAsyncJobSnapshot(), the registeringAsyncJobManagerlives on the internalToolSession, and the built-inbashbackground mode that does register is not delegatable (ctx.invokeToolis same-name only).
agent calls bgrun(command: "make test-short", name: "unit-tests")
→ extension resolves log path: <jobsDir>/<slug>-<ts>-<pid>.log (default <project>/.pi-bgrun/jobs/ in a repo, else ~/.pi-bgrun/jobs/)
→ spawn('sh', ['-c', <wrapper>, 'bgrun', '<cmd>'],
{ stdio: ['ignore', logFd, logFd], detached: true }).unref()
<wrapper> = the output-ceiling pipeline (see "Log size ceiling"), or the
uncapped one-liner 'sh -c "$1"; ec=$?; printf "\\n__BGRUN_EXIT__=%d\\n" "$ec"; exit "$ec"'
when the ceiling is disabled (maxLogBytes: 0)
→ records job in-memory + appends a bgrun-job entry to the session
→ returns "started: <job-id>"
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
→ ctx.ui.setWidget("bgrun", ...) — live panel: the running jobs
→ ctx.ui.setStatus("bgrun", ...) — one-line outcome that outlives the panel
The child writes the log directly via its own stdout fd (no pipe to the agent), so the job
survives the agent crashing and the log completes on disk. The trailing
__BGRUN_EXIT__=N marker makes the log self-describing — bgstatus recovers the
exit code even after a restart.
Both hosts get the same two surfaces, so a job's progress and outcome are visible without the conversation having to carry them:
- Editor panel (only while something is running — it never takes editor
space when idle): a header with the running count, then a row per running job
(full id, label, elapsed). Deliberately nothing else — a list of finished jobs
above the editor competes with the work in progress, and
bgstatusanswers "what ran" on demand. Rows are bounded to the 10 lines both hosts cap a widget at, with… N more runninginstead of a silently dropped tail. - Status line (always visible):
⏳ N runningwhile jobs are in flight, then✅ <name> exit=0for the most recent finish, cleared when the session has neither.
Both surfaces also cover the host's own background jobs — oh-my-pi
backgrounds long bash calls itself (bash.autoBackground, or async: true)
and delivers their output as an async result. Those are not bgrun jobs: their ids
look like bg_1, they have no log here, and they are cancelled when the session
is switched or replaced. The panel tags them native and bgstatus lists them
under their own heading, so one glance (or one call) covers all background work
in the session, and an id from either namespace explains itself — bggrep bg_5
names what bg_5 is, where its output went, and how to kill it (oh-my-pi's own
hub cancel ids:["bg_5"]), instead of a bare "no log found".
When the host truncated a native job's output it spills the full text to a
session artifact, and the delivery advertises the id. bgrun resolves it, so
bgtail bg_5 / bggrep bg_5 read the host's own spill through the same bounded
readers used for bgrun logs — the same condensation, caps and grep budgets, with
the output stamped as the host's file (no exit marker, not ours to clean). That
mapping is deliberately in-memory: bg_N ids restart at 1 and artifact ids are
session-scoped, so a persisted one could point at a different job's output after
a restart. A native job whose output was small enough to deliver inline has no
artifact, and bgrun says so rather than inventing a path.
The panel and status line are also re-indexed when oh-my-pi changes the
transcript: /new, /resume, a fork and a tree branch emit
session_switch/session_branch instead of session_start, so listening only
for session_start left a fresh session reporting the jobs of the one before it.
Finished jobs are dropped with the transcript that ran them; jobs that are still
running stay, since they outlive it.
On pi a bgrun-job card is also drawn in the transcript (an entry renderer),
which omp cannot render — hence these two surfaces. Neither is a history view:
the panel is live-only and the status line keeps just the latest outcome (plus
N result pending when the host has settled a job but not yet injected its
result); use bgstatus includeDone: true when you need the full list. See
Host differences.
Two-tier read model — the log file itself stays on disk, capped (see log size ceiling), for deep analysis; only bounded digests ever enter the conversation:
- Quick peek:
bgtail <id>— condensed newest lines (ANSI stripped, repeats collapsed only where the count can be shown, ~2KB/line and ~8KB caps). The first read is the last-40-lines tail; each later read returns only what was appended since. A still-running job is answered with its state, not the log — launching was the task and the wake is the next input, so a poll has nothing to pay it off;peek: truereads a live log when you really need it. Asking for more lines than you have been shown widens the view instead of re-sending the tail: you get the lines above your coverage, so widening costs the difference rather than the whole window again (a line seen before an intervening gap can repeat). The wake message itself already carries the exit code, the stats and the log's last line; a configured trace digest (js-trace/py-trace/rb-trace, see the digest section) can additionally name the failure and its source frame. Either way it is a summary, never the full record: on a failed run the context around the failure lives only in the log, so read a window around the failure before concluding a cause. - Pattern search:
bggrep <id> [pattern] [context]— line-numbered matches, capped and condensed (~50 matches, ~2KB/line, ~8KB); takes the job id, so there is no log path to reconstruct. Searches the last 2 MB by default — passbytesto widen (max 64 MiB), or usectx_execute_fileon the path for whole-file code-based analysis. Pass your own pattern when you know the log's format. A still-running job returns its state, not matches (peek: trueopts in). A wider window costs latency and memory, not context: the returned matches stay capped either way. - Whole-log analysis:
ctx_execute_fileon the job's log path (reachable when logs are project-local) to extract only failure lines. NevercatorReada full bgrun log. The sandbox keeps the file's bytes out of context — only your script's stdout enters it — so print aggregates and capped slices (fails.slice(0, 40)), never the content. With a 64 MiB-ceiling log, an unslicedconsole.log(FILE_CONTENT)is the one way this path becomes the dump it exists to avoid; usebgtail/bggrepfirst, and this third.
Why bggrep instead of bash grep on the log? A bash grep's output is
uncapped — a retry-storm log can dump thousands of matching lines straight
into context, and safety depends on remembering | head on every call.
bggrep is bounded by design (last 2 MB of the log by default — bytes widens
it, max 64 MiB — per-line 10 000-char pre-truncation before matching, ~50
matches, ~8KB), takes the job id instead of
a reconstructed log path (no shell-quoting of the regex), resolves the job id to the
configured jobs dir itself (no path to reconstruct), and reports match counts,
line numbers, and skip markers. Plain grep is fine only for a one-off search you know is tiny.
bggrep takes a caller-supplied regex, and a pathological one (for example
(a+)+$) can backtrack exponentially. V8 has no regex step limit and cannot
interrupt a regex running on the main thread, so the match loop runs in a
worker with a wall-clock budget (default 2000ms, override with
PI_BGRUN_GREP_TIMEOUT_MS). If the budget is exceeded the worker is terminated
and bggrep returns an error — a runaway pattern fails, it never hangs the
session. Normal patterns and logs finish far inside the budget; worker
startup adds a few tens of milliseconds per call.
stdout+stderr used to go straight to the log file with no write bound, so a
runaway job (yes, a spew loop, a pathological build) could fill the disk and
take the machine down. Job logs are now capped (maxLogBytes / PI_BGRUN_MAX_LOG_BYTES,
default 64 MiB, 0 = unlimited):
- The cap keeps the first N bytes. There is no portable in-tree way to keep the tail — a ring buffer needs a helper binary, and rewriting the file breaks the readers that depend on the exit marker staying last. A job past 64 MiB is almost always a runaway, so the head is the useful part.
- The ceiling lives inside the detached process tree, so it still holds after the agent exits or crashes — it is not an agent-side watchdog.
- The job is not killed, and its real exit code is preserved: bytes past the
cap are drained and discarded instead of SIGPIPE'ing the producer into
141. - It is not silent. The log carries
__BGRUN_TRUNC__ output truncated: kept the first <N> byteson the line before the exit marker, and the marker line itself carries the flag (__BGRUN_EXIT__=0 truncated=67108864). Both are reserved__BGRUN_*__lines that content readers filter exactly like the exit marker, and readers classify the notice by that marker flag, never by matching text — a command that echoes a notice-shaped line cannot make its own log look capped, and cannot get its own output discounted as wrapper bookkeeping either. If the ceiling could not be installed at all (mkfifounavailable, so the job ran uncapped) the log says that too —__BGRUN_NOCAP__ log ceiling unavailable, withnocap=1in the marker — so "uncapped" is never indistinguishable from "output was that small". Every surface the agent reads is labelled: the wake's Stats line gainslog truncated at 64 MiB,bgtailandbggrepappend a note and reporttruncatedAtBytesin their details, and a configured digest scorecard is skipped rather than run against a log that lost its end — summaries and failure lists live at the end, so its numbers would be confidently wrong. Treat a skipped digest on a capped job as "unknown", not "no failures". - Reading a capped log stays readable-whole: the widest
byteswindow is the ceiling plus 4 KiB (not the ceiling itself, which a capped log always exceeds by its notices and marker), sobytes: 67112960(ceiling + the wrapper's 4 KiB) is what spans the whole kept log. Windows are additionally limited to their last 500 000 lines — materializing a 64 MiB window of one-character lines would cost gigabytes of strings — and when that line bound trims a window,bgtail/bggrepsay so in the same labelled way instead of silently answering from a subset. - Maintainer rationale — why a fifo, why the first bytes, which alternatives
were measured and rejected:
docs/log-size-ceiling.md. - Cost: a capped job runs through one copier process (
perlwhere available, elsedd/head) reading the job through a fifo, plus a bounded drain wait — a few tens of milliseconds of job startup, no steady-state overhead. The copier is also what drains the stream past the cap, so the producer is never SIGPIPE'd. - Configure
maxLogBytes: 0for the previous uncapped behavior, e.g. when the whole log must survive forctx_execute_file.
The jobs dir defaults to <project>/.pi-bgrun/jobs when the session cwd is
inside a recognizable project root (.git or $CONFIG_DIR, found by walking up from
the cwd); otherwise it falls back to ~/.pi-bgrun/jobs. Warning: a
jobsDir (or a PI_BGRUN_GLOBAL_DIR target) equal to your home directory is
dangerous — cleanup removes matching *.log files directly there. The home
directory itself is never treated as a project root — the host's global agent
dir ($AGENT_DIR) would otherwise make every cwd under $HOME resolve to $HOME (a symlinked
is still recognized). Override via jobsDir / PI_BGRUN_DIR. Within a project,
the dir is shared by every agent 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
panel and bgstatus listings show this session's running jobs, and bgstatus
keeps finished jobs out of its listing unless asked (bgstatus includeDone: true) — the panel never shows finished work at all. The status line holds the
latest outcome. Jobs started by other sessions can still be inspected by id, but
they don't clutter your panel.
Configuration is layered (later wins): defaults ← user config file ← project config file (trusted projects only) ← environment variables.
- User:
$AGENT_DIR/pi-bgrun.json - Project:
<project>/$CONFIG_DIR/pi-bgrun.json
The project file is per-contributor state, not shared policy: it is read only for
a trusted project, it changes what every bgrun job in that checkout does, and a
digest entry runs a shell command at wake time. This repo therefore gitignores
its own; docs/dogfooding.md has the setup its maintainers
run locally (completed jobs visible, a scorecard on bun test runs).
{
"adoptForeignJobs": false,
"showCompletedJobs": false,
"cleanupDays": 7,
"maxLogBytes": 67108864,
"globalAutoClean": true,
"jobsDir": "/some/other/dir"
}Inside a recognizable project root, job logs land at <project>/.pi-bgrun/jobs
by default. The root is found by walking up from the session cwd, so a session
started in a subdirectory still resolves project-locally. An explicit
relative jobsDir (from any config layer, or PI_BGRUN_DIR) still
resolves against the project root — e.g. "jobsDir": "var/bgrun-logs" writes
to <project>/var/bgrun-logs.
Benefits:
- Logs sit inside the project sandbox, so project-confined analysis tools
(e.g. context-mode's
ctx_execute_file/ctx_index) can process whole logs without pulling raw bytes into the context window. - Each checkout/worktree gets its own logs — no cross-project clutter in a machine-global dir.
- The dir is auto-added to the repo's
.git/info/exclude(local-only — the tracked.gitignoreis never touched), so logs never pollutegit status. This happens on the firstbgrun; at session start it also happens for trusted projects only, so merely opening the agent in an untrusted repo neither edits.git/info/excludenor creates the dir. Works in linked worktrees too (writes to the common git dir, resolved via the worktree'scommondirfile).
Upgrading from a pre-project-local version: in a repo the default jobs dir
is now <project>/.pi-bgrun/jobs, not ~/.pi-bgrun/jobs. Keep the old
behavior with an absolute jobsDir/PI_BGRUN_DIR. Old global logs aren't
moved, but under the project-local default the orphan sweep and bgclean all
still reach them — both cover your project's dir and the machine-global
one. An explicit absolute jobsDir is swept alone, exactly as before. Jobs are
no longer discoverable across projects through a single shared dir — unless you
opt into a shared absolute jobsDir.
Rules and migration notes:
- Absolute
jobsDirvalues behave exactly as in older versions: used as-is, never treated as project-local, and swept alone (the orphan sweep andbgclean alldo not also touch~/.pi-bgrun/jobs). Set"jobsDir": "~/.pi-bgrun/jobs"(or any absolute path) to keep using the machine-global dir inside a repo. - If the cwd has no
.git/$CONFIG_DIRat or above it, the default falls back to~/.pi-bgrun/jobs; a relative override also falls back to the global dir rather than scattering logs across arbitrary directories. - Tools resolve a job's log from the session's job record first, so jobs started before a config change stay readable after it.
- Existing logs in the old global dir are not migrated (they're ephemeral,
cleanupDays-retained). They are still reclaimed automatically: the orphan sweep andbgclean allboth cover~/.pi-bgrun/jobsin addition to the current project's dir.
Project-scoped logs are the model. A single shared ~/.pi-bgrun/jobs was
the old default; it is now deprecated and is no longer what any of the docs
lead with. Retirement is staged — nothing breaks today:
PI_BGRUN_GLOBAL_DIRand the~/.pi-bgrun/jobsdefault are deprecated; they will be removed in a future major.- Supported for now: an existing absolute
jobsDir/PI_BGRUN_DIRkeeps working exactly as before, and a cwd with no project root still falls back to~/.pi-bgrun/jobs(there is nowhere project-scoped to put it, and the alternative — scattering logs into an arbitrary cwd — is worse).
Why project-scoped won:
- Each checkout owns its logs — no cross-project clutter, no ambiguous
bgstatusscope, andbgcleancan't reach into another project's runs. - Reachable by project-sandboxed analysis (
ctx_execute_file,ctx_index): logs live inside the workspace, so whole-log analysis no longer needs a path outside it. - Disposable with the workspace — delete the checkout, lose its logs.
What changes for you, if you set a global dir on purpose:
- Drop the absolute
jobsDir/PI_BGRUN_DIRfrom your config to get<project>/.pi-bgrun/jobs. - Planned sharing across projects is what you lose: jobs started in one
checkout are no longer visible to a session in another, and
adoptForeignJobsonly adopts within the same jobs dir. If you need that, keep the absolute dir — it is supported, merely no longer the recommended default — and say so upstream if it is load-bearing for you. - Old logs in
~/.pi-bgrun/jobskeep being swept (the orphan sweep andbgclean allcover both dirs under the project-local default). Delete the dir by hand once its logs are past retention.
Environment variables (same knobs, handy for one-off overrides):
| Variable | Default | Description |
|---|---|---|
PI_BGRUN_DIR |
<project>/.pi-bgrun/jobs in repos; else ~/.pi-bgrun/jobs |
Override where job logs are stored. An absolute path is used as-is; a relative path resolves against the project root (see project-local logs), falling back to ~/.pi-bgrun/jobs when there is no project root. |
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). 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_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. |
PI_BGRUN_GLOBAL_AUTO_CLEAN |
true |
Set 0/false to disable the automatic orphan sweep (see below). |
PI_BGRUN_GREP_TIMEOUT_MS |
2000 |
Wall-clock budget for a bggrep match. A caller-supplied regex that exceeds it is aborted (its worker terminated) and reported as an error instead of hanging — see Bounded matching. |
PI_BGRUN_USER_CONFIG |
$AGENT_DIR/pi-bgrun.json |
Override the user-level config file path (see Configuration). |
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.
A scorecard that reports counts only — pass/fail totals, failing test names —
will not point at a failure: it says a job went red, not where or why. The three
trace presets (js-trace, py-trace, rb-trace) exist for that; they name
the failure and its source frame. The tool deliberately does not try to
detect that a scorecard "produced no failure signal" — that judgement would need
per-ecosystem knowledge of what a failure looks like, so it is left to you: a
count-only scorecard is a red/green indicator, and the cause is in the log.
Every bgrun job carries three identifiers, and the digest selector reads all
three:
| Field | Required | Normalized | Drives |
|---|---|---|---|
command |
yes | used as-is (sh -c) |
what runs; the match.command target |
name |
no | trimmed, blank → none, ≤80 chars | display label + job-id/log slug; the match.name target |
type |
no | trimmed, lowercased, blank → none, ≤40 chars | digest routing only; the first-class selector |
name names the job (and its log file); type never affects the id or the
widget, but is echoed in bgstatus <id> and bgrun's started: line — its
main job is selecting the scorecard. Selection tries type
entries first (exact, case-insensitive), then falls back to match.name /
match.command globs. The config type is capped to the same 40 characters
as the job type, so an over-long type still matches.
Three ways, easiest first — pick the first one you're comfortable with:
-
Ask your agent (recommended). Say: "Set up the pi-bgrun digest for this project." The
digest-configskill ships with this package and does the whole job: it samples your project's real job logs, tries the shipped presets against them, drafts a custom command if none fits, validates the result on both a green and a red log, and writes the config. It sees your actual output format, which is exactly what a good digest depends on — and you never have to read a log yourself. The one-shot toast some projects see ("no digest configured") — once per project that has run a bgrun job, toasted either at session start or at its first failing job — is pointing at this same skill. -
One-line preset if you know your stack. Create
<project>/$CONFIG_DIR/pi-bgrun.json(or merge into an existing one):{ "digest": { "preset": "go-test" } }Preset What it summarizes Suggested typego-testGo test output: package ok/FAIL counts + failing test names testjestJest output: Tests/Test Suites summary + failed test names testpytestpytest output: final passed/failed/error summary line + FAILED test ids testjunit-xmlJUnit XML: <failure>/<error>counts + failing testcase names, withfile:linewhen the emitter carries onetestjs-traceJS/TS, Go, Rust, JVM stack traces (innermost frame first): first failure signature + the deepest source frame, forwards or backwards testpy-tracePython tracebacks (outermost frame first): first failure header + the deepest File "…", line Nframe (the LAST one, not the first)testrb-traceRuby / Minitest: the N) Error:/N) Failure:header + its firstpath:line:in 'fn'frame (the raise site)testAll shipped presets are test runners, so they all suggest the conventional type
test. The suggestion is documentation, not behavior: you still write thetypeon the entry yourself, and a preset entry with notypeapplies to every job as before.The three
*-tracepresets name a failure and its source frame; the built-in wake no longer guesses one, so this is how a wake gets a pointer at all. Use them withon(below) so a green log that happens to contain anError:line cannot inject a failure:{ "digest": { "type": "test", "preset": "js-trace", "on": "failure" } }They recognise exactly the shapes in their descriptions and print nothing for any other runner — that runner's failure is still in the log, just not pointed at by the wake.
-
Custom command. For formats the presets don't cover:
{ "digest": { "command": "grep -E 'FAIL|ok ' \"$1\" | head -5" } }The command receives the job's log path as
$1and its stdout is appended to the wake. Worked example — a log containing:PASS src/auth.test.ts (2.1s) FAIL src/api.test.ts Tests: 12 passed, 1 failed, 13 totalplus the command
grep -E '^(PASS|FAIL|Tests:)' "$1" | head -5, wakes with:digest (command): FAIL src/api.test.ts Tests: 12 passed, 1 failed, 13 totalRules of thumb: quote
"$1", end the pipeline inhead -Nso output is bounded, and — this is the important one — check the command against a green and a red log before committing to it. A scorecard that says "all passing" on a failing log is worse than no scorecard. Thedigest-configskill does this validation for you; if you'd rather hand-tune a command yourself, you can also ask your agent to validate a specific command against specific job logs.
An entry may carry "on": "failure", which appends its output only when the
job exited non-zero:
{ "digest": { "type": "test", "preset": "js-trace", "on": "failure" } }The framework enforces this, not the command: a digest command receives only
the log path, never the exit code, so a green job's wake must never show a
failure a command guessed from the text. That matters for the *-trace presets
— a green log can legitimately contain an Error: line. on defaults to absent
("always append"), so existing configs are unchanged; any value other than
"failure" is a config error and drops the entry, like a bad match or type.
digest can also be an ordered list of scorecards. Give each a type and
pass the matching type: when you start the job — the most reliable selector,
because it does not depend on the agent naming every job consistently:
{
"digest": [
{ "type": "test", "preset": "go-test" },
{ "type": "build", "label": "build",
"command": "grep -E '^error' \"$1\" | head -5" },
{ "match": { "command": "*cargo*" }, "label": "cargo", "preset": "go-test" },
{ "preset": "go-test" }
]
}Start jobs with the matching type:
bgrun(command: "go test ./...", name: "unit-tests", type: "test")
type is an optional bgrun parameter. The vocabulary is defined by the
type fields of the project's digest config in $CONFIG_DIR/pi-bgrun.json. You do
not have to read that file to use it: when a digest with typed entries is
configured and the job is started without a type, bgrun's started: line
names the configured types and the one to pass — the one moment the answer is
still actionable. If a job's type (or name/command) selects no entry, the
wake itself carries a line naming the job and the configured types, with the
fix (pass the matching type on the next bgrun call) — the agent is the only
party who can correct it, and on oh-my-pi a log-only diagnostic would be
invisible to it. The same fact goes to the
host log. It is emitted once per distinct mismatch (at most 3 per
session, then suppressed), so a project that never passes the right type cannot
grow the context per job — a mismatched type is visible without being
scorecard-less and without becoming noise.
Selection order (exactly one entry, or none):
- Type entries first. An entry declaring a
typematches ONLY a job that declared that same type — exact and case-insensitive ("test"matches"Test") — and must ALSO satisfy the entry'smatchif it has one. All type entries are checked first, in config order, regardless of where they sit relative to match entries. First type match wins. - Match/default fallback. If no type entry matched — including when the
job has no type — the entries without a
typeare scanned in config order:match.name/match.commandglobs and no-matchdefaults, first match wins. Put a no-matchdefault last so jobs you didn't anticipate still get a scorecard. - No match → no digest.
typeandmatchcompose (AND): with both present the entry matches only a job of that type that also satisfies the glob. Usematchalone for jobs that won't pass atype.match.nameandmatch.commandare globs tested against the job'snameand command line. Both present → both must match. Matching is case-insensitive and whole-string —*matches any run,?exactly one character, everything else is literal, and\escapes the next character (\*is a literal star) — so"*unit*"matches"unit-tests-run3"while a bare"unit-tests"matches only exactly that.labelsets the wake tag:digest (<label>): …. Precedence:label→ (type entry) the type string → (matched glob entry)match.name→ the entry's preset id (elsecommand). So a bare{ "preset": "go-test" }wakes asdigest (go-test):.- First match wins; exactly one digest block is appended per wake.
The legacy single-object form still works unchanged — { "digest": { "preset": "go-test" } } is a one-entry list with no matchers.
Opt in per project via <project>/$CONFIG_DIR/pi-bgrun.json (read only for trusted
projects). If both preset and command are set within one entry, the preset
wins. An empty list (or one where every entry is invalid) counts as not
configured.
- Exit code always leads. The digest is appended after the universal
stats, labeled
digest (<label>):—labelfollows the precedence above (entrylabel→ type string →match.name→ preset id /command). It never overrides or reorders the exit code, duration, or line count. - Capped and timed. Digest output is capped at ~500 chars, and buffering stops once that cap is reached — a command that prints unbounded output cannot balloon the wake. The digest command gets a 5s timeout plus a 250ms SIGTERM→SIGKILL grace (≈5.25s worst case), during which the wake waits.
- Silent-fail. A digest command that errors, times out, or prints nothing simply contributes nothing — it never breaks a wake.
- No config, no behavior. Absent or invalid config contributes nothing;
without a
digestsection the wake is unchanged.
A configured wake reads like this:
✅ Background job "tests" `abc123` finished (exit 1).
Command: go test ./...
Stats: 42.3s, 1204 lines
Last output: FAIL example.com/api/handlers
digest (go-test): 7 ok / 1 FAIL: TestResolveNotFound
Analyze the failure: `bgtail` for a peek at the end of the log, or `bggrep` to search it. This wake is a summary, not the diagnosis — the log holds the context around the failure, and any failure named here (by a trace digest) is one failure, not the whole story. Then continue the task that depended on this.
Shell safety: the command comes from trust-gated config and runs with your
own privileges — the same trust boundary as the jobsDir setting.
A user-level default digest works too: set digest in
$AGENT_DIR/pi-bgrun.json (path overridable via PI_BGRUN_USER_CONFIG), and
any project without its own digest inherits it. The project digest section
overrides the user-level one wholesale (no per-key merge).
Cleanup follows the same principle as everything else: one session should not delete another session's artifacts.
-
Session-scoped auto-sweep (default) runs at
session_startandsession_shutdownand removes only this session's finished logs older thancleanupDays. Cheap and unthrottled. -
Orphan sweep (default on; opt out with
globalAutoClean: false/PI_BGRUN_GLOBAL_AUTO_CLEAN=0) — also sweeps every shared jobs dir at session boundaries — the machine-global~/.pi-bgrun/jobsplus the current project's dir — removing finished logs (exit marker, or dead pid) older thancleanupDays. This is what keeps orphans from sessions that crashed or will never be resumed from accumulating: a week-old finished log is garbage under the same retention its owning session would apply itself. Under the project-local default it covers the current project's dir plus the machine-global one; an explicit absolutejobsDiris swept alone. Throttled to once percleanupDaysvia a.last-cleanmarker so restart-heavy workflows don't re-sweep on every launch. Running jobs are pid-protected, so live sessions are never affected. -
Manual:
bgcleancleans this session's old logs;bgcleanwithall: truesweeps every session's logs across the shared dirs immediately (and refreshes the markers). -
Running jobs are never swept while their pid is alive.
The jobs dir is only auto-swept when it is recognizably ours. bgrun
writes a .bgrun-jobs ownership marker into the dir on first use; the
automatic global sweep refuses to delete *.log files in a dir without it, so
a stray PI_BGRUN_DIR (or a config pointing at an unrelated directory) can't
be quietly emptied a week later. Manual bgclean all is an explicit
instruction, so it bypasses the gate and always works.
The dir also carries small bookkeeping files. The digest markers
(.bgrun-used-*, .digest-nudge-*) and stale .tmp-*.log staging files are
swept at cleanupDays; .bgrun-jobs and .last-clean persist until removed
by hand:
| File | Purpose |
|---|---|
.bgrun-jobs |
Ownership marker — gates the automatic global sweep. |
.last-clean |
Throttles the global sweep to once per cleanupDays. |
.bgrun-used-<hash> |
Per-project evidence that bgrun has run here (digest nudge). |
.digest-nudge-<hash> |
Per-project: the one-shot digest nudge was already shown. |
Version numbers and the changelog are derived from commit messages via release-please, so the prefix on a squash-merged PR title is load-bearing:
| Prefix | Release |
|---|---|
fix: / feat: / deps: |
yes — patch / minor / patch |
feat!: / fix!: / BREAKING CHANGE: |
yes — minor (pre-1.0) |
refactor: docs: test: ci: build: chore: style: |
no |
no prefix, e.g. Address review findings (#11) |
no |
An unprefixed commit is ignored outright: no changelog entry, and it cannot trigger
a release on its own. pr-title.yml enforces the format on every PR
(bun run lint:pr-title locally).
PRs are squash-merged, and that is structural rather than stylistic: the squash
collapses the PR to a single commit whose subject is the title, which is the
message release-please parses. That is why the title — not the branch commits — is
what CI validates, and why work-in-progress commit messages never surface. Rebase
and merge-commit methods would put branch subjects on main and break that mapping,
so repository settings must disable both (docs/releasing.md
lists the exact toggles).
Every merge to main updates a single open Release PR holding the package.json
bump and CHANGELOG.md entry. Nothing is published until that PR is merged —
ordinary merges only update it.
Process, the required repository settings, and the two manual steps per release:
docs/releasing.md.
Early / pre-release.