pi-background-run 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,14 @@ pi-background-run **wakes the live agent session** so it proactively reads a
8
8
  condensed digest of the results and continues — no polling, no human intervention.
9
9
 
10
10
  Built as a [pi](https://github.com/earendil-works/pi-coding-agent) extension. No
11
- shell runner, no poller, no sidecar files — the extension spawns the job in-process,
11
+ shell runner and no external daemon — the extension spawns the job in-process,
12
12
  detects completion via the child `exit` event, and calls `pi.sendUserMessage` to wake
13
13
  the agent. The log file is self-describing (full output + a trailing
14
- `__BGRUN_EXIT__=N` marker), so exit codes survive pi restarting.
14
+ `__BGRUN_EXIT__=N` marker), so exit codes survive pi restarting. Two small pieces
15
+ exist beyond the spawn: a 30s timer that only re-checks jobs whose live child handle
16
+ is gone (reconstructed from a restart, or adopted from another session), and a
17
+ `.last-clean` marker that throttles the **global** orphan sweep (the
18
+ session-scoped sweep is unthrottled).
15
19
 
16
20
  ## Install
17
21
 
@@ -19,11 +23,9 @@ the agent. The log file is self-describing (full output + a trailing
19
23
  pi install npm:pi-background-run
20
24
  ```
21
25
 
22
- Or the scoped alias (same code, permanent namespace claim):
23
-
24
- ```bash
25
- pi install npm:@stablekernel/pi-background-run
26
- ```
26
+ The scoped alias `@stablekernel/pi-background-run` is the same package (permanent
27
+ namespace claim, published in lockstep). Prefer the unscoped name; the alias is
28
+ not deprecated, so both stay installable and receive every release.
27
29
 
28
30
  Restart pi after install so the extension loads.
29
31
 
@@ -32,10 +34,10 @@ Restart pi after install so the extension loads.
32
34
  | Tool | Purpose |
33
35
  | ------ | --------- |
34
36
  | `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. |
35
- | `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`). Jobs from other sessions are only listed when `adoptForeignJobs` is enabled. |
36
- | `bgtail` | Read the newest lines of a job's log (default 40), **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). |
37
- | `bggrep` | Regex search over a job's log: line-numbered matches, optional `context` lines, capped (~50 matches, ~2KB/line, ~8KB) and condensed. Runs inside the extension, so it reaches **any** jobs dir — including global logs that project-sandboxed tools (`ctx_execute_file`) cannot. With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). |
38
- | `bgclean` | Remove old job logs. **Default scope: this session's jobs only** — other sessions' logs are untouched. Pass `all: true` to sweep the whole shared jobs dir. Retention: `cleanupDays` config (7 days). Never removes a running job's log. |
37
+ | `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. |
38
+ | `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). |
39
+ | `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). |
40
+ | `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. |
39
41
 
40
42
  ## Slash commands
41
43
 
@@ -55,16 +57,20 @@ wake messages) is the agent's workflow.
55
57
 
56
58
  ## Roadmap / not provided
57
59
 
58
- - `bgkill` not implemented; use `bash` with `kill` (job ids end in the child pid) if you ever need to stop a running job.
60
+ - Deprecated: the machine-global jobs dir (`PI_BGRUN_GLOBAL_DIR`, `~/.pi-bgrun/jobs`) see [deprecation](#deprecated-machine-global-jobs-dir). Supported until a future major.
61
+ - `bgkill` — not implemented; to stop a running job, use `kill -- -<pid>` (kill the process group — the child is spawned detached). The pid is the last `--`-separated segment of the job id (e.g. `unit-tests-1726680000-12345` → pid `12345`); it is not shown as a separate field in `bgstatus` output.
59
62
  - `bgwait` — not implemented; the wake mechanism makes blocking on a job unnecessary in the normal flow.
60
63
 
61
64
  ## How it works
62
65
 
63
66
  ```text
64
67
  agent calls bgrun(command: "make test-short", name: "unit-tests")
65
- → extension resolves log path: <jobsDir>/<slug>-<ts>-<pid>.log (default ~/.pi-bgrun/jobs/)
66
- → spawn('sh', ['-c', '<cmd>; ec=$?; printf "\\n__BGRUN_EXIT__=%d\\n" "$ec"; exit $ec'],
68
+ → extension resolves log path: <jobsDir>/<slug>-<ts>-<pid>.log (default <project>/.pi-bgrun/jobs/ in a repo, else ~/.pi-bgrun/jobs/)
69
+ → spawn('sh', ['-c', <wrapper>, 'bgrun', '<cmd>'],
67
70
  { stdio: ['ignore', logFd, logFd], detached: true }).unref()
71
+ <wrapper> = the output-ceiling pipeline (see "Log size ceiling"), or the
72
+ uncapped one-liner 'sh -c "$1"; ec=$?; printf "\\n__BGRUN_EXIT__=%d\\n" "$ec"; exit "$ec"'
73
+ when the ceiling is disabled (maxLogBytes: 0)
68
74
  → records job in-memory + appends a bgrun-job entry to the session
69
75
  → returns "started: <job-id>"
70
76
 
@@ -83,8 +89,9 @@ exit code even after a restart.
83
89
 
84
90
  ## Reading results without flooding context
85
91
 
86
- Two-tier read model — the log file stays complete on disk for deep analysis;
87
- only bounded digests ever enter the conversation:
92
+ Two-tier read model — the log file itself stays on disk, capped (see
93
+ [log size ceiling](#log-size-ceiling)), for deep analysis; only bounded digests
94
+ ever enter the conversation:
88
95
 
89
96
  - **Quick peek:** `bgtail <id>` — condensed newest lines (ANSI stripped, repeats
90
97
  collapsed, ~2KB/line and ~8KB caps). The first read is the last-40-lines tail; each later
@@ -92,31 +99,108 @@ only bounded digests ever enter the conversation:
92
99
  free. The wake message itself already carries the exit code and the log's
93
100
  last line, so many turns need no follow-up read at all.
94
101
  - **Pattern search:** `bggrep <id> [pattern] [context]` — line-numbered matches,
95
- capped and condensed (~50 matches, ~2KB/line, ~8KB); works on global jobs dirs that `ctx_execute_file`
96
- cannot reach. Pass your own pattern when you know the log's format.
102
+ capped and condensed (~50 matches, ~2KB/line, ~8KB); takes the job id, so
103
+ there is no log path to reconstruct. Searches the **last 2 MB** by default —
104
+ pass `bytes` to widen (max 64 MiB), or use `ctx_execute_file` on the path for
105
+ whole-file code-based analysis. Pass your own pattern when you know the log's
106
+ format. A **wider window costs latency and memory, not context**: the returned
107
+ matches stay capped either way.
97
108
  - **Whole-log analysis:** `ctx_execute_file` on the job's log path (reachable
98
109
  when logs are project-local) to extract only failure lines. Never `cat` or
99
- `Read` a full bgrun log.
110
+ `Read` a full bgrun log. The sandbox keeps the file's bytes out of context —
111
+ only your script's **stdout** enters it — so print aggregates and capped
112
+ slices (`fails.slice(0, 40)`), never the content. With a 64 MiB-ceiling log,
113
+ an unsliced `console.log(FILE_CONTENT)` is the one way this path becomes the
114
+ dump it exists to avoid; use `bgtail`/`bggrep` first, and this third.
100
115
 
101
116
  **Why `bggrep` instead of `bash grep` on the log?** A bash grep's output is
102
117
  uncapped — a retry-storm log can dump thousands of matching lines straight
103
118
  into context, and safety depends on remembering `| head` on every call.
104
- `bggrep` is bounded by design (~50 matches, ~2KB/line, ~8KB), takes the job id instead of
105
- a reconstructed log path (no shell-quoting of the regex), runs on any jobs
106
- dir including global logs that project-sandboxed tools like
107
- `ctx_execute_file` cannot reach and reports match counts, line numbers, and
108
- skip markers. Plain `grep` is fine only for a one-off search you know is tiny.
119
+ `bggrep` is bounded by design (last 2 MB of the log by default — `bytes` widens
120
+ it, max 64 MiB per-line 10 000-char pre-truncation before matching, ~50
121
+ matches, ~8KB), takes the job id instead of
122
+ a reconstructed log path (no shell-quoting of the regex), resolves the job id to the
123
+ configured jobs dir itself (no path to reconstruct), and reports match counts,
124
+ line numbers, and skip markers. Plain `grep` is fine only for a one-off search you know is tiny.
125
+
126
+ ### Bounded matching
127
+
128
+ `bggrep` takes a **caller-supplied regex**, and a pathological one (for example
129
+ `(a+)+$`) can backtrack exponentially. V8 has no regex step limit and cannot
130
+ interrupt a regex running on the main thread, so the match loop runs in a
131
+ worker with a wall-clock budget (default `2000ms`, override with
132
+ `PI_BGRUN_GREP_TIMEOUT_MS`). If the budget is exceeded the worker is terminated
133
+ and `bggrep` returns an error — **a runaway pattern fails, it never hangs the
134
+ session.** Normal patterns and logs finish far inside the budget; worker
135
+ startup adds a few tens of milliseconds per call.
136
+
137
+ ### Log size ceiling
138
+
139
+ stdout+stderr used to go straight to the log file with no write bound, so a
140
+ runaway job (`yes`, a spew loop, a pathological build) could fill the disk and
141
+ take the machine down. Job logs are now capped (`maxLogBytes` / `PI_BGRUN_MAX_LOG_BYTES`,
142
+ default **64 MiB**, `0` = unlimited):
143
+
144
+ - The cap keeps the **first** N bytes. There is no portable in-tree way to keep
145
+ the tail — a ring buffer needs a helper binary, and rewriting the file breaks
146
+ the readers that depend on the exit marker staying last. A job past 64 MiB is
147
+ almost always a runaway, so the head is the useful part.
148
+ - The ceiling lives **inside the detached process tree**, so it still holds
149
+ after pi exits or crashes — it is not a pi-side watchdog.
150
+ - The job is **not** killed, and its real exit code is preserved: bytes past the
151
+ cap are drained and discarded instead of SIGPIPE'ing the producer into `141`.
152
+ - It is **not silent**. The log carries
153
+ `__BGRUN_TRUNC__ output truncated: kept the first <N> bytes` on the line
154
+ before the exit marker, and the marker line itself carries the flag
155
+ (`__BGRUN_EXIT__=0 truncated=67108864`). Both are reserved `__BGRUN_*__` lines
156
+ that content readers filter exactly like the exit marker, and readers classify
157
+ the notice by that **marker flag**, never by matching text — a command that
158
+ echoes a notice-shaped line cannot make its own log look capped, and cannot
159
+ get its own output discounted as wrapper bookkeeping either. If the ceiling
160
+ could not be installed at all (`mkfifo` unavailable, so the job ran uncapped)
161
+ the log says that too — `__BGRUN_NOCAP__ log ceiling unavailable`, with
162
+ `nocap=1` in the marker — so "uncapped" is never indistinguishable from
163
+ "output was that small". Every surface the agent reads is labelled: the wake's
164
+ Stats line gains `log truncated at 64 MiB`,
165
+ `bgtail` and `bggrep` append a note and report `truncatedAtBytes` in their
166
+ details, and a configured digest scorecard is **skipped** rather than run
167
+ against a log that lost its end — summaries and failure lists live at the end,
168
+ so its numbers would be confidently wrong. Treat a skipped digest on a capped
169
+ job as "unknown", not "no failures".
170
+ - Reading a capped log stays readable-whole: the widest `bytes` window is the
171
+ ceiling **plus 4 KiB** (not the ceiling itself, which a capped log always
172
+ exceeds by its notices and marker), so `bytes: 67108864` still spans the whole
173
+ kept log. Windows are additionally limited to their last 500 000 lines —
174
+ materializing a 64 MiB window of one-character lines would cost gigabytes of
175
+ strings — and when that line bound trims a window, `bgtail`/`bggrep` say so in
176
+ the same labelled way instead of silently answering from a subset.
177
+ - Maintainer rationale — why a fifo, why the *first* bytes, which alternatives
178
+ were measured and rejected: [`docs/log-size-ceiling.md`](docs/log-size-ceiling.md).
179
+ - Cost: a capped job runs through one copier process (`perl` where available,
180
+ else `dd`/`head`) reading the job through a fifo, plus a bounded drain wait —
181
+ a few tens of milliseconds of job startup, no steady-state overhead. The
182
+ copier is also what drains the stream past the cap, so the producer is never
183
+ SIGPIPE'd.
184
+ - Configure `maxLogBytes: 0` for the previous uncapped behavior, e.g. when the
185
+ whole log must survive for `ctx_execute_file`.
109
186
 
110
187
  ## Configuration
111
188
 
112
- The jobs dir (default `~/.pi-bgrun/jobs`, overridable via `jobsDir` / `PI_BGRUN_DIR`)
113
- is shared by **every pi session on the machine** that sharing is what enables
114
- cross-session job lookup, session-restart reconstruction, and machine-wide
115
- cleanup. By default each session only *tracks its own jobs*: the widget and
116
- `bgstatus` listings show this session's running jobs, and finished jobs are
117
- hidden (ask for them explicitly with `bgstatus includeDone: true`). Jobs
118
- started by other sessions can still be inspected by id, but they don't clutter
119
- your widget.
189
+ The jobs dir defaults to `<project>/.pi-bgrun/jobs` when the session cwd is
190
+ inside a recognizable project root (`.git` or `.pi`, found by walking up from
191
+ the cwd); otherwise it falls back to `~/.pi-bgrun/jobs`. **Warning:** a
192
+ `jobsDir` (or a `PI_BGRUN_GLOBAL_DIR` target) equal to your home directory is
193
+ dangerous cleanup removes matching `*.log` files directly there. The home
194
+ directory itself is never treated as a project root — pi's global `~/.pi/agent`
195
+ dir would otherwise make every cwd under `$HOME` resolve to `$HOME` (a symlinked
196
+ is still recognized). Override via `jobsDir` / `PI_BGRUN_DIR`. Within a project,
197
+ the dir is shared by every pi session working in that checkout — that sharing
198
+ enables cross-session job lookup, session-restart reconstruction, and
199
+ per-project cleanup. By default each session only *tracks its own jobs*: the
200
+ widget and `bgstatus` listings show this session's running jobs, and finished
201
+ jobs are hidden (ask for them explicitly with `bgstatus includeDone: true`).
202
+ Jobs started by other sessions can still be inspected by id, but they don't
203
+ clutter your widget.
120
204
 
121
205
  Configuration is layered (later wins): **defaults ← user config file ← project
122
206
  config file (trusted projects only) ← environment variables**.
@@ -124,55 +208,309 @@ config file (trusted projects only) ← environment variables**.
124
208
  - User: `~/.pi/agent/pi-bgrun.json`
125
209
  - Project: `<project>/.pi/pi-bgrun.json`
126
210
 
211
+ The project file is per-contributor state, not shared policy: it is read only for
212
+ a trusted project, it changes what every `bgrun` job in that checkout does, and a
213
+ digest entry runs a shell command at wake time. This repo therefore gitignores
214
+ its own; [`docs/dogfooding.md`](docs/dogfooding.md) has the setup its maintainers
215
+ run locally (completed jobs visible, a scorecard on `bun test` runs).
216
+
127
217
  ```json
128
218
  {
129
219
  "adoptForeignJobs": false,
130
220
  "showCompletedJobs": false,
131
221
  "cleanupDays": 7,
222
+ "maxLogBytes": 67108864,
132
223
  "globalAutoClean": true,
133
224
  "jobsDir": "/some/other/dir"
134
225
  }
135
226
  ```
136
227
 
137
- ### Project-local logs
228
+ ### Project-local logs (default in repos)
138
229
 
139
- A **relative** `jobsDir` (from any config layer, or `PI_BGRUN_DIR`) opts into
140
- project-local logs: it resolves against the session's project root, so job logs
141
- land inside the workspace e.g. `"jobsDir": ".pi-bgrun/jobs"` in the project
142
- config writes logs to `<project>/.pi-bgrun/jobs`.
230
+ Inside a recognizable project root, job logs land at `<project>/.pi-bgrun/jobs`
231
+ by default. The root is found by walking up from the session cwd, so a session
232
+ started in a subdirectory still resolves project-locally. An explicit
233
+ **relative** `jobsDir` (from any config layer, or `PI_BGRUN_DIR`) still
234
+ resolves against the project root — e.g. `"jobsDir": "var/bgrun-logs"` writes
235
+ to `<project>/var/bgrun-logs`.
143
236
 
144
- Why you might want this:
237
+ Benefits:
145
238
 
146
239
  - Logs sit inside the project sandbox, so project-confined analysis tools
147
240
  (e.g. context-mode's `ctx_execute_file` / `ctx_index`) can process whole logs
148
241
  without pulling raw bytes into the context window.
149
- - Each checkout/worktree gets its own logs — no cross-project clutter in the
150
- shared dir.
242
+ - Each checkout/worktree gets its own logs — no cross-project clutter in a
243
+ machine-global dir.
151
244
  - The dir is auto-added to the repo's `.git/info/exclude` (local-only — the
152
245
  tracked `.gitignore` is never touched), so logs never pollute `git status`.
153
- Works in linked worktrees too (`.git` file pointed git dir).
246
+ This happens on the first `bgrun`; at session start it also happens for
247
+ **trusted** projects only, so merely opening pi in an untrusted repo neither
248
+ edits `.git/info/exclude` nor creates the dir. Works in linked worktrees too
249
+ (writes to the common git dir, resolved via the worktree's `commondir` file).
250
+
251
+ **Upgrading from a pre-project-local version:** in a repo the default jobs dir
252
+ is now `<project>/.pi-bgrun/jobs`, not `~/.pi-bgrun/jobs`. Keep the old
253
+ behavior with an absolute `jobsDir`/`PI_BGRUN_DIR`. Old global logs aren't
254
+ moved, but under the project-local default the orphan sweep and `bgclean all`
255
+ still reach them — both cover your project's dir **and** the machine-global
256
+ one. An explicit absolute `jobsDir` is swept alone, exactly as before. Jobs are
257
+ no longer discoverable across projects through a single shared dir — unless you
258
+ opt into a shared absolute `jobsDir`.
154
259
 
155
260
  Rules and migration notes:
156
261
 
157
- - Absolute `jobsDir` values behave exactly as in older versions nothing
158
- moves, nothing breaks on upgrade.
159
- - If the session cwd is not a recognizable project root (no `.git`/`.pi`), a
160
- relative path falls back to the global dir rather than scattering logs
161
- across arbitrary directories.
262
+ - Absolute `jobsDir` values behave exactly as in older versions: used as-is,
263
+ never treated as project-local, and swept alone (the orphan sweep and
264
+ `bgclean all` do not also touch `~/.pi-bgrun/jobs`). Set
265
+ `"jobsDir": "~/.pi-bgrun/jobs"` (or any absolute path) to keep using the
266
+ machine-global dir inside a repo.
267
+ - If the cwd has no `.git`/`.pi` at or above it, the default falls back to
268
+ `~/.pi-bgrun/jobs`; a relative override also falls back to the global dir
269
+ rather than scattering logs across arbitrary directories.
162
270
  - Tools resolve a job's log from the session's job record first, so jobs
163
271
  started before a config change stay readable after it.
164
272
  - Existing logs in the old global dir are not migrated (they're ephemeral,
165
- `cleanupDays`-retained); `bgclean all` sweeps them once you've switched.
273
+ `cleanupDays`-retained). They are still reclaimed automatically: the orphan
274
+ sweep and `bgclean all` both cover `~/.pi-bgrun/jobs` in addition to the
275
+ current project's dir.
276
+
277
+ ### Deprecated: machine-global jobs dir
278
+
279
+ **Project-scoped logs are the model.** A single shared `~/.pi-bgrun/jobs` was
280
+ the old default; it is now **deprecated** and is no longer what any of the docs
281
+ lead with. Retirement is staged — nothing breaks today:
282
+
283
+ - `PI_BGRUN_GLOBAL_DIR` and the `~/.pi-bgrun/jobs` **default are deprecated**;
284
+ they will be removed in a future major.
285
+ - **Supported for now:** an existing absolute `jobsDir` / `PI_BGRUN_DIR` keeps
286
+ working exactly as before, and a cwd with no project root still falls back to
287
+ `~/.pi-bgrun/jobs` (there is nowhere project-scoped to put it, and the
288
+ alternative — scattering logs into an arbitrary cwd — is worse).
289
+
290
+ Why project-scoped won:
291
+
292
+ - **Each checkout owns its logs** — no cross-project clutter, no ambiguous
293
+ `bgstatus` scope, and `bgclean` can't reach into another project's runs.
294
+ - **Reachable by project-sandboxed analysis** (`ctx_execute_file`,
295
+ `ctx_index`): logs live inside the workspace, so whole-log analysis no longer
296
+ needs a path outside it.
297
+ - **Disposable with the workspace** — delete the checkout, lose its logs.
298
+
299
+ What changes for you, if you set a global dir on purpose:
300
+
301
+ 1. Drop the absolute `jobsDir` / `PI_BGRUN_DIR` from your config to get
302
+ `<project>/.pi-bgrun/jobs`.
303
+ 2. Planned sharing across projects is what you lose: jobs started in one
304
+ checkout are no longer visible to a session in another, and
305
+ `adoptForeignJobs` only adopts within the same jobs dir. If you need that,
306
+ keep the absolute dir — it is supported, merely no longer the recommended
307
+ default — and say so upstream if it is load-bearing for you.
308
+ 3. Old logs in `~/.pi-bgrun/jobs` keep being swept (the orphan sweep and
309
+ `bgclean all` cover both dirs under the project-local default). Delete the
310
+ dir by hand once its logs are past retention.
166
311
 
167
312
  Environment variables (same knobs, handy for one-off overrides):
168
313
 
169
314
  | Variable | Default | Description |
170
315
  | --- | --- | --- |
171
- | `PI_BGRUN_DIR` | `~/.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](#project-local-logs)), falling back to the default when there is no project root. |
316
+ | `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](#project-local-logs-default-in-repos)), falling back to `~/.pi-bgrun/jobs` when there is no project root. |
317
+ | `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. |
172
318
  | `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. |
173
319
  | `PI_BGRUN_SHOW_COMPLETED` | `false` | Include finished jobs in `bgstatus` listings by default. |
174
320
  | `PI_BGRUN_CLEANUP_DAYS` | `7` | Log retention for cleanup sweeps and the `bgclean` default. |
175
- | `PI_BGRUN_GLOBAL_AUTO_CLEAN` | `true` | Set `0`/`false` to disable the automatic global orphan sweep (see below). |
321
+ | `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). |
322
+ | `PI_BGRUN_GLOBAL_AUTO_CLEAN` | `true` | Set `0`/`false` to disable the automatic orphan sweep (see below). |
323
+ | `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](#bounded-matching). |
324
+ | `PI_BGRUN_USER_CONFIG` | `~/.pi/agent/pi-bgrun.json` | Override the user-level config file path (see [Configuration](#configuration)). |
325
+
326
+ ### Digest scorecard (opt-in)
327
+
328
+ Wake messages always lead with universal facts — exit code, duration, and the
329
+ command's own log line count (the internal exit marker is excluded). A project
330
+ can additionally opt into a **digest scorecard**: a one-line pass/fail summary
331
+ extracted from the log and appended to the wake.
332
+
333
+ #### Job identity: name, type, command
334
+
335
+ Every `bgrun` job carries three identifiers, and the digest selector reads all
336
+ three:
337
+
338
+ | Field | Required | Normalized | Drives |
339
+ | --- | --- | --- | --- |
340
+ | `command` | yes | used as-is (`sh -c`) | what runs; the `match.command` target |
341
+ | `name` | no | trimmed, blank → none, ≤80 chars | display label + job-id/log slug; the `match.name` target |
342
+ | `type` | no | trimmed, lowercased, blank → none, ≤40 chars | digest routing only; the first-class selector |
343
+
344
+ `name` names the job (and its log file); `type` never affects the id or the
345
+ widget, but is echoed in `bgstatus <id>` and `bgrun`'s `started:` line — its
346
+ main job is selecting the scorecard. Selection tries `type`
347
+ entries first (exact, case-insensitive), then falls back to `match.name` /
348
+ `match.command` globs. The config `type` is capped to the same 40 characters
349
+ as the job `type`, so an over-long type still matches.
350
+
351
+ #### Setting it up
352
+
353
+ Three ways, easiest first — pick the first one you're comfortable with:
354
+
355
+ 1. **Ask your agent (recommended).** Say: *"Set up the pi-bgrun digest for
356
+ this project."* The `digest-config` skill ships with this package and does
357
+ the whole job: it samples your project's real job logs, tries the shipped
358
+ presets against them, drafts a custom command if none fits, validates the
359
+ result on both a green and a red log, and writes the config. It sees your
360
+ actual output format, which is exactly what a good digest depends on —
361
+ and you never have to read a log yourself. The one-shot toast some
362
+ projects see on session start ("no digest configured") — once per project
363
+ that has run a bgrun job — is pointing at this same skill.
364
+ 2. **One-line preset if you know your stack.** Create
365
+ `<project>/.pi/pi-bgrun.json` (or merge into an existing one):
366
+
367
+ ```json
368
+ { "digest": { "preset": "go-test" } }
369
+ ```
370
+
371
+ | Preset | What it summarizes | Suggested `type` |
372
+ | --- | --- | --- |
373
+ | `go-test` | Go test output: package ok/FAIL counts + failing test names | `test` |
374
+ | `jest` | Jest output: Tests/Test Suites summary + failed test names | `test` |
375
+ | `pytest` | pytest output: final passed/failed/error summary line + FAILED test ids | `test` |
376
+ | `junit-xml` | JUnit XML: `<failure>`/`<error>` counts + failing testcase names | `test` |
377
+
378
+ All shipped presets are test runners, so they all suggest the conventional
379
+ type `test`. The suggestion is documentation, not behavior: you still write
380
+ the `type` on the entry yourself, and a preset entry with no `type` applies
381
+ to every job as before.
382
+
383
+ 3. **Custom command.** For formats the presets don't cover:
384
+
385
+ ```json
386
+ { "digest": { "command": "grep -E 'FAIL|ok ' \"$1\" | head -5" } }
387
+ ```
388
+
389
+ The command receives the job's log path as `$1` and its stdout is appended
390
+ to the wake. Worked example — a log containing:
391
+
392
+ ```text
393
+ PASS src/auth.test.ts (2.1s)
394
+ FAIL src/api.test.ts
395
+ Tests: 12 passed, 1 failed, 13 total
396
+ ```
397
+
398
+ plus the command `grep -E '^(PASS|FAIL|Tests:)' "$1" | head -5`, wakes with:
399
+
400
+ ```text
401
+ digest (command): FAIL src/api.test.ts
402
+ Tests: 12 passed, 1 failed, 13 total
403
+ ```
404
+
405
+ Rules of thumb: quote `"$1"`, end the pipeline in `head -N` so output is
406
+ bounded, and — this is the important one — **check the command against a
407
+ green and a red log before committing to it**. A scorecard that says "all
408
+ passing" on a failing log is worse than no scorecard. The `digest-config`
409
+ skill does this validation for you; if you'd rather hand-tune a command
410
+ yourself, you can also ask your agent to validate a specific command
411
+ against specific job logs.
412
+
413
+ #### Multiple scorecards (one per job type)
414
+
415
+ `digest` can also be an ordered **list** of scorecards. Give each a `type` and
416
+ pass the matching `type:` when you start the job — the most reliable selector,
417
+ because it does not depend on the agent naming every job consistently:
418
+
419
+ ```json
420
+ {
421
+ "digest": [
422
+ { "type": "test", "preset": "go-test" },
423
+ { "type": "build", "label": "build",
424
+ "command": "grep -E '^error' \"$1\" | head -5" },
425
+ { "match": { "command": "*cargo*" }, "label": "cargo", "preset": "go-test" },
426
+ { "preset": "go-test" }
427
+ ]
428
+ }
429
+ ```
430
+
431
+ Start jobs with the matching type:
432
+
433
+ ```text
434
+ bgrun(command: "go test ./...", name: "unit-tests", type: "test")
435
+ ```
436
+
437
+ `type` is an optional `bgrun` parameter. The vocabulary is defined by the
438
+ `type` fields of the project's digest config in `.pi/pi-bgrun.json`; when the
439
+ project's digest config defines types, prefer passing the matching one. If a
440
+ job's `type` (or name/command) selects no entry, pi-bgrun logs a one-line
441
+ diagnostic naming the job and the configured types — so a mismatched type is
442
+ visible instead of silently scorecard-less.
443
+
444
+ Selection order (exactly one entry, or none):
445
+
446
+ 1. **Type entries first.** An entry declaring a `type` matches ONLY a job that
447
+ declared that same type — exact and case-insensitive (`"test"` matches
448
+ `"Test"`) — and must ALSO satisfy the entry's `match` if it has one. All type
449
+ entries are checked first, in config order, regardless of where they sit
450
+ relative to match entries. First type match wins.
451
+ 2. **Match/default fallback.** If no type entry matched — including when the
452
+ job has no type — the entries *without* a `type` are scanned in config order:
453
+ `match.name` / `match.command` globs and no-`match` defaults, first match
454
+ wins. Put a no-`match` default **last** so jobs you didn't anticipate still get
455
+ a scorecard.
456
+ 3. No match → no digest.
457
+
458
+ - `type` and `match` compose (AND): with both present the entry matches only a
459
+ job of that type that also satisfies the glob. Use `match` alone for jobs
460
+ that won't pass a `type`.
461
+ - `match.name` and `match.command` are **globs** tested against the job's
462
+ `name` and command line. Both present → both must match. Matching is
463
+ **case-insensitive and whole-string** — `*` matches any run, `?` exactly one
464
+ character, everything else is literal, and `\` escapes the next character
465
+ (`\*` is a literal star) — so `"*unit*"` matches `"unit-tests-run3"` while a
466
+ bare `"unit-tests"` matches only exactly that.
467
+ - `label` sets the wake tag: `digest (<label>): …`. Precedence: `label` →
468
+ (type entry) the type string → (matched glob entry) `match.name` → the
469
+ entry's preset id (else `command`). So a bare `{ "preset": "go-test" }`
470
+ wakes as `digest (go-test):`.
471
+ - First match wins; exactly one digest block is appended per wake.
472
+
473
+ The legacy single-object form still works unchanged — `{ "digest": { "preset":
474
+ "go-test" } }` is a one-entry list with no matchers.
475
+
476
+ Opt in per project via `<project>/.pi/pi-bgrun.json` (read only for trusted
477
+ projects). If both `preset` and `command` are set within one entry, the preset
478
+ wins. An empty list (or one where every entry is invalid) counts as *not
479
+ configured*.
480
+
481
+ #### Guarantees
482
+
483
+ - **Exit code always leads.** The digest is appended after the universal
484
+ stats, labeled `digest (<label>):` — `label` follows the precedence above
485
+ (entry `label` → type string → `match.name` → preset id / `command`). It
486
+ never overrides or reorders the exit code, duration, or line count.
487
+ - **Capped and timed.** Digest output is capped at ~500 chars, and buffering
488
+ stops once that cap is reached — a command that prints unbounded output
489
+ cannot balloon the wake. The digest command gets a 5s timeout plus a 250ms
490
+ SIGTERM→SIGKILL grace (≈5.25s worst case), during which the wake waits.
491
+ - **Silent-fail.** A digest command that errors, times out, or prints nothing
492
+ simply contributes nothing — it never breaks a wake.
493
+ - **No config, no behavior.** Absent or invalid config contributes nothing;
494
+ without a `digest` section the wake is unchanged.
495
+
496
+ A configured wake reads like this:
497
+
498
+ ```text
499
+ ✅ Background job "tests" `abc123` finished (exit 1).
500
+ Command: go test ./...
501
+ Stats: 42.3s, 1204 lines
502
+ Last output: FAIL example.com/api/handlers
503
+ digest (go-test): 7 ok / 1 FAIL: TestResolveNotFound
504
+ Review the result now: call `bgtail` ...
505
+ ```
506
+
507
+ Shell safety: the command comes from trust-gated config and runs with your
508
+ own privileges — the same trust boundary as the `jobsDir` setting.
509
+
510
+ A user-level default digest works too: set `digest` in
511
+ `~/.pi/agent/pi-bgrun.json` (path overridable via `PI_BGRUN_USER_CONFIG`), and
512
+ any project without its own digest inherits it. The project `digest` section
513
+ overrides the user-level one **wholesale** (no per-key merge).
176
514
 
177
515
  ### Log cleanup
178
516
 
@@ -182,20 +520,75 @@ not delete another session's artifacts.**
182
520
  - **Session-scoped auto-sweep (default)** runs at `session_start` and
183
521
  `session_shutdown` and removes only *this session's* finished logs older
184
522
  than `cleanupDays`. Cheap and unthrottled.
185
- - **Global orphan sweep (default on; opt out with `globalAutoClean: false` /
186
- `PI_BGRUN_GLOBAL_AUTO_CLEAN=0`)** — also sweeps the whole shared jobs dir at
187
- session boundaries, removing *finished* logs (exit marker, or dead pid)
188
- older than `cleanupDays`. This is what keeps orphans from sessions that
523
+ - **Orphan sweep (default on; opt out with `globalAutoClean: false` /
524
+ `PI_BGRUN_GLOBAL_AUTO_CLEAN=0`)** — also sweeps every shared jobs dir at
525
+ session boundaries the machine-global `~/.pi-bgrun/jobs` plus the current
526
+ project's dir removing *finished* logs (exit marker, or dead pid) older
527
+ than `cleanupDays`. This is what keeps orphans from sessions that
189
528
  crashed or will never be resumed from accumulating: a week-old finished log
190
529
  is garbage under the same retention its owning session would apply itself.
191
- Throttled to once per `cleanupDays` via a `.last-clean` marker so
192
- restart-heavy workflows don't re-sweep on every launch. Running jobs are
193
- pid-protected, so live sessions are never affected.
530
+ Under the project-local default it covers the current project's dir plus the
531
+ machine-global one; an explicit absolute `jobsDir` is swept alone. Throttled
532
+ to once per `cleanupDays` via a `.last-clean` marker so restart-heavy
533
+ workflows don't re-sweep on every launch. Running jobs are pid-protected, so
534
+ live sessions are never affected.
194
535
  - **Manual**: `bgclean` cleans this session's old logs; `bgclean` with
195
- `all: true` sweeps every session's logs immediately (and refreshes the
196
- marker).
536
+ `all: true` sweeps every session's logs across the shared dirs immediately
537
+ (and refreshes the markers).
538
+
197
539
  - Running jobs are never swept while their pid is alive.
198
540
 
541
+ **The jobs dir is only *auto*-swept when it is recognizably ours.** `bgrun`
542
+ writes a `.bgrun-jobs` ownership marker into the dir on first use; the
543
+ automatic global sweep refuses to delete `*.log` files in a dir without it, so
544
+ a stray `PI_BGRUN_DIR` (or a config pointing at an unrelated directory) can't
545
+ be quietly emptied a week later. Manual `bgclean all` is an explicit
546
+ instruction, so it bypasses the gate and always works.
547
+
548
+ The dir also carries small bookkeeping files. The digest markers
549
+ (`.bgrun-used-*`, `.digest-nudge-*`) and stale `.tmp-*.log` staging files are
550
+ swept at `cleanupDays`; `.bgrun-jobs` and `.last-clean` persist until removed
551
+ by hand:
552
+
553
+ | File | Purpose |
554
+ | --- | --- |
555
+ | `.bgrun-jobs` | Ownership marker — gates the *automatic* global sweep. |
556
+ | `.last-clean` | Throttles the global sweep to once per `cleanupDays`. |
557
+ | `.bgrun-used-<hash>` | Per-project evidence that bgrun has run here (digest nudge). |
558
+ | `.digest-nudge-<hash>` | Per-project: the one-shot digest nudge was already shown. |
559
+
560
+ ## Releasing
561
+
562
+ Version numbers and the changelog are derived from commit messages via
563
+ [release-please](https://github.com/googleapis/release-please), so the prefix on a
564
+ squash-merged PR title is load-bearing:
565
+
566
+ | Prefix | Release |
567
+ | --- | --- |
568
+ | `fix:` / `feat:` / `deps:` | yes — patch / minor / patch |
569
+ | `feat!:` / `fix!:` / `BREAKING CHANGE:` | yes — minor (pre-1.0) |
570
+ | `refactor:` `docs:` `test:` `ci:` `build:` `chore:` `style:` | no |
571
+ | no prefix, e.g. `Address review findings (#11)` | no |
572
+
573
+ An unprefixed commit is ignored outright: no changelog entry, and it cannot trigger
574
+ a release on its own. `pr-title.yml` enforces the format on every PR
575
+ (`bun run lint:pr-title` locally).
576
+
577
+ **PRs are squash-merged, and that is structural rather than stylistic:** the squash
578
+ collapses the PR to a single commit whose subject is the *title*, which is the
579
+ message release-please parses. That is why the title — not the branch commits — is
580
+ what CI validates, and why work-in-progress commit messages never surface. Rebase
581
+ and merge-commit methods would put branch subjects on `main` and break that mapping,
582
+ so repository settings must disable both ([`docs/releasing.md`](docs/releasing.md)
583
+ lists the exact toggles).
584
+
585
+ Every merge to `main` updates a single open **Release PR** holding the `package.json`
586
+ bump and `CHANGELOG.md` entry. Nothing is published until that PR is merged —
587
+ ordinary merges only update it.
588
+
589
+ Process, the required repository settings, and the one secret:
590
+ [`docs/releasing.md`](docs/releasing.md).
591
+
199
592
  ## Status
200
593
 
201
- Early / pre-release. See `.pi/wip/pi-port-plan.md` in the source tree for the design.
594
+ Early / pre-release.