diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee183a..da91851 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: bun-version: latest - name: bun install - run: bun install + run: bun install --frozen-lockfile - name: tsc --noEmit id: tsc @@ -54,6 +54,14 @@ jobs: echo "$pack_output" file_count=$(echo "$pack_output" | grep -oP 'total files:\s*\K[0-9]+' || echo "?") echo "file_count=${file_count}" >> "$GITHUB_OUTPUT" + # Enforce the allowlist: test files and other non-runtime files must + # never ship in the published tarball (they bloat installs and leak + # internals). npm's `files` negation should exclude these; fail loudly + # if a future edit drops the negation. + if echo "$pack_output" | grep -qE '(index\.test\.ts|/test/|\.test\.)'; then + echo "::error::test files found in the published tarball — fix package.json files[]" + exit 1 + fi # Sticky PR comment: one report per PR, updated in place on every run # (found via the hidden marker, so comments never stack up). @@ -102,9 +110,12 @@ jobs: TESTS_OUTCOME: ${{ steps.tests.outcome }} PACK_OUTCOME: ${{ steps.pack.outcome }} PACK_FILES: ${{ steps.pack.outputs.file_count }} + WORKFLOW_NAME: ${{ github.workflow }} + JOB_STATUS: ${{ job.status }} + COMMIT_SHA: ${{ github.sha }} run: | { - echo "## CI — ${{ github.workflow }} — ${{ job.status }}" + echo "## CI — ${WORKFLOW_NAME} — ${JOB_STATUS}" echo "" echo "| Check | Result |" echo "|---|---|" @@ -112,5 +123,5 @@ jobs: echo "| tests | \`${TESTS_OUTCOME}\` |" echo "| npm pack --dry-run | \`${PACK_OUTCOME}\` — ${PACK_FILES} files in tarball |" echo "" - echo "Ref: \`${{ github.sha }}\`" + echo "Ref: \`${COMMIT_SHA}\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a87913..d60b41c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,9 @@ jobs: env: PKG_VERSION: ${{ steps.version_check.outputs.pkg_version }} run: | - npm publish --provenance --access public + # No --provenance flag: trusted publishing (OIDC) makes npm generate + # provenance attestations automatically. Passing the flag is redundant. + npm publish --access public echo "::notice::Published pi-background-run@${PKG_VERSION} to npm" - name: Publish primary dry-run @@ -112,7 +114,7 @@ jobs: PKG_VERSION: ${{ steps.version_check.outputs.pkg_version }} run: | node -e "const p=require('./package.json'); p.name='@stablekernel/pi-background-run'; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')" - npm publish --provenance --access public + npm publish --access public echo "::notice::Published @stablekernel/pi-background-run@${PKG_VERSION} to npm" - name: Publish alias dry-run @@ -121,16 +123,11 @@ jobs: node -e "const p=require('./package.json'); p.name='@stablekernel/pi-background-run'; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')" npm publish --dry-run --access public - # ── Deprecate the alias in favor of the primary ──────────────────────── - # Only on real publish. The alias is a permanent namespace claim; the README - # and install instructions point at the unscoped primary. - - name: Deprecate alias - if: ${{ github.event_name == 'release' || !inputs.dry-run }} - env: - PKG_VERSION: ${{ steps.version_check.outputs.pkg_version }} - continue-on-error: true - run: | - npm deprecate "@stablekernel/pi-background-run@${PKG_VERSION}" "Use pi-background-run (the unscoped primary) instead: pi install npm:pi-background-run" + # NOTE: the scoped alias is published, not deprecated. `npm deprecate` + # cannot authenticate via OIDC trusted publishing (it needs a long-lived + # token, which npm is retiring), and the alias is a permanent namespace + # claim that tracks the primary. To deprecate the alias manually: + # npm deprecate "@stablekernel/pi-background-run@" "Use pi-background-run instead" - name: Write publish summary if: always() @@ -141,9 +138,11 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} PRIMARY_OUTCOME: ${{ steps.publish_primary.outcome }} ALIAS_OUTCOME: ${{ steps.publish_alias.outcome }} + WORKFLOW_NAME: ${{ github.workflow }} + JOB_STATUS: ${{ job.status }} run: | { - echo "## Release — ${{ github.workflow }} — ${{ job.status }}" + echo "## Release — ${WORKFLOW_NAME} — ${JOB_STATUS}" echo "" echo "| Field | Value |" echo "|---|---|" diff --git a/README.md b/README.md index 4db3f99..e3d4560 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,14 @@ 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, no poller, no sidecar files — the extension spawns the job in-process, +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 pi restarting. +`__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 +`.last-clean` marker that throttles the **global** orphan sweep (the +session-scoped sweep is unthrottled). ## Install @@ -19,11 +23,9 @@ the agent. The log file is self-describing (full output + a trailing pi install npm:pi-background-run ``` -Or the scoped alias (same code, permanent namespace claim): - -```bash -pi install npm:@stablekernel/pi-background-run -``` +The 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 pi after install so the extension loads. @@ -33,8 +35,8 @@ Restart pi after install so the extension loads. | ------ | --------- | | `bgrun` | Launch a command detached in the background. Optional `name` gives the job a short human-readable label. Returns `started: ` immediately. Wakes the session automatically on completion. | | `bgstatus` | Show job status. With an id: any job's state + exit code. Without: this session's running jobs (finished jobs hidden by default — pass `includeDone: true` or set `showCompletedJobs`). Other sessions' *running* jobs are listed only when `adoptForeignJobs` is enabled; finished foreign logs from the shared dir can also appear when finished jobs are included. | -| `bgtail` | Read the newest lines of a job's log (default 40), **condensed for context**: ANSI escapes stripped, repeated lines collapsed, long lines and total size capped. First read = full last-N tail; repeat reads return **only lines appended since your last read** (delta tailing) — polling a running job never re-pays for lines already seen. Pass `raw: true` for the unprocessed last-N window (still advances the bookmark). | -| `bggrep` | Regex search over 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 the configured jobs dir (including a global one) that project-sandboxed tools (`ctx_execute_file`) cannot. With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). | +| `bgtail` | Read the newest lines of a job's log (default 40; it reads the log's **last 2 MB**), **condensed for context**: ANSI escapes stripped, repeated lines collapsed, long lines and total size capped. First read = full last-N tail; repeat reads return **only lines appended since your last read** (delta tailing) — polling a running job never re-pays for lines already seen. Pass `raw: true` for the unprocessed last-N window (still advances the bookmark). | +| `bggrep` | Regex search over the **last 2 MB** of a job's log: line-numbered matches, optional `context` lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. Runs inside the extension, so it reaches the configured jobs dir (including a global one) that project-sandboxed tools (`ctx_execute_file`) cannot. Matching runs under a wall-clock budget ([Bounded matching](#bounded-matching)). With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). | | `bgclean` | Remove old job logs. **Default scope: this session's jobs only** — other sessions' logs are untouched — and it also drops stale per-project digest markers (`.bgrun-used-*`, `.digest-nudge-*`) in the session's jobs dir (markers are not session data). Pass `all: true` to sweep every shared jobs dir — under the project-local default that is the project's dir plus the machine-global one, while an explicit absolute `jobsDir` is swept alone — and do the same marker sweep across them. Retention: `cleanupDays` config (7 days); `days` must be a positive number (`days: 0` is rejected rather than purging everything). Never removes a running job's log. | ## Slash commands @@ -55,7 +57,7 @@ wake messages) is the agent's workflow. ## Roadmap / not provided -- `bgkill` — not implemented; use `bash` with `kill` (job ids end in the child pid) if you ever need to stop a running job. +- `bgkill` — not implemented; to stop a running job, use `kill -- -` (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. - `bgwait` — not implemented; the wake mechanism makes blocking on a job unnecessary in the normal flow. ## How it works @@ -63,7 +65,7 @@ wake messages) is the agent's workflow. ```text agent calls bgrun(command: "make test-short", name: "unit-tests") → extension resolves log path: /--.log (default /.pi-bgrun/jobs/ in a repo, else ~/.pi-bgrun/jobs/) - → spawn('sh', ['-c', '; ec=$?; printf "\\n__BGRUN_EXIT__=%d\\n" "$ec"; exit $ec'], + → spawn('sh', ['-c', 'sh -c "$1"; ec=$?; printf "\\n__BGRUN_EXIT__=%d\\n" "$ec"; exit "$ec"', 'bgrun', ''], { stdio: ['ignore', logFd, logFd], detached: true }).unref() → records job in-memory + appends a bgrun-job entry to the session → returns "started: " @@ -101,12 +103,24 @@ only bounded digests ever enter the conversation: **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 (~50 matches, ~2KB/line, ~8KB), takes the job id instead of +`bggrep` is bounded by design (last 2 MB of the log, 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), reaches the configured jobs dir (including a global one) that project-sandboxed tools like `ctx_execute_file` cannot, and reports match counts, line numbers, and skip markers. Plain `grep` is fine only for a one-off search you know is tiny. +### Bounded matching + +`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. + ## Configuration The jobs dir defaults to `/.pi-bgrun/jobs` when the session cwd is @@ -162,7 +176,7 @@ Benefits: This happens on the first `bgrun`; at session start it also happens for **trusted** projects only, so merely opening pi in an untrusted repo neither edits `.git/info/exclude` nor creates the dir. Works in linked worktrees too - (`.git` file → pointed git dir). + (writes to the common git dir, resolved via the worktree's `commondir` file). **Upgrading from a pre-project-local version:** in a repo the default jobs dir is now `/.pi-bgrun/jobs`, not `~/.pi-bgrun/jobs`. Keep the old @@ -200,6 +214,8 @@ Environment variables (same knobs, handy for one-off overrides): | `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_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](#bounded-matching). | +| `PI_BGRUN_USER_CONFIG` | `~/.pi/agent/pi-bgrun.json` | Override the user-level config file path (see [Configuration](#configuration)). | ### Digest scorecard (opt-in) @@ -220,7 +236,8 @@ three: | `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 -display — its only job is selecting the scorecard. Selection tries `type` +widget, but is echoed in `bgstatus ` 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. @@ -412,8 +429,28 @@ not delete another session's artifacts.** - **Manual**: `bgclean` cleans this session's old logs; `bgclean` with `all: true` sweeps 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-` | Per-project evidence that bgrun has run here (digest nudge). | +| `.digest-nudge-` | Per-project: the one-shot digest nudge was already shown. | + ## Status Early / pre-release. diff --git a/bun.lock b/bun.lock index 38cf5ee..6f5bdb4 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*", + "@types/node": "^24.0.0", "typebox": "^1.3.0", "typescript": "^5.7.0", }, @@ -146,7 +147,7 @@ "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + "@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -266,7 +267,7 @@ "undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -291,5 +292,9 @@ "@earendil-works/pi-protocol/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "protobufjs/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + + "protobufjs/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], } } diff --git a/extension/index.test.ts b/extension/index.test.ts index 9de05ac..cae199d 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -42,6 +42,17 @@ import { digestNudgeMarkerPath, jobUsageMarkerPath, } from "./index.ts"; +import type { + EntryRenderer, + ExtensionAPI, + SessionShutdownEvent, + SessionStartEvent, + ToolDefinition, +} from "@earendil-works/pi-coding-agent"; + +function markJobsDir(dir: string): void { + writeFileSync(join(dir, ".bgrun-jobs"), ""); +} // All test temp files live under one per-run root so cleanup and git hygiene // stay trivial — nothing is written into the repo or the real $HOME. @@ -82,36 +93,101 @@ interface CapturedWake { options?: Record; } +interface CapturedEntry { + type: string; + customType?: string; + data?: Record; +} + +// The slice of ExtensionAPI the extension under test actually calls. Typing the +// fake against these REAL signatures means a host-API change (e.g. +// registerCommand's options shape, or an event rename) fails tsc here instead of +// silently passing because the fake was `any`. +type UsedExtensionAPI = Pick< + ExtensionAPI, + | "on" + | "registerTool" + | "registerCommand" + | "registerEntryRenderer" + | "sendUserMessage" + | "appendEntry" +>; + +// Tools as the tests consume them: real metadata types from ToolDefinition, but +// a loose result shape so assertions can read `.content[0].text` without having +// to narrow the TextContent | ImageContent union at ~90 call sites. +type FakeTool = Pick< + ToolDefinition, + "name" | "label" | "description" | "parameters" | "promptSnippet" +> & { + execute( + toolCallId: string, + params: any, + signal: AbortSignal | undefined, + onUpdate: undefined, + ctx: FakeContext, + ): Promise<{ + content: { type: string; text: string }[]; + details: any; + isError?: boolean; + }>; +}; + +interface FakeUIContext { + notify(text: string, kind?: string): void; + setStatus(key: string, text: string | undefined): void; + setWidget(key: string, lines: string[] | undefined): void; +} + +interface FakeContext { + isIdle(): boolean; + hasUI: boolean; + ui: FakeUIContext; + sessionManager: { getEntries(): CapturedEntry[] }; + [key: string]: unknown; +} + +interface FakeCommand { + description?: string; + handler: (args: string, ctx: FakeContext) => Promise | void; +} + +type FakeHandler = (event: any, ctx: FakeContext) => Promise | any; + +type FakeRenderer = ( + entry: { data?: Record }, + opts: { expanded?: boolean }, + theme: unknown, +) => unknown; + +interface FakePiHandles { + pi: ExtensionAPI; + wakes: CapturedWake[]; + entries: CapturedEntry[]; + tools: Map; + commands: Map; + entryRenderers: Map; + ctx: FakeContext; + handlers: Map; + fireSessionStart: () => Promise; + fireSessionShutdown: () => Promise; +} + function makeFakePi( opts: { idle?: boolean; - priorEntries?: any[]; + priorEntries?: CapturedEntry[]; ctxFields?: Record; } = {}, -): { - pi: any; - wakes: CapturedWake[]; - entries: any[]; - tools: Map Promise }>; - commands: Map< - string, - { description?: string; handler: (...args: any[]) => Promise } - >; - ctx: any; - handlers: Map Promise)[]>; - fireSessionStart: () => Promise; -} { +): FakePiHandles { const wakes: CapturedWake[] = []; - const entries: any[] = opts.priorEntries ? [...opts.priorEntries] : []; - const tools = new Map< - string, - { execute: (...args: any[]) => Promise } - >(); - const commands = new Map< - string, - { description?: string; handler: (...args: any[]) => Promise } - >(); - const handlers = new Map Promise)[]>(); + const entries: CapturedEntry[] = opts.priorEntries + ? [...opts.priorEntries] + : []; + const tools = new Map(); + const commands = new Map(); + const entryRenderers = new Map(); + const handlers = new Map(); const idle = opts.idle ?? true; const ctx = { isIdle: () => idle, @@ -119,30 +195,52 @@ function makeFakePi( ui: { notify() {}, setWidget() {}, setStatus() {} }, sessionManager: { getEntries: () => entries }, ...(opts.ctxFields as Record | undefined), - }; - const pi = { - sendUserMessage(text: string, options?: Record) { - wakes.push({ text, options }); + } as unknown as FakeContext; + + // Typed against the real ExtensionAPI members the extension uses — the + // 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 = { + sendUserMessage(content, options) { + wakes.push({ + text: content as string, + options: options as Record | undefined, + }); + }, + appendEntry(customType, data) { + entries.push({ + type: "custom", + customType, + data: data as Record | undefined, + }); }, - appendEntry(customType: string, data?: unknown) { - entries.push({ type: "custom", customType, data }); + registerEntryRenderer(customType: string, renderer: EntryRenderer) { + entryRenderers.set(customType, renderer as unknown as FakeRenderer); }, - registerEntryRenderer() {}, - registerTool(def: any) { - tools.set(def.name, def); + registerTool(def) { + tools.set(def.name, def as unknown as FakeTool); }, - registerCommand(name: string, def: any) { - commands.set(name, def); + registerCommand(name, options) { + commands.set(name, options as unknown as FakeCommand); }, - on(event: string, handler: (...args: any[]) => Promise) { + on(event: string, handler: unknown) { const list = handlers.get(event) ?? []; - list.push(handler); + list.push(handler as FakeHandler); handlers.set(event, list); }, }; + const pi = used as ExtensionAPI; + const fireSessionStart = async () => { + const event = { reason: "startup" } as unknown as SessionStartEvent; for (const h of handlers.get("session_start") ?? []) { - await h({ reason: "startup" }, ctx); + await h(event, ctx); + } + }; + const fireSessionShutdown = async () => { + const event = { reason: "shutdown" } as unknown as SessionShutdownEvent; + for (const h of handlers.get("session_shutdown") ?? []) { + await h(event, ctx); } }; return { @@ -151,28 +249,65 @@ function makeFakePi( entries, tools, commands, + entryRenderers, ctx, handlers, fireSessionStart, + fireSessionShutdown, }; } -async function loadExtension( - fakePi: any, -): Promise Promise }>> { +async function loadExtension(fakePi: ExtensionAPI): Promise { const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; const mod = await import(url); mod.default(fakePi); - return fakePi.tools as Map< - string, - { execute: (...args: any[]) => Promise } - >; } +async function loadModule(): Promise { + const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; + return await import(url); +} + +async function withEnv( + name: string, + value: string | undefined, + fn: () => Promise | T, +): Promise { + const saved = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + return await fn(); + } finally { + if (saved === undefined) delete process.env[name]; + else process.env[name] = saved; + } +} + +async function withJobsDir( + fn: (dir: string, h: ReturnType) => Promise | T, + opts?: Parameters[0], +): Promise { + const dir = mkTmp("pi-bgrun-test-"); + process.env.PI_BGRUN_DIR = dir; + try { + const h = makeFakePi(opts); + await loadExtension(h.pi); + return await fn(dir, h); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +} + +// Default below Bun's 5s test timeout so a stuck wait rejects with a clear +// message instead of racing the harness kill (a flake-masking failure mode). function waitForWakes( wakes: CapturedWake[], count: number, - timeoutMs = 5000, + // Under bun's 5s per-test timeout so this fires first with a clearer error, + // but above the ~55ms these tests normally take, leaving CI-load headroom. + timeoutMs = 4000, ): Promise { return new Promise((resolve, reject) => { const start = Date.now(); @@ -190,13 +325,54 @@ function waitForWakes( }); } -test("bgrun: successful command writes log + exit marker and wakes with ✅", async () => { - const dir = mkTmp("pi-bgrun-test-"); +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; try { const { pi, wakes, tools, ctx } = makeFakePi(); await loadExtension(pi); const bgrun = tools.get("bgrun")!; + const mod: any = await loadModule(); + + await bgrun.execute( + "call-hash", + { command: "echo hi #" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + const hashId = wakes[0].text.match(/`([^`]+)`/)?.[1]; + assert.ok(hashId, "got hash job id"); + const hashLog = readFileSync(join(dir, `${hashId}.log`), "utf8"); + assert.match(hashLog, /__BGRUN_EXIT__=0/); + + await bgrun.execute( + "call-exit3", + { command: "exit 3" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 2); + assert.match(wakes[1].text, /finished \(exit 3\)/); + const exit3Id = wakes[1].text.match(/`([^`]+)`/)?.[1]; + assert.ok(exit3Id, "got exit3 job id"); + assert.equal( + mod.parseExitFromLogPath(join(dir, `${exit3Id}.log`)), + 3, + "marker recoverable after restart", + ); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("bgrun: successful command writes log + exit marker and wakes with ✅", async () => { + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; const res = await bgrun.execute( "call-1", @@ -224,18 +400,12 @@ test("bgrun: successful command writes log + exit marker and wakes with ✅", as const log = readFileSync(logPath, "utf8"); assert.match(log, /hello world/); assert.match(log, /__BGRUN_EXIT__=0/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; await bgrun.execute( @@ -249,41 +419,32 @@ test("bgrun: failing command wakes with ❌ and the non-zero exit code", async ( const wake = wakes[0].text; assert.match(wake, /❌/); assert.match(wake, /exit 7/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgrun: when agent is busy, wake is queued as followUp", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi({ idle: false }); - await loadExtension(pi); - const bgrun = tools.get("bgrun")!; + await withJobsDir( + async (_dir, h) => { + const { wakes, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; - await bgrun.execute( - "call-busy", - { command: "echo while-busy" }, - undefined, - undefined, - ctx, - ); - await waitForWakes(wakes, 1); - assert.equal(wakes[0].options?.deliverAs, "followUp"); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + await bgrun.execute( + "call-busy", + { command: "echo while-busy" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + assert.equal(wakes[0].options?.deliverAs, "followUp"); + }, + { idle: false }, + ); }); test("bgtail: returns last N lines, strips the exit marker", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; @@ -307,18 +468,12 @@ test("bgtail: returns last N lines, strips the exit marker", async () => { const text = tail.content[0].text as string; assert.ok(!text.includes("__BGRUN_EXIT__"), "marker stripped"); assert.match(text, /line2\nline3$|^line3$/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lines", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; @@ -361,18 +516,12 @@ test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lin "notes mention run collapse", ); assert.ok((tail.details as any).condensed === true); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail: raw=true skips condensing", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; @@ -401,18 +550,12 @@ test("bgtail: raw=true skips condensing", async () => { "raw keeps repeated lines uncollapsed", ); assert.ok((tail.details as any).condensed === false); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail: total cap kicks in on large output with guidance note", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; @@ -444,18 +587,12 @@ test("bgtail: total cap kicks in on large output with guidance note", async () = "cap note names the raw line count and suggests escalation paths", ); assert.ok((tail.details as any).condenserNotes, "notes in details too"); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgstatus: shows running then done with exit code", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgstatus = tools.get("bgstatus")!; @@ -488,10 +625,7 @@ test("bgstatus: shows running then done with exit code", async () => { ); assert.match(done.content[0].text as string, /done/); assert.match(done.content[0].text as string, /exit=0/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgstatus: list-all after 'restart' hides finished logs by default, notes them instead", async () => { @@ -612,11 +746,8 @@ test("bgrun: rejects empty command", async () => { // ── Phase 1 tests ──────────────────────────────────────────────────────────── test("bgrun: appends bgrun-job entries (running then done)", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, entries, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, entries, tools, ctx } = h; const bgrun = tools.get("bgrun")!; await bgrun.execute( @@ -629,17 +760,14 @@ test("bgrun: appends bgrun-job entries (running then done)", async () => { // One running entry appended at start. const runningEntries = entries.filter((e) => e.data?.state === "running"); assert.equal(runningEntries.length, 1, "running entry appended at start"); - assert.equal(runningEntries[0].data.cmd, "echo entry-test"); + assert.equal(runningEntries[0]?.data?.cmd, "echo entry-test"); await waitForWakes(wakes, 1); // One done entry appended on exit. const doneEntries = entries.filter((e) => e.data?.state === "done"); assert.equal(doneEntries.length, 1, "done entry appended on exit"); - assert.equal(doneEntries[0].data.exitCode, 0); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + assert.equal(doneEntries[0]?.data?.exitCode, 0); + }); }); test("session_start: reconstructs in-memory Map from bgrun-job entries", async () => { @@ -700,11 +828,8 @@ test("session_start: reconstructs in-memory Map from bgrun-job entries", async ( // ── name (human-readable label) tests ─────────────────────────────────────── test("bgrun: name flows into job id, response, entry, wake, and status", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, entries, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, entries, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const res = await bgrun.execute( @@ -734,18 +859,12 @@ test("bgrun: name flows into job id, response, entry, wake, and status", async ( // Persisted entries carry the name. const withName = entries.filter((e) => e.data?.name === "unit-tests"); assert.equal(withName.length, 2, "running + done entries carry name"); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgrun: name is optional — behavior unchanged without it", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const res = await bgrun.execute( @@ -769,18 +888,12 @@ test("bgrun: name is optional — behavior unchanged without it", async () => { !wakes[0].text.includes('"'), "wake has no name quote when unnamed", ); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgrun: blank name is ignored, over-long name is truncated", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; // Blank name treated as absent. @@ -810,10 +923,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => { assert.equal(nameLine.length, 80, "name truncated to 80 chars"); await waitForWakes(wakes, 2); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgrun: name survives session_start reconstruction", async () => { @@ -865,11 +975,8 @@ test("bgrun: name survives session_start reconstruction", async () => { }); test("bgstatus: list shows name after job id", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgstatus = tools.get("bgstatus")!; @@ -902,10 +1009,7 @@ test("bgstatus: list shows name after job id", async () => { !/— nightly: done/.test(runningOnly.content[0].text as string), "done job hidden without includeDone", ); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("session_start: foreign jobs are NOT adopted by default (opt-in only)", async () => { @@ -1125,11 +1229,8 @@ test("session_start: with foreign adoption OFF, finished foreign logs are not ad }); test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const res = await bgrun.execute( @@ -1152,59 +1253,47 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => { ); await waitForWakes(wakes, 1); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); -test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => { - // Regression: exit marker must win over pid liveness. Old code checked - // pid first, so any log whose id-pid happened to be a live process (e.g. - // pi's own pid from the old id bug, or pid reuse) was kept forever. +test("bgclean all: a live pid protects a log whose exit marker is NOT terminal", async () => { + // Regression: a running job's own output can contain a line like + // "__BGRUN_EXIT__=0" (a test grepping this extension). A non-terminal marker + // is not completion evidence, so pid liveness must win, or the sweep deletes + // a live job's log. const dir = mkTmp("pi-bgrun-test-"); process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); try { - // Old finished foreign log whose id-pid is THIS process (alive!) — must - // still be removed by an explicit global sweep. - const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`); - writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n"); + // Foreign log whose id-pid is THIS process (alive). The marker is followed + // by more output, so it is NOT the terminal line. + const livePath = join(dir, `live-job-1000000000-${process.pid}.log`); + writeFileSync( + livePath, + "still running\n__BGRUN_EXIT__=0\nmore output follows\n", + ); const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const fs = await import("node:fs"); - fs.utimesSync(oldPath, oldTime, oldTime); + fs.utimesSync(livePath, oldTime, oldTime); const { pi, tools, ctx } = makeFakePi(); await loadExtension(pi); const bgclean = tools.get("bgclean")!; - // Default scope: this session only — the foreign log is untouched. - const scoped = await bgclean.execute( - "call-stale-scoped", - { days: 7 }, - undefined, - undefined, - ctx, - ); - assert.match(scoped.content[0].text as string, /removed 0/); - assert.ok( - existsSync(oldPath), - "foreign log untouched by session-scoped bgclean", - ); - const result = await bgclean.execute( - "call-stale", + "call-live-marker", { days: 7, all: true }, undefined, undefined, ctx, ); - assert.match( - result.content[0].text as string, - /removed 1 job log\(s\) \(all sessions\)/, + assert.ok( + existsSync(livePath), + "live-pid log kept despite a spurious exit marker", ); assert.ok( - !existsSync(oldPath), - "finished job's log removed despite live id-pid", + result.details.skippedRunning >= 1, + "live log counted as skipped-running", ); } finally { delete process.env.PI_BGRUN_DIR; @@ -1368,6 +1457,7 @@ test("session_start: done entries with missing exitCode (signal kills) reconstru test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => { const dir = mkTmp("pi-bgrun-test-"); process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); delete process.env.PI_BGRUN_FOREIGN_JOBS; delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; try { @@ -1447,6 +1537,7 @@ test("auto-clean: session boundaries sweep this session's old logs AND week-old test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => { const dir = mkTmp("pi-bgrun-test-"); process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); delete process.env.PI_BGRUN_FOREIGN_JOBS; process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0"; try { @@ -1624,6 +1715,8 @@ test("auto-clean: .last-clean throttles per dir under the project-local default const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const projJobs = join(proj, ".pi-bgrun", "jobs"); mkdirSync(projJobs, { recursive: true }); + // The automatic sweep only touches a dir carrying our ownership marker. + markJobsDir(projJobs); const projOld = join(projJobs, "old-proj-1000000000-99999.log"); writeFileSync(projOld, "done\n__BGRUN_EXIT__=0\n"); utimesSync(projOld, oldTime, oldTime); @@ -1702,6 +1795,7 @@ test("auto-clean: untrusted project aliased by a global symlink writes nothing ( test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => { const dir = mkTmp("pi-bgrun-test-"); process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); delete process.env.PI_BGRUN_FOREIGN_JOBS; delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on try { @@ -1768,6 +1862,7 @@ test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgcle test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => { const dir = mkTmp("pi-bgrun-test-"); process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); // Isolate bgclean's scoping from the global orphan auto-sweep (default on) // so the foreign log survives session_start for bgclean to (not) act on. process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0"; @@ -1862,9 +1957,7 @@ test("bgclean: default scope is this session's logs; all: true sweeps everything }); test("bgclean all: sweeps stale per-project digest markers, keeps fresh ones", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { + await withJobsDir(async (dir, h) => { const fs = await import("node:fs"); const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const staleMarkers = [".bgrun-used-abc123", ".digest-nudge-def456"]; @@ -1879,8 +1972,7 @@ test("bgclean all: sweeps stale per-project digest markers, keeps fresh ones", a fs.writeFileSync(logPath, "out\n__BGRUN_EXIT__=0\n"); fs.utimesSync(logPath, old, old); - const { pi, tools, ctx } = makeFakePi(); - await loadExtension(pi); + const { tools, ctx } = h; const bgclean = tools.get("bgclean")!; await bgclean.execute( "call-mk", @@ -1905,10 +1997,7 @@ test("bgclean all: sweeps stale per-project digest markers, keeps fresh ones", a !existsSync(stale2), "session-scoped sweep also drops stale markers", ); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgclean: rejects non-positive days", async () => { @@ -2057,8 +2146,7 @@ test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool }); test("formatSince: same-day shows time only; older days include the date", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); assert.equal(typeof mod.formatSince, "function"); const now = new Date("2026-09-09T10:00:00").getTime(); @@ -2089,9 +2177,7 @@ test("formatSince: same-day shows time only; older days include the date", async // ── Project-local jobs dir ────────────────────────────────────────────────── test("resolveJobsDirPath: relative resolves against a project root; absolute and no-root fall back", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); const scratch = mkTmp("pi-bgrun-scratch-"); try { @@ -2130,9 +2216,7 @@ test("resolveJobsDirPath: relative resolves against a project root; absolute and }); test("resolveJobsDirPath: finds an enclosing project root from a subdirectory; .pi counts; worktree .git file counts", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); const piOnly = mkTmp("pi-bgrun-pionly-"); const worktree = mkTmp("pi-bgrun-wt-"); @@ -2169,9 +2253,7 @@ test("resolveJobsDirPath: finds an enclosing project root from a subdirectory; . }); test("resolveJobsDirPath: the user's home dir is never treated as a project root", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); // Hermetic: inject a fake home rather than touching the real ~/.pi. A `.pi` // at the fake home is exactly the case the guard exists for (pi's global // agent dir must not make every cwd under home project-local). @@ -2190,9 +2272,7 @@ test("resolveJobsDirPath: the user's home dir is never treated as a project root }); test("resolveJobsDirPath: a symlinked home is still recognized as the home dir", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const realHome = mkTmp("pi-bgrun-realhome-"); const linkParent = mkTmp("pi-bgrun-link-"); try { @@ -2212,9 +2292,7 @@ test("resolveJobsDirPath: a symlinked home is still recognized as the home dir", }); test("resolveJobsDirPath: a cwd reached via a symlink to the home dir is still not project-local", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const home = mkTmp("pi-bgrun-home-"); const linkParent = mkTmp("pi-bgrun-link-"); try { @@ -2235,9 +2313,7 @@ test("resolveJobsDirPath: a cwd reached via a symlink to the home dir is still n }); test("resolveJobsDirPath: expands a leading ~ to the home dir (not project-local)", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const scratch = mkTmp("pi-bgrun-scratch-"); try { const r = mod.resolveJobsDirPath("~/.pi-bgrun/jobs", { cwd: scratch }); @@ -2249,9 +2325,7 @@ test("resolveJobsDirPath: expands a leading ~ to the home dir (not project-local }); test("resolveJobsDirPath: expands only a leading ~ (or ~/) — ~user and embedded ~ are literal", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); try { mkdirSync(join(proj, ".git"), { recursive: true }); @@ -2277,37 +2351,29 @@ test("resolveJobsDirPath: expands only a leading ~ (or ~/) — ~user and embedde }); test("resolveJobsDirPath: PI_BGRUN_GLOBAL_DIR is tilde-expanded", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const scratch = mkTmp("pi-bgrun-scratch-"); - const saved = process.env.PI_BGRUN_GLOBAL_DIR; - process.env.PI_BGRUN_GLOBAL_DIR = "~/.pi-bgrun/jobs"; try { - const r = mod.resolveJobsDirPath(undefined, { cwd: scratch }); - assert.equal(r.dir, join(homedir(), ".pi-bgrun", "jobs")); - assert.equal(r.projectLocal, false); + await withEnv("PI_BGRUN_GLOBAL_DIR", "~/.pi-bgrun/jobs", () => { + const r = mod.resolveJobsDirPath(undefined, { cwd: scratch }); + assert.equal(r.dir, join(homedir(), ".pi-bgrun", "jobs")); + assert.equal(r.projectLocal, false); + }); } finally { - if (saved === undefined) delete process.env.PI_BGRUN_GLOBAL_DIR; - else process.env.PI_BGRUN_GLOBAL_DIR = saved; rmSync(scratch, { recursive: true, force: true }); } }); test("resolveJobsDirPath: without PI_BGRUN_GLOBAL_DIR the global default is ~/.pi-bgrun/jobs", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const scratch = mkTmp("pi-bgrun-scratch-"); - const saved = process.env.PI_BGRUN_GLOBAL_DIR; - delete process.env.PI_BGRUN_GLOBAL_DIR; try { - const r = mod.resolveJobsDirPath(undefined, { cwd: scratch }); - assert.equal(r.dir, join(homedir(), ".pi-bgrun", "jobs")); - assert.equal(r.projectLocal, false); + await withEnv("PI_BGRUN_GLOBAL_DIR", undefined, () => { + const r = mod.resolveJobsDirPath(undefined, { cwd: scratch }); + assert.equal(r.dir, join(homedir(), ".pi-bgrun", "jobs")); + assert.equal(r.projectLocal, false); + }); } finally { - if (saved === undefined) delete process.env.PI_BGRUN_GLOBAL_DIR; - else process.env.PI_BGRUN_GLOBAL_DIR = saved; rmSync(scratch, { recursive: true, force: true }); } }); @@ -2697,9 +2763,7 @@ test("bgclean all: a symlinked global dir aliasing the project dir is visited on }); test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once per dir", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const repo = mkTmp("pi-bgrun-repo-"); try { mkdirSync(join(repo, ".git", "info"), { recursive: true }); @@ -2721,9 +2785,7 @@ test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once }); test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git dir", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const wt = mkTmp("pi-bgrun-wt-"); const gd = mkTmp("pi-bgrun-gitdir-"); try { @@ -2739,10 +2801,28 @@ test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git d } }); +test("ensureGitExcluded: linked worktree (.git file) writes to commondir exclude", async () => { + const mod: any = await loadModule(); + const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-")); + const common = mkdtempSync(join(tmpdir(), "pi-bgrun-common-")); + const wtGitDir = join(common, "worktrees", "wt1"); + try { + mkdirSync(join(common, "info"), { recursive: true }); + mkdirSync(wtGitDir, { recursive: true }); + writeFileSync(join(wt, ".git"), `gitdir: ${wtGitDir}\n`); + writeFileSync(join(wtGitDir, "commondir"), "../..\n"); + mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")); + const exclude = readFileSync(join(common, "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + assert.ok(!existsSync(join(wtGitDir, "info", "exclude"))); + } finally { + rmSync(wt, { recursive: true, force: true }); + rmSync(common, { recursive: true, force: true }); + } +}); + test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const wt = mkTmp("pi-bgrun-wt-"); const gd = join(TEST_TMP_ROOT, "pi-bgrun git dir with spaces"); mkdirSync(gd, { recursive: true }); @@ -2758,9 +2838,7 @@ test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => { }); test("ensureGitExcluded: retries after a transient failure — memoizes only on success", async () => { - const mod = await import( - pathToFileURL(join(process.cwd(), "extension/index.ts")).href - ); + const mod: any = await loadModule(); const repo = mkTmp("pi-bgrun-repo-"); try { mkdirSync(join(repo, ".git", "info"), { recursive: true }); @@ -2911,11 +2989,8 @@ test("bgtail: prefers the session record's logPath when the jobsDir config chang // ── bggrep ────────────────────────────────────────────────────────────────── test("bggrep: line-numbered matches; explicit pattern wins; default pattern; no-match case", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bggrep = tools.get("bggrep")!; @@ -2974,18 +3049,12 @@ test("bggrep: line-numbered matches; explicit pattern wins; default pattern; no- assert.equal(g3.details.matches, 0); assert.equal(g3.isError, undefined); assert.match(g3.content[0].text as string, /— none/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep: context lines with gap markers between distant matches", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bggrep = tools.get("bggrep")!; @@ -3018,18 +3087,12 @@ test("bggrep: context lines with gap markers between distant matches", async () assert.match(text, /L8: MATCH two/); assert.match(text, /L9: l9/); // context after assert.match(text, /…\[3 lines skipped\]…/); // l4-l6 between the windows - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep: invalid pattern errors clearly", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bggrep = tools.get("bggrep")!; const res = await bgrun.execute( @@ -3052,18 +3115,12 @@ test("bggrep: invalid pattern errors clearly", async () => { ), /bggrep: invalid pattern/, ); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep: caps at 50 matches with a not-shown note", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bggrep = tools.get("bggrep")!; const res = await bgrun.execute( @@ -3091,10 +3148,7 @@ test("bggrep: caps at 50 matches with a not-shown note", async () => { ); assert.match(g.content[0].text as string, /L50: boom 50/); assert.doesNotMatch(g.content[0].text as string, /L51: boom 51/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep: prefers the session record's logPath when the jobsDir config changes", async () => { @@ -3143,11 +3197,8 @@ test("bggrep: prefers the session record's logPath when the jobsDir config chang // ── bgtail delta tailing ──────────────────────────────────────────────────── test("bgtail: delta tailing — first read full tail, then only new lines, then none", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; const res = await bgrun.execute( @@ -3186,18 +3237,12 @@ test("bgtail: delta tailing — first read full tail, then only new lines, then const t3 = await bgtail.execute("c4", { id }, undefined, undefined, ctx); assert.match(t3.content[0].text as string, /no new lines since last read/); assert.equal(t3.details.linesShown, 0); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail: raw:true keeps the verbatim window but still advances the bookmark", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; const res = await bgrun.execute( @@ -3226,18 +3271,12 @@ test("bgtail: raw:true keeps the verbatim window but still advances the bookmark // The raw read advanced the bookmark → the next condensed read is empty const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx); assert.match(t.content[0].text as string, /no new lines since last read/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail: a shrunken log resets to a full tail with a note", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; const res = await bgrun.execute( @@ -3259,18 +3298,12 @@ test("bgtail: a shrunken log resets to a full tail with a note", async () => { const text = t.content[0].text as string; assert.match(text, /log shrank since last read — showing full tail/); assert.match(text, /tiny replacement/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail: a replaced log with the same line count resets to a full tail", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; const res = await bgrun.execute( @@ -3298,18 +3331,12 @@ test("bgtail: a replaced log with the same line count resets to a full tail", as const text = t.content[0].text as string; assert.match(text, /log was replaced since last read — showing full tail/); assert.match(text, /xxxxxxxxxxxxxxxxxx/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep and bgtail normalize CRLF logs", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; const bggrep = tools.get("bggrep")!; @@ -3340,18 +3367,12 @@ test("bggrep and bgtail normalize CRLF logs", async () => { const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx); assert.ok(!(t.content[0].text as string).includes("\r")); assert.match(t.content[0].text as string, /error: boom/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep: empty log reports zero lines, and a missing log is notFound", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bggrep = tools.get("bggrep")!; const res = await bgrun.execute( @@ -3387,18 +3408,12 @@ test("bggrep: empty log reports zero lines, and a missing log is notFound", asyn ); assert.equal(missing.isError, true); assert.equal(missing.details.notFound, true); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bggrep: context windows combine with the 50-match cap", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bggrep = tools.get("bggrep")!; const res = await bgrun.execute( @@ -3432,18 +3447,12 @@ test("bggrep: context windows combine with the 50-match cap", async () => { assert.match(text, /showing first 50; 10 more not shown/); assert.match(text, /L4: hit 4/); assert.match(text, /…\[1 line skipped\]…/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("bgtail and bggrep clamp nonsensical numeric params", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; const bgtail = tools.get("bgtail")!; const bggrep = tools.get("bggrep")!; @@ -3479,17 +3488,13 @@ test("bgtail and bggrep clamp nonsensical numeric params", async () => { ctx, ); assert.match(g.content[0].text as string, /L3: three/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); // ── universal stats in the wake message (digest foundation) ────────────── test("formatDuration: one decimal in seconds under a minute, m:ss above", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); assert.equal(typeof mod.formatDuration, "function"); assert.equal(mod.formatDuration(0), "0.0s"); assert.equal(mod.formatDuration(42_300), "42.3s"); @@ -3500,11 +3505,8 @@ test("formatDuration: one decimal in seconds under a minute, m:ss above", async }); test("wake message: Stats line (duration + line count) sits between Command: and Last output: on a green run", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; await bgrun.execute( @@ -3526,6 +3528,128 @@ test("wake message: Stats line (duration + line count) sits between Command: and cmdIdx !== -1 && cmdIdx < statsIdx && statsIdx < lastIdx, "Stats line sits between Command: and Last output:", ); + }); +}); +test("redactForSlug: credential values never reach a filename or status line", async () => { + const mod: any = await loadModule(); + // Every credential SHAPE a shell command commonly carries. Each entry is + // [command, substring that must NOT survive]. Regression guard for the + // original too-narrow regex, which only caught `key: value` / `key=value` + // with a short hard-coded key list and missed all of these. + const leaks: [string, string][] = [ + // Header arguments (quoted, bare, `=`-joined). The X-Request-Id cases are + // deliberately NOT secret-named, so ONLY the -H/--header rules can redact + // them (otherwise the auth/assignment rules mask a broken header branch). + ["curl -H 'X-Request-Id: abc123' https://x", "abc123"], + ["curl --header 'X-Request-Id: abc123' https://x", "abc123"], + ['curl --header="X-Request-Id: abc123" https://x', "abc123"], + ["curl -H 'Authorization: Bearer abc123' https://x", "abc123"], + ["curl --header 'Authorization: Basic abc123' https://x", "abc123"], + ['curl --header="X-Session: abc123" https://x', "abc123"], + // Auth scheme + credential in a bare (non-header) value. + ["deploy Authorization: Bearer abc123", "abc123"], + ["auth: Bearer abc123", "abc123"], + // Underscore-prefixed env keys (no \b between `_` and the key word). + ["GITHUB_TOKEN=ghp_abc123 git push", "ghp_abc123"], + ["AWS_SECRET_ACCESS_KEY=abc123 aws s3 cp a b", "abc123"], + ["NPM_TOKEN=npm_zzz npm publish", "npm_zzz"], + ["env DB_PASSWORD=hunter2 ./run", "hunter2"], + ["MYSQL_PWD=hunter2 mysql -u root", "hunter2"], + // JSON (quotes around key and value). + ['curl --data {"password":"hunter2"} https://x', "hunter2"], + // Flag with a space (not `=`) and the `=` form. + ["curl --api-key abc123 https://x", "abc123"], + ["curl --api-key=abc123 https://x", "abc123"], + ["mysql --password secret db", "secret"], + ["deploy --token abc123", "abc123"], + ["run --client-secret shhh", "shhh"], + // URL userinfo and curl user:pass forms. + ["git clone https://oauth2:glpat-abc@gitlab.com/x/y", "glpat-abc"], + ["curl -u user:pass https://x", "user:pass"], + ["curl --user user:pass https://x", "user:pass"], + ]; + for (const [command, secret] of leaks) { + const out = mod.redactForSlug(command); + assert.ok( + !out.includes(secret), + `secret ${JSON.stringify(secret)} leaked: ${JSON.stringify(command)} -> ${JSON.stringify(out)}`, + ); + assert.match(out, /REDACTED/, `redaction marker missing for ${command}`); + } + // Non-secret commands must be left ALONE — over-redaction mangles a slug. + // `-h` (help) must NOT be treated as the case-sensitive `-H` header flag. + for (const command of [ + "make test-short", + "grep -h pattern file", + "mkdir -p src/lib", + "sort -u names.txt", + "npm run build -- --watch", + "timeout=30 node app.js", + "author=AUTHOR_NAME deploy", + ]) { + assert.equal( + mod.redactForSlug(command), + command, + `non-secret command was altered: ${command}`, + ); + } +}); + +test("bgstatus: read-only — checking status does not append transcript entries", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); + process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); + try { + const id = `read-only-job-${Math.floor(Date.now() / 1000)}-${process.pid}`; + const logPath = join(dir, `${id}.log`); + // No exit marker yet — session_start sees a live pid (this process) and + // keeps the reconstructed job as running. + writeFileSync(logPath, "working…\n"); + + const priorEntries = [ + { + type: "custom", + customType: "bgrun-job", + data: { + id, + pid: process.pid, + cmd: "sleep 999", + name: undefined, + started: Date.now(), + logPath, + state: "running", + }, + }, + ]; + const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({ + priorEntries, + }); + await loadExtension(pi); + await fireSessionStart(); + + const afterStart = entries.length; + + // The job finishes while pi is still up: marker lands in the log. + writeFileSync(logPath, "working…\n\n__BGRUN_EXIT__=0\n"); + + const bgstatus = tools.get("bgstatus")!; + const res = await bgstatus.execute( + "call-ro", + { includeDone: true }, + undefined, + undefined, + ctx, + ); + assert.match( + res.content[0].text as string, + /done exit=0/, + "revalidation still reports the accurate done state", + ); + assert.equal( + entries.length, + afterStart, + "no transcript entry appended by a status check", + ); } finally { delete process.env.PI_BGRUN_DIR; rmSync(dir, { recursive: true, force: true }); @@ -3533,11 +3657,8 @@ test("wake message: Stats line (duration + line count) sits between Command: and }); test("wake message: Stats line also present on a red (non-zero exit) run", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; await bgrun.execute( @@ -3550,18 +3671,12 @@ test("wake message: Stats line also present on a red (non-zero exit) run", async await waitForWakes(wakes, 1); assert.match(wakes[0].text, /Stats: \d+\.\ds, \d+ lines/); assert.match(wakes[0].text, /exit 7/); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); test("wake message: missing log file — Stats shows duration only, wake still sent", async () => { - const dir = mkTmp("pi-bgrun-test-"); - process.env.PI_BGRUN_DIR = dir; - try { - const { pi, wakes, tools, ctx } = makeFakePi(); - await loadExtension(pi); + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; const bgrun = tools.get("bgrun")!; // Unlink the log while the job runs; at exit the file is gone. @@ -3580,10 +3695,7 @@ test("wake message: missing log file — Stats shows duration only, wake still s assert.match(wake, /✅/); assert.match(wake, /Stats: \d+\.\ds$/m, "duration only, no lines"); assert.ok(!wake.includes(" lines"), "unreadable log contributes nothing"); - } finally { - delete process.env.PI_BGRUN_DIR; - rmSync(dir, { recursive: true, force: true }); - } + }); }); // ── digest config + shipped presets (opt-in) ───────────────────────────── @@ -3594,13 +3706,9 @@ function writeJson(filePath: string, obj: unknown): void { } test("resolveConfig: digest resolves from a trusted project config", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); - process.env.PI_BGRUN_USER_CONFIG = join( - mkTmp("pi-bgrun-home-"), - "user.json", - ); // does not exist + process.env.PI_BGRUN_USER_CONFIG = join(mkTmp("pi-bgrun-home-"), "user.json"); // does not exist try { writeJson(join(proj, ".pi", "pi-bgrun.json"), { digest: { preset: "go-test" }, @@ -3618,12 +3726,8 @@ test("resolveConfig: digest resolves from a trusted project config", async () => }); test("resolveConfig: digest absent everywhere → undefined", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); - process.env.PI_BGRUN_USER_CONFIG = join( - mkTmp("pi-bgrun-home-"), - "user.json", - ); + const mod: any = await loadModule(); + process.env.PI_BGRUN_USER_CONFIG = join(mkTmp("pi-bgrun-home-"), "user.json"); try { const cfg = mod.resolveConfig({}); assert.equal(cfg.digest, undefined); @@ -3633,13 +3737,9 @@ test("resolveConfig: digest absent everywhere → undefined", async () => { }); test("resolveConfig: untrusted project → no digest even when the project config has one", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); - process.env.PI_BGRUN_USER_CONFIG = join( - mkTmp("pi-bgrun-home-"), - "user.json", - ); + process.env.PI_BGRUN_USER_CONFIG = join(mkTmp("pi-bgrun-home-"), "user.json"); try { writeJson(join(proj, ".pi", "pi-bgrun.json"), { digest: { preset: "go-test" }, @@ -3658,31 +3758,30 @@ test("resolveConfig: untrusted project → no digest even when the project confi }); test("resolveConfig: reads the project config from the resolved project root, not the session subdirectory", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); - const savedDir = process.env.PI_BGRUN_DIR; - delete process.env.PI_BGRUN_DIR; try { - mkdirSync(join(proj, ".git"), { recursive: true }); - writeJson(join(proj, ".pi", "pi-bgrun.json"), { - jobsDir: "var/bgrun-logs", + await withEnv("PI_BGRUN_DIR", undefined, () => { + mkdirSync(join(proj, ".git"), { recursive: true }); + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + jobsDir: "var/bgrun-logs", + }); + const sub = join(proj, "packages", "foo"); + mkdirSync(sub, { recursive: true }); + const cfg = mod.resolveConfig({ + cwd: sub, + isProjectTrusted: () => true, + }); + assert.equal(cfg.jobsDir, join(proj, "var", "bgrun-logs")); + assert.equal(cfg.jobsDirProjectLocal, true); }); - const sub = join(proj, "packages", "foo"); - mkdirSync(sub, { recursive: true }); - const cfg = mod.resolveConfig({ cwd: sub, isProjectTrusted: () => true }); - assert.equal(cfg.jobsDir, join(proj, "var", "bgrun-logs")); - assert.equal(cfg.jobsDirProjectLocal, true); } finally { - if (savedDir === undefined) delete process.env.PI_BGRUN_DIR; - else process.env.PI_BGRUN_DIR = savedDir; rmSync(proj, { recursive: true, force: true }); } }); test("resolveConfig: layering — project digest replaces user digest wholesale; user used when project has none", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const home = mkTmp("pi-bgrun-home-"); const proj = mkTmp("pi-bgrun-proj-"); process.env.PI_BGRUN_USER_CONFIG = join(home, "user.json"); @@ -3708,13 +3807,9 @@ test("resolveConfig: layering — project digest replaces user digest wholesale; }); test("resolveConfig: invalid digest values dropped, valid ones kept (best-effort)", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); - process.env.PI_BGRUN_USER_CONFIG = join( - mkTmp("pi-bgrun-home-"), - "user.json", - ); + process.env.PI_BGRUN_USER_CONFIG = join(mkTmp("pi-bgrun-home-"), "user.json"); try { // Both invalid → no digest at all. writeJson(join(proj, ".pi", "pi-bgrun.json"), { @@ -3765,13 +3860,9 @@ test("resolveConfig: invalid digest values dropped, valid ones kept (best-effort }); test("resolveConfig: digest label is trimmed and capped at 60", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); - process.env.PI_BGRUN_USER_CONFIG = join( - mkTmp("pi-bgrun-home-"), - "user.json", - ); + process.env.PI_BGRUN_USER_CONFIG = join(mkTmp("pi-bgrun-home-"), "user.json"); try { writeJson(join(proj, ".pi", "pi-bgrun.json"), { digest: [ @@ -3796,13 +3887,9 @@ test("resolveConfig: digest label is trimmed and capped at 60", async () => { }); test("resolveConfig: digest type normalized; invalid type drops entry; match kept on a type entry", async () => { - const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href; - const mod: any = await import(url); + const mod: any = await loadModule(); const proj = mkTmp("pi-bgrun-proj-"); - process.env.PI_BGRUN_USER_CONFIG = join( - mkTmp("pi-bgrun-home-"), - "user.json", - ); + process.env.PI_BGRUN_USER_CONFIG = join(mkTmp("pi-bgrun-home-"), "user.json"); try { writeJson(join(proj, ".pi", "pi-bgrun.json"), { digest: [ @@ -4845,7 +4932,12 @@ test("wake digest: match by command line chooses the matching entry", async () = { label: "default", command: "echo default" }, ], }); - const wake = await runDigestJob(proj, { command: "cargo build --release" }); + // Keep the literal selector text in the command, but do not actually run + // cargo — the command is really spawned, and a real toolchain is slow/flaky + // on CI. + const wake = await runDigestJob(proj, { + command: 'echo "cargo build --release"', + }); const line = digestLineOf(wake); assert.ok(line, "wake carries a digest line"); assert.match(line!, /^digest \(build\): build-ok$/); @@ -4956,7 +5048,7 @@ test("wake digest: type + match compose end-to-end (normalization keeps match)", let wake = await runDigestJob(proj, { type: "test", name: "unit-tests", - command: "go test", + command: "echo hi", }); assert.match(digestLineOf(wake)!, /^digest \(unit\): unit$/); // type "test" but name does not match → second entry proves `match` is @@ -4964,7 +5056,7 @@ test("wake digest: type + match compose end-to-end (normalization keeps match)", wake = await runDigestJob(proj, { type: "test", name: "other", - command: "go test", + command: "echo hi", }); assert.match(digestLineOf(wake)!, /^digest \(any-test\): any$/); } finally { @@ -5017,27 +5109,56 @@ test("wake digest: job type selects the matching type entry (digest (test))", as } }); -test("wake digest: type is case-insensitive; unknown type falls through to match/default", async () => { +// Split into three tests: each spawns a real job, and bundling three into one +// blew bun's 5s per-test budget on a loaded CI runner. +const CASE_INSENSITIVE_DIGEST = [ + { type: "test", label: "typed-test", command: "echo typed" }, + { match: { command: "*run*" }, label: "match", command: "echo match" }, + { label: "default", command: "echo default" }, +]; + +test("wake digest: type match is case-insensitive", async () => { const { dir, proj, home } = setupDigestEnv(); try { writeJson(join(proj, ".pi", "pi-bgrun.json"), { - digest: [ - { type: "test", label: "typed-test", command: "echo typed" }, - { match: { command: "*run*" }, label: "match", command: "echo match" }, - { label: "default", command: "echo default" }, - ], + digest: CASE_INSENSITIVE_DIGEST, + }); + const wake = await runDigestJob(proj, { + type: "TEST", + command: "echo hi", }); - // Case-insensitive exact type match. - let wake = await runDigestJob(proj, { type: "TEST", command: "echo hi" }); assert.match(digestLineOf(wake)!, /^digest \(typed-test\): typed$/); - // Unknown type → falls through to the match scan. - wake = await runDigestJob(proj, { + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + +test("wake digest: unknown type falls through to a match entry", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: CASE_INSENSITIVE_DIGEST, + }); + // The command must MATCH `*run*` (the entry selector) but must also be + // cheap to actually spawn — `npm run lint` runs for real and made this + // test wait on npm startup (~1.6s locally, >5s on CI). + const wake = await runDigestJob(proj, { type: "lint", - command: "npm run lint", + command: "echo run", }); assert.match(digestLineOf(wake)!, /^digest \(match\): match$/); - // Unknown type + no match → default entry. - wake = await runDigestJob(proj, { type: "lint", command: "ls" }); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + +test("wake digest: unknown type with no match uses the default entry", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: CASE_INSENSITIVE_DIGEST, + }); + const wake = await runDigestJob(proj, { type: "lint", command: "ls" }); assert.match(digestLineOf(wake)!, /^digest \(default\): default$/); } finally { teardownDigestEnv(dir, proj, home); @@ -5059,7 +5180,7 @@ test("wake digest: type entry beats an earlier match entry (type-first order)", }); const wake = await runDigestJob(proj, { type: "test", - command: "go test ./...", + command: 'echo "go test ./..."', }); assert.match(digestLineOf(wake)!, /^digest \(typed\): typed$/); } finally { @@ -5108,7 +5229,7 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct // The persisted done entry carries the type. const done = entries.filter((e) => e.customType === "bgrun-job").at(-1); - assert.equal(done.data.type, "test"); + assert.equal(done?.data?.type, "test"); // Resume: a fresh instance reconstructs the in-memory map from entries. const { @@ -5140,28 +5261,57 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct } }); -test("bgrun: no type → no type line in the started result", async () => { - const dir = mkTmp("pi-bgrun-test-"); +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; try { - const { pi, tools, ctx } = makeFakePi(); + const { pi, wakes, tools, ctx, fireSessionStart, fireSessionShutdown } = + makeFakePi(); await loadExtension(pi); + await fireSessionStart(); + const bgrun = tools.get("bgrun")!; const res = await bgrun.execute( - "call-ty3", - { command: "echo plain" }, + "call-shutdown", + { command: "true" }, undefined, undefined, ctx, ); - assert.ok(!(res.content[0].text as string).includes("type:")); - assert.equal((res.details as any).type, undefined); + const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)?.[1]; + assert.ok(id, "got job id"); + await waitForWakes(wakes, 1); + const logPath = join(dir, `${id}.log`); + assert.ok(existsSync(logPath), "log exists before shutdown"); + + // Backdate so retention (default 7d) considers it old. + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + utimesSync(logPath, old, old); + + await fireSessionShutdown(); + assert.ok(!existsSync(logPath), "old log swept on session_shutdown"); } finally { delete process.env.PI_BGRUN_DIR; rmSync(dir, { recursive: true, force: true }); } }); +test("bgrun: no type → no type line in the started result", async () => { + await withJobsDir(async (_dir, h) => { + const { tools, ctx } = h; + const bgrun = tools.get("bgrun")!; + const res = await bgrun.execute( + "call-ty3", + { command: "echo plain" }, + undefined, + undefined, + ctx, + ); + assert.ok(!(res.content[0].text as string).includes("type:")); + assert.equal((res.details as any).type, undefined); + }); +}); + // ── digest nudge: one-shot session_start toast ──────────────────────────── // Real exported paths — no local mirror to drift from the implementation. @@ -5396,3 +5546,486 @@ test("digest nudge: a bgrun in a nested cwd writes the usage marker at the proje teardownDigestEnv(dir, proj, home); } }); + +test("resolveConfig: project config is honored only when the project is trusted", async () => { + const mod: any = await loadModule(); + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + const userCfg = join( + mkdtempSync(join(tmpdir(), "pi-bgrun-user-")), + "none.json", + ); + try { + await withEnv("PI_BGRUN_DIR", undefined, () => { + mkdirSync(join(proj, ".pi"), { recursive: true }); + writeFileSync( + join(proj, ".pi", "pi-bgrun.json"), + JSON.stringify({ + cleanupDays: 42, + adoptForeignJobs: true, + globalAutoClean: false, + showCompletedJobs: true, + }), + ); + + const untrusted = mod.resolveConfig({ + cwd: proj, + isProjectTrusted: () => false, + userConfigPath: userCfg, + }); + assert.equal(untrusted.cleanupDays, 7, "untrusted: default retention"); + assert.equal( + untrusted.adoptForeignJobs, + false, + "untrusted: default adopt", + ); + + const trusted = mod.resolveConfig({ + cwd: proj, + isProjectTrusted: () => true, + userConfigPath: userCfg, + }); + assert.equal(trusted.cleanupDays, 42, "trusted: file retention applied"); + assert.equal(trusted.adoptForeignJobs, true, "trusted: adoptForeignJobs"); + assert.equal(trusted.globalAutoClean, false, "trusted: globalAutoClean"); + assert.equal( + trusted.showCompletedJobs, + true, + "trusted: showCompletedJobs", + ); + }); + } finally { + rmSync(proj, { recursive: true, force: true }); + } +}); + +test("resolveConfig: user file applies; project file overrides it key-by-key", async () => { + const mod: any = await loadModule(); + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + const userDir = mkdtempSync(join(tmpdir(), "pi-bgrun-user-")); + const userCfg = join(userDir, "pi-bgrun.json"); + try { + await withEnv("PI_BGRUN_DIR", undefined, () => { + writeFileSync( + userCfg, + JSON.stringify({ cleanupDays: 5, globalAutoClean: false }), + ); + mkdirSync(join(proj, ".pi"), { recursive: true }); + writeFileSync( + join(proj, ".pi", "pi-bgrun.json"), + JSON.stringify({ cleanupDays: 11 }), + ); + + const trusted = mod.resolveConfig({ + cwd: proj, + isProjectTrusted: () => true, + userConfigPath: userCfg, + }); + assert.equal( + trusted.cleanupDays, + 11, + "project overrides the same user key", + ); + assert.equal( + trusted.globalAutoClean, + false, + "user keys absent from the project file survive", + ); + + // Untrusted project: its file is skipped, the user file still applies. + const untrusted = mod.resolveConfig({ + cwd: proj, + isProjectTrusted: () => false, + userConfigPath: userCfg, + }); + assert.equal(untrusted.cleanupDays, 5, "untrusted: user value used"); + assert.equal( + untrusted.globalAutoClean, + false, + "untrusted: user value used", + ); + }); + } finally { + rmSync(proj, { recursive: true, force: true }); + rmSync(userDir, { recursive: true, force: true }); + } +}); + +test("resolveConfig: env vars override config files", async () => { + const mod: any = await loadModule(); + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + const userDir = mkdtempSync(join(tmpdir(), "pi-bgrun-user-")); + const userCfg = join(userDir, "pi-bgrun.json"); + const prevDir = process.env.PI_BGRUN_DIR; + const prevDays = process.env.PI_BGRUN_CLEANUP_DAYS; + delete process.env.PI_BGRUN_DIR; + try { + writeFileSync(userCfg, JSON.stringify({ cleanupDays: 11 })); + process.env.PI_BGRUN_CLEANUP_DAYS = "3"; + + const cfg = mod.resolveConfig({ + cwd: proj, + isProjectTrusted: () => true, + userConfigPath: userCfg, + }); + assert.equal(cfg.cleanupDays, 3, "env beats both config files"); + } finally { + if (prevDir !== undefined) process.env.PI_BGRUN_DIR = prevDir; + if (prevDays === undefined) delete process.env.PI_BGRUN_CLEANUP_DAYS; + else process.env.PI_BGRUN_CLEANUP_DAYS = prevDays; + rmSync(proj, { recursive: true, force: true }); + rmSync(userDir, { recursive: true, force: true }); + } +}); + +test("resolveConfig: malformed JSON is ignored with a warning, not a throw", async () => { + const mod: any = await loadModule(); + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + const userDir = mkdtempSync(join(tmpdir(), "pi-bgrun-user-")); + const userCfg = join(userDir, "pi-bgrun.json"); + const prevDir = process.env.PI_BGRUN_DIR; + delete process.env.PI_BGRUN_DIR; + const originalError = console.error; + const warnings: string[] = []; + console.error = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + try { + writeFileSync(userCfg, "{ not json"); + + const cfg = mod.resolveConfig({ + cwd: proj, + isProjectTrusted: () => true, + userConfigPath: userCfg, + }); + assert.equal(cfg.cleanupDays, 7, "falls back to defaults"); + assert.ok( + warnings.some((w) => w.includes("malformed")), + "a malformed-config warning is emitted", + ); + } finally { + console.error = originalError; + if (prevDir !== undefined) process.env.PI_BGRUN_DIR = prevDir; + rmSync(proj, { recursive: true, force: true }); + rmSync(userDir, { recursive: true, force: true }); + } +}); +test("bgstatus : reports done from the log when the in-memory record lags", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); + process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); + try { + const id = `lag-job-${Math.floor(Date.now() / 1000)}-${process.pid}`; + const logPath = join(dir, `${id}.log`); + writeFileSync(logPath, "working…\n"); + + const priorEntries = [ + { + type: "custom", + customType: "bgrun-job", + data: { + id, + pid: process.pid, + cmd: "sleep 999", + started: Date.now(), + logPath, + state: "running", + }, + }, + ]; + const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries }); + await loadExtension(pi); + await fireSessionStart(); + + // The job finishes (marker lands) but no child 'exit' event exists for a + // reconstructed record — by-id must derive done from the log/pid rather + // than reporting "running" until the next poll tick. + writeFileSync(logPath, "working…\n\n__BGRUN_EXIT__=0\n"); + + const bgstatus = tools.get("bgstatus")!; + const res = await bgstatus.execute( + "call-lag", + { id }, + undefined, + undefined, + ctx, + ); + assert.match( + res.content[0].text as string, + /done exit=0/, + "by-id reflects the log, not the stale in-memory record", + ); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); +test("entry renderer: bgrun-job renders running and done/expanded without throwing", async () => { + const { pi, entryRenderers } = makeFakePi(); + await loadExtension(pi); + const render = entryRenderers.get("bgrun-job"); + assert.ok(render, "a renderer is registered for bgrun-job"); + // Minimal theme stub: the renderer only calls fg()/bg() for styling. + const theme = { + bg: (_key: string, text: string) => text, + fg: (_key: string, text: string) => text, + }; + const base = { + id: "render-job-1", + cmd: "make test", + started: Date.now(), + logPath: "/tmp/render-job-1.log", + }; + const running = render({ data: { ...base, state: "running" } }, {}, theme); + assert.ok(running, "running entry renders"); + const done = render( + { data: { ...base, state: "done", exitCode: 0, exitedAt: Date.now() } }, + { expanded: true }, + theme, + ); + assert.ok(done, "done + expanded entry renders"); +}); +test("bggrep: the match budget trips and reports an error (timeout plumbing)", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); + process.env.PI_BGRUN_DIR = dir; + process.env.PI_BGRUN_GREP_TIMEOUT_MS = "1"; // force the budget to trip + markJobsDir(dir); + try { + const { pi, tools, ctx } = makeFakePi(); + await loadExtension(pi); + const id = `grep-budget-${Math.floor(Date.now() / 1000)}-${process.pid}`; + writeFileSync(join(dir, `${id}.log`), "alpha\nbeta\ngamma\n"); + const bggrep = tools.get("bggrep")!; + const t0 = Date.now(); + const res = await bggrep.execute( + "c", + { id, pattern: "(a+)+$" }, + undefined, + undefined, + ctx, + ); + const elapsed = Date.now() - t0; + assert.equal(res.isError, true, "budget exceeded is an error result"); + assert.match( + res.content[0].text as string, + /match budget/, + "explains the budget was exceeded", + ); + assert.ok(elapsed < 5_000, `returned within budget (${elapsed}ms)`); + } finally { + delete process.env.PI_BGRUN_DIR; + delete process.env.PI_BGRUN_GREP_TIMEOUT_MS; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("bggrepTimeoutMs: falls back to the default for missing/invalid values", async () => { + const mod: any = await loadModule(); + delete process.env.PI_BGRUN_GREP_TIMEOUT_MS; + assert.equal(mod.bggrepTimeoutMs(), 2_000, "default when unset"); + process.env.PI_BGRUN_GREP_TIMEOUT_MS = "0"; + assert.equal(mod.bggrepTimeoutMs(), 2_000, "non-positive falls back"); + process.env.PI_BGRUN_GREP_TIMEOUT_MS = "nope"; + assert.equal(mod.bggrepTimeoutMs(), 2_000, "non-numeric falls back"); + process.env.PI_BGRUN_GREP_TIMEOUT_MS = "250"; + assert.equal(mod.bggrepTimeoutMs(), 250, "valid override honored"); + delete process.env.PI_BGRUN_GREP_TIMEOUT_MS; +}); +test("bgstatus : unknown id errors, and a log-only job recovers as done", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); + process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); + try { + const { pi, tools, ctx } = makeFakePi(); + await loadExtension(pi); + const bgstatus = tools.get("bgstatus")!; + + const missing = await bgstatus.execute( + "c1", + { id: "ghost-1-999999" }, + undefined, + undefined, + ctx, + ); + assert.equal(missing.isError, true, "unknown id is an error"); + assert.match( + missing.content[0].text as string, + /No job found with id/, + "unknown id explains itself", + ); + + const id = `recovered-${Math.floor(Date.now() / 1000)}-${process.pid}`; + writeFileSync( + join(dir, `${id}.log`), + "did the thing\n\n__BGRUN_EXIT__=0\n", + ); + const done = await bgstatus.execute( + "c2", + { id }, + undefined, + undefined, + ctx, + ); + assert.match( + done.content[0].text as string, + /done exit=0 \(recovered from log\)/, + "log-only job recovers as done", + ); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("jobs dir: .tmp-*.log staging files are skipped as jobs and swept when stale", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); + process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); + try { + const { pi, tools, ctx } = makeFakePi(); + await loadExtension(pi); + const tmp = join(dir, ".tmp-build-1-deadbeef.log"); + writeFileSync(tmp, "half-written"); + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + utimesSync(tmp, old, old); + + const list = await tools + .get("bgstatus")! + .execute("c1", {}, undefined, undefined, ctx); + assert.ok( + !(list.content[0].text as string).includes(".tmp-build-1-deadbeef"), + "staging file is not listed as a job", + ); + + await tools + .get("bgclean")! + .execute("c2", { days: 1, all: true }, undefined, undefined, ctx); + assert.ok(!existsSync(tmp), "stale staging file is reclaimed"); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("bggrep: a pathological regex returns within the budget instead of hanging", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); + process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); + try { + const { pi, tools, ctx } = makeFakePi(); + await loadExtension(pi); + const id = `patho-${Math.floor(Date.now() / 1000)}-${process.pid}`; + // A long run of `a` then `b` is the classic catastrophic-backtracking input + // for `^(a+)+$`. Under Node/V8 this would lock the thread; the worker must + // abort at the 2s budget. Under Bun's engine it completes quickly. + writeFileSync(join(dir, `${id}.log`), "a".repeat(60_000) + "b\n"); + const t0 = Date.now(); + const res = await tools + .get("bggrep")! + .execute("c", { id, pattern: "^(a+)+$" }, undefined, undefined, ctx); + const elapsed = Date.now() - t0; + assert.ok(elapsed < 5_000, `bounded by the budget (${elapsed}ms)`); + assert.ok( + res.isError === true || typeof res.content[0].text === "string", + "returns a result (no hang, no throw)", + ); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("session_start: a reconstructed done job is not re-persisted on every resume", async () => { + // Regression: reconstructed done records were re-appended on each resume + // (exitCode set but donePersisted unset), so the transcript grew a duplicate + // done card per restart. + const dir = mkTmp("pi-bgrun-test-"); + process.env.PI_BGRUN_DIR = dir; + try { + const id = `resume-done-${Date.now()}-99999999`; + const logPath = join(dir, `${id}.log`); + writeFileSync(logPath, "out\n__BGRUN_EXIT__=0\n"); + const baseEntries: CapturedEntry[] = [ + { + type: "custom", + customType: "bgrun-job", + data: { + id, + pid: 99999999, + cmd: "echo done", + started: Date.now() - 1000, + logPath, + state: "done", + exitCode: 0, + exitedAt: Date.now() - 500, + }, + }, + ]; + const countDone = (entries: CapturedEntry[]) => + entries.filter( + (e) => + e.customType === "bgrun-job" && + e.data?.id === id && + e.data?.state === "done", + ).length; + + // Resume 1. + const first = makeFakePi({ priorEntries: baseEntries }); + await loadExtension(first.pi); + first.ctx.hasUI = true; // drive updateWidget → revalidateStaleJobs + await first.fireSessionStart(); + assert.equal( + countDone(first.entries), + 1, + "first resume must not append a duplicate done entry", + ); + + // Resume 2: fresh extension instance over the transcript resume 1 left. + const second = makeFakePi({ priorEntries: first.entries }); + await loadExtension(second.pi); + second.ctx.hasUI = true; + await second.fireSessionStart(); + assert.equal( + countDone(second.entries), + 1, + "second resume must still leave exactly one done entry", + ); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); +test("bgclean all: a TERMINAL exit marker reclaims a finished log despite a reused live pid", async () => { + // A finished job writes the marker as the LAST line. If its pid is later + // reused by an unrelated live process, pid liveness alone would keep the log + // forever — the terminal marker must win. + const dir = mkTmp("pi-bgrun-test-"); + process.env.PI_BGRUN_DIR = dir; + markJobsDir(dir); + try { + const donePath = join(dir, `done-job-1000000000-${process.pid}.log`); + writeFileSync(donePath, "finished ok\n__BGRUN_EXIT__=0\n"); + const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const fs = await import("node:fs"); + fs.utimesSync(donePath, oldTime, oldTime); + + const { pi, tools, ctx } = makeFakePi(); + await loadExtension(pi); + await tools + .get("bgclean")! + .execute( + "call-reused-pid", + { days: 7, all: true }, + undefined, + undefined, + ctx, + ); + + assert.ok( + !existsSync(donePath), + "terminal-marker log reclaimed even though its pid is alive (reused)", + ); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/extension/index.ts b/extension/index.ts index b96c048..1df317b 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -50,7 +50,7 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, relative, sep } from "node:path"; import { homedir } from "node:os"; -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { DIGEST_PRESET_IDS, digestNoMatchWarning, @@ -64,6 +64,11 @@ import { // code survives pi restarting. `;` (not `&&`) ensures the printf runs even when // the command fails. Never use `set -e` in the wrapper. const EXIT_MARKER = "__BGRUN_EXIT__="; +const JOBS_DIR_MARKER = ".bgrun-jobs"; +// Tail-read caps — avoid whole-file readFileSync on runaway logs. +const LOG_TAIL_BYTES = 256 * 1024; // exit marker + last line +const LOG_READ_BYTES = 2 * 1024 * 1024; // bgtail / bggrep +const BGGREP_LINE_CAP = 10_000; // per-line match length cap const DEFAULT_CLEANUP_DAYS = 7; const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle @@ -79,6 +84,133 @@ function globalJobsDir(): string { ); } +// bggrep runs caller-supplied regexes. A pathological pattern (e.g. /^(a+)+$/) +// can backtrack catastrophically, and 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. On expiry the worker is terminated and a +// bounded error is returned instead of hanging the session. Bun's engine is +// more backtracking-resistant, but Node is the common case. +const BGGREP_DEFAULT_TIMEOUT_MS = 2_000; + +// Executed inside the worker (eval'd). Uses require(): available in an eval +// worker on both Node and Bun, unlike a static import (the eval body is CJS). +const BGGREP_WORKER_SOURCE = ` +const { parentPort, workerData } = require("node:worker_threads"); +try { + const re = new RegExp(workerData.source); + const lines = workerData.lines; + const cap = workerData.cap; + const out = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + if (line.length > cap) line = line.slice(0, cap); + if (re.test(line)) out.push(i); + } + parentPort.postMessage({ ok: true, matches: out }); +} catch (err) { + parentPort.postMessage({ ok: false, message: String((err && err.message) || err) }); +} +`; + +// Read at call time so tests (and users) can lower the budget; a non-positive +// or non-numeric value falls back to the default. +export function bggrepTimeoutMs(): number { + const raw = Number(process.env.PI_BGRUN_GREP_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : BGGREP_DEFAULT_TIMEOUT_MS; +} + +type GrepMatchOutcome = + | { kind: "ok"; matchIdx: number[] } + | { kind: "timeout" } + | { kind: "invalid"; message: string }; + +// Bounded between lines only — a single pathological line can still stall. +// Used solely when worker_threads is unavailable (never on Node or Bun). +function matchLinesSyncBounded( + source: string, + lines: string[], + cap: number, + budgetMs: number, +): GrepMatchOutcome { + let re: RegExp; + try { + re = new RegExp(source); + } catch (err) { + return { kind: "invalid", message: (err as Error).message }; + } + const out: number[] = []; + const start = Date.now(); + for (let i = 0; i < lines.length; i++) { + if ((i & 0x3ff) === 0 && Date.now() - start > budgetMs) { + return { kind: "timeout" }; + } + const line = lines[i].length > cap ? lines[i].slice(0, cap) : lines[i]; + if (re.test(line)) out.push(i); + } + return { kind: "ok", matchIdx: out }; +} + +async function matchLinesWithBudget( + source: string, + lines: string[], + cap: number, + budgetMs: number, +): Promise { + let WorkerCtor: typeof import("node:worker_threads").Worker; + try { + ({ Worker: WorkerCtor } = await import("node:worker_threads")); + } catch { + return matchLinesSyncBounded(source, lines, cap, budgetMs); + } + let worker: import("node:worker_threads").Worker; + try { + worker = new WorkerCtor(BGGREP_WORKER_SOURCE, { + eval: true, + workerData: { source, lines, cap }, + }); + } catch { + return matchLinesSyncBounded(source, lines, cap, budgetMs); + } + return new Promise((resolve) => { + let settled = false; + const finish = (outcome: GrepMatchOutcome) => { + if (settled) return; + settled = true; + clearTimeout(timer); + worker.removeAllListeners(); + // Swallow a late 'error' emitted after listeners are dropped, or it + // becomes an unhandled emitter throw on the way to terminate(). + worker.on("error", () => {}); + void worker.terminate(); + resolve(outcome); + }; + const timer = setTimeout(() => finish({ kind: "timeout" }), budgetMs); + worker.on( + "message", + (msg: { ok: boolean; matches?: number[]; message?: string }) => { + finish( + msg.ok + ? { kind: "ok", matchIdx: msg.matches ?? [] } + : { kind: "invalid", message: msg.message ?? "invalid pattern" }, + ); + }, + ); + worker.on("error", (err) => + finish({ kind: "invalid", message: err.message }), + ); + worker.on("exit", (code) => { + // Any exit before a message is a failure — including exit 0, which would + // otherwise linger until the budget and be misreported as a timeout. + if (!settled) { + finish({ + kind: "invalid", + message: `grep worker exited with code ${code} before a result`, + }); + } + }); + }); +} + // Default regex for bggrep when the caller passes no pattern: common failure // signatures across test runners and build tools. ONLY a convenience default — // bggrep's contract is that the caller's own pattern always wins, because a @@ -86,6 +218,245 @@ function globalJobsDir(): string { export const DEFAULT_GREP_PATTERN = "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖"; +function readLogSlice( + logPath: string, + maxBytes: number, +): { content: string; truncated: boolean; size: number } | null { + try { + const st = statSync(logPath); + const size = st.size; + if (size === 0) return { content: "", truncated: false, size: 0 }; + const readLen = Math.min(size, maxBytes); + const fd = openSync(logPath, "r"); + try { + const buf = Buffer.alloc(readLen); + // Honor the byte count: a short read (file rotated/truncated between stat + // and read) would otherwise leave the buffer's tail zero-filled and leak + // NUL bytes into bgtail/bggrep output. + const n = readSync(fd, buf, 0, readLen, size - readLen); + return { + content: (n < readLen ? buf.subarray(0, n) : buf).toString("utf8"), + truncated: readLen < size, + size, + }; + } finally { + closeSync(fd); + } + } catch { + return null; + } +} + +// The wrapper writes __BGRUN_EXIT__=N as the FINAL line of the log. A marker +// that is NOT the last non-empty line is just job output that happened to +// contain the string (e.g. a command that greps a bgrun log) and is NOT +// evidence of completion. Position matters both ways: trusting any marker would +// let cleanup delete a running job's log; trusting none would let a finished +// log whose pid was later reused live forever. +function parseExitFromContent(content: string): number | null { + const lines = content.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim().length === 0) continue; + const match = lines[i].match(/^__BGRUN_EXIT__=(-?\d+)/); + return match ? parseInt(match[1], 10) : null; + } + return null; +} + +export function parseExitFromLogPath(logPath: string): number | null { + const slice = readLogSlice(logPath, LOG_TAIL_BYTES); + if (!slice) return null; + return parseExitFromContent(slice.content); +} + +function readLastLogLine(logPath: string, maxLen = 200): string | null { + const slice = readLogSlice(logPath, LOG_TAIL_BYTES); + if (!slice) return null; + return readLastLineFromContent(slice.content, maxLen); +} + +function readLastLineFromContent(content: string, maxLen = 200): string | null { + const lines = content.split("\n").filter((l) => l.trim().length > 0); + if (lines.length === 0) return null; + const real = lines.filter((l) => !l.startsWith(EXIT_MARKER)); + // No content lines (a marker-only log) → nothing to show. Never fall back to + // the exit-marker line — that leaks "__BGRUN_EXIT__=N" into the wake. + if (real.length === 0) return null; + const last = real[real.length - 1]; + return last.length > maxLen ? last.slice(0, maxLen) + "…" : last; +} + +function validateJobId(id: string, tool: string): void { + if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) { + throw new Error(`${tool}: invalid job id ${JSON.stringify(id)}`); + } +} + +function isRunningPid(pid: number): boolean { + if (pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + // EPERM means the process exists but we can't signal it — treat as alive. + return code === "EPERM"; + } +} + +function pidFromId(id: string): number | null { + const parts = id.split("-"); + const pid = parseInt(parts[parts.length - 1], 10); + return Number.isFinite(pid) ? pid : null; +} + +// One entry per *.log in a jobs dir, with the derived state every caller needs +// (finish marker, owning pid + liveness, timestamps). This is the single scan +// used by cleanup, foreign-job adoption, and bgstatus — they used to each +// re-implement the readdir/filter/parse/pid dance and drifted apart. +interface ScannedLogFile { + id: string; + logPath: string; + pid: number | null; // pid encoded in the id's last segment + alive: boolean; // pid > 0 and signalable (or EPERM) + mtimeMs: number; + birthtimeMs: number; +} + +interface ScannedLog extends ScannedLogFile { + exit: number | null; // parsed __BGRUN_EXIT__ marker, null while running +} + +// The scan WITHOUT the exit-marker read. Exit parsing needs a tail read of the +// file, so callers that can filter by mtime first (cleanup) use this and pay +// for the read only on files they may actually act on. +function scanLogFiles(jobsDir: string): ScannedLogFile[] { + let names: string[]; + try { + names = readdirSync(jobsDir); + } catch { + return []; // jobs dir doesn't exist — nothing to scan + } + const out: ScannedLogFile[] = []; + for (const name of names) { + if (!name.endsWith(".log")) continue; + // .tmp-*.log is the pre-rename staging file (see the spawn path). It is + // never a job — a crashed spawn can leave one behind; sweepStaleMarkers + // reclaims it. + if (name.startsWith(".tmp-")) continue; + const logPath = join(jobsDir, name); + let st: ReturnType; + try { + st = statSync(logPath); + } catch { + continue; // vanished between readdir and stat + } + const pid = pidFromId(name.slice(0, -".log".length)); + out.push({ + id: name.slice(0, -".log".length), + logPath, + pid, + alive: pid !== null && pid > 0 && isRunningPid(pid), + mtimeMs: st.mtimeMs, + birthtimeMs: st.birthtimeMs, + }); + } + return out; +} + +// The full scan (exit marker resolved) for callers that need finished/running +// state for every entry. +function scanJobsDir(jobsDir: string): ScannedLog[] { + return scanLogFiles(jobsDir).map((e) => ({ + ...e, + exit: parseExitFromLogPath(e.logPath), + })); +} + +// Redact obvious credential values before they reach a filename, widget, or +// status line. The raw command still appears in the wake message (needed for +// context), but the persisted job id / slug is a much longer-lived leak +// channel (it survives in filenames and `bgstatus` output for cleanupDays). +// ── Secret redaction for slugs ───────────────────────────────────────────── +// A job id becomes a filename, and filenames get listed, shared, and scraped. +// Commands routinely embed credentials, so redact values BEFORE they reach a +// slug. This deliberately errs toward over-redaction: a mangled slug is +// cosmetic, a leaked token is not. + +// Secret-ish key words, matched as a substring of a longer key (GH_TOKEN, +// AWS_SECRET_ACCESS_KEY, DB_PASSWORD) with a trailing non-letter guard so +// "author"/"designer" are not mistaken for "auth"/"sig". +const SECRET_KEY_WORDS = + "authorization|pass(?:word|wd|phrase)?|passw(?:or)?d|secret|token|" + + "api[-_]?key|apikey|access[-_]?key|private[-_]?key|client[-_]?secret|" + + "credential(?:s)?|session[-_]?id|signature|pwd|bearer|auth"; +// A key: optional surrounding word chars/dots/dashes, then a secret word. +const SECRET_KEY = String.raw`[A-Za-z0-9_.-]*(?:${SECRET_KEY_WORDS})(?![A-Za-z])`; +// A value: a quoted string, a `scheme credential` pair ("Bearer abc"), or a +// bare token. The scheme form is tried first so the credential after it is +// consumed too — otherwise "Authorization: Bearer abc" redacts only "Bearer". +const SECRET_VALUE = String.raw`(?:'[^']*'|"[^"]*"|(?:bearer|basic|token|digest)\s+\S+|\S+)`; +const SECRET_ASSIGN_RE = new RegExp( + String.raw`(${SECRET_KEY})["']?\s*[:=]\s*["']?${SECRET_VALUE}`, + "gi", +); +const SECRET_FLAG_RE = new RegExp( + String.raw`(^|\s)(-{1,2}${SECRET_KEY})(\s*[:=]\s*|\s+)["']?${SECRET_VALUE}`, + "gi", +); + +export function redactForSlug(command: string): string { + return ( + command + // Header arguments: -H stays CASE-SENSITIVE (so a lower-case `-h`/help + // flag is never mangled); --header is case-insensitive. The whole + // argument is consumed — any header can carry a token. + .replace(/(^|\s)-H(=|\s+)('[^']*'|"[^"]*"|\S+)/g, "$1-H$2-REDACTED") + .replace( + /(^|\s)--header(=|\s+)('[^']*'|"[^"]*"|\S+)/gi, + "$1--header$2-REDACTED", + ) + // curl -u user:pass / --user user:pass (only when it looks like a pair, + // so unrelated flags like `sort -u` are left alone). + .replace(/(^|\s)-u(\s+)([^\s:]+:[^\s]+)/g, "$1-u$2-REDACTED") + .replace(/(^|\s)--user(\s+)([^\s:]+:[^\s]+)/g, "$1--user$2-REDACTED") + // URL userinfo: scheme://user:pass@host. + .replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+)@/gi, "$1-REDACTED@") + // KEY=value / KEY: value, including quoted JSON ("password":"x"). + .replace(SECRET_ASSIGN_RE, "$1-REDACTED") + // --flag value / --flag=value / --flag: value. + .replace(SECRET_FLAG_RE, "$1$2-REDACTED") + ); +} + +function resolveGitCommonDir(gitDir: string): string { + const commonFile = join(gitDir, "commondir"); + if (!existsSync(commonFile)) return gitDir; + try { + const rel = readFileSync(commonFile, "utf8").trim(); + return isAbsolute(rel) ? rel : join(gitDir, rel); + } catch { + return gitDir; + } +} + +function ensureJobsDirMarker(jobsDir: string): void { + try { + mkdirSync(jobsDir, { recursive: true }); + const marker = join(jobsDir, JOBS_DIR_MARKER); + if (!existsSync(marker)) writeFileSync(marker, ""); + } catch { + // best-effort + } +} + +function logReadError(id: string, logPath: string): string { + if (existsSync(logPath)) { + return `Log for job ${id} at ${logPath} exists but could not be read (file may be too large or unreadable)`; + } + return `No log found for job ${id} at ${logPath}`; +} + // ── Configuration ─────────────────────────────────────────────────────────── // // Layered: defaults ← user config file ← project config file (trusted projects @@ -152,12 +523,23 @@ function parseBoolEnv(v: string | undefined): boolean | undefined { } function readConfigFile(path: string): BgrunConfigFile { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + return {}; // missing — normal, not an error + } try { - const raw = JSON.parse(readFileSync(path, "utf8")); + const raw = JSON.parse(text); if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw as BgrunConfigFile; - } catch { - // missing or malformed — treat as empty + console.error( + `[pi-bgrun] config ${path} is not a JSON object — ignoring its contents`, + ); + } catch (err) { + console.error( + `[pi-bgrun] config ${path} is malformed JSON (${(err as Error).message}) — ignoring its contents`, + ); } return {}; } @@ -241,9 +623,16 @@ export function resolveJobsDirPath( ? { dir: join(root, PROJECT_LOCAL_JOBS_REL), projectLocal: true } : { dir: globalJobsDir(), projectLocal: false }; } - return root - ? { dir: join(root, p), projectLocal: true } - : { dir: globalJobsDir(), projectLocal: false }; + if (!root) return { dir: globalJobsDir(), projectLocal: false }; + // A relative path can escape the project root ("../outside"); only flag it + // project-local when the joined dir actually stays inside the root, so git + // exclusion and the untrusted-repo guard apply to the right thing. + const joined = join(root, p); + const rel = relative(root, joined); + // `..foo` is a sibling, not an escape — only `..` itself or `../` escapes. + const inside = + rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel); + return { dir: joined, projectLocal: inside }; } // Auto-ignore a project-local jobs dir in git so logs never pollute @@ -297,11 +686,13 @@ function appendExcludePattern( if (!m) return false; // unparseable .git file — retry later gitDir = m[1].trim(); } + gitDir = resolveGitCommonDir(gitDir); const rel = relative(repoRoot, jobsDir); // Defense-in-depth: the walk-up guarantees jobsDir sits under repoRoot, but // a future caller or symlinked path could break that — ../-prefixed // patterns are silently useless in gitignore semantics, so skip them. - if (rel.startsWith("..") || isAbsolute(rel)) return true; + if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) + return true; const pattern = rel.split(sep).join("/") + "/"; const excludePath = join(gitDir, "info", "exclude"); let existing = ""; @@ -356,11 +747,19 @@ function projectHash(projectDir: string): string { * project-scoped even when the jobs dir is shared (an absolute/global * `jobsDir`); project-local dirs get the same per-project key harmlessly. */ +function projectMarkerPath( + jobsDir: string, + projectDir: string, + prefix: string, +): string { + return join(jobsDir, `${prefix}${projectHash(projectDir)}`); +} + export function jobUsageMarkerPath( jobsDir: string, projectDir: string, ): string { - return join(jobsDir, `.bgrun-used-${projectHash(projectDir)}`); + return projectMarkerPath(jobsDir, projectDir, ".bgrun-used-"); } /** @@ -375,7 +774,7 @@ export function digestNudgeMarkerPath( jobsDir: string, projectDir: string, ): string { - return join(jobsDir, `.digest-nudge-${projectHash(projectDir)}`); + return projectMarkerPath(jobsDir, projectDir, ".digest-nudge-"); } /** @@ -505,12 +904,16 @@ function normalizeDigestEntry(raw: unknown): DigestEntry | undefined { export function resolveConfig(ctx?: { cwd?: string; isProjectTrusted?: () => boolean; + // Test seam: os.homedir() caches in some runtimes, so tests inject the user + // config path instead of mutating HOME. + userConfigPath?: string; }): BgrunConfig { - // User config: $HOME/.pi/agent/pi-bgrun.json, overridable via - // PI_BGRUN_USER_CONFIG (mirrors the PI_BGRUN_DIR escape hatch — mainly for - // tests, which cannot swap the real home dir). + // User config: $HOME/.pi/agent/pi-bgrun.json. Overridable by an explicit + // test seam (ctx.userConfigPath) and by PI_BGRUN_USER_CONFIG (mirrors the + // PI_BGRUN_DIR escape hatch — mainly for tests, which cannot swap home). const user = readConfigFile( - process.env.PI_BGRUN_USER_CONFIG || + ctx?.userConfigPath ?? + process.env.PI_BGRUN_USER_CONFIG ?? join(homedir(), ".pi", "agent", "pi-bgrun.json"), ); let project: BgrunConfigFile = {}; @@ -519,7 +922,7 @@ export function resolveConfig(ctx?: { // Read the project config from the same root resolveJobsDirPath uses, so // a session started in a subdirectory still picks up /.pi config. const cwd = ctx.cwd ?? process.cwd(); - const projectRoot = findProjectRoot(cwd) ?? cwd; + const projectRoot = projectRootFor(cwd); project = readConfigFile( join(projectRoot, CONFIG_DIR_NAME, "pi-bgrun.json"), ); @@ -652,6 +1055,7 @@ interface JobRecord { logPath: string; exitedAt?: number; exitCode?: number; + donePersisted?: boolean; // done entry already appended to the transcript child?: ReturnType; // absent for adopted (fs-discovered) jobs 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) @@ -683,16 +1087,6 @@ interface BgStatusDetails { recovered?: boolean; } -function isRunningPid(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - // EPERM means the process exists but we can't signal it — still alive. - return (err as NodeJS.ErrnoException)?.code === "EPERM"; - } -} - export default function (pi: ExtensionAPI) { const jobs = new Map(); // Poller for stale job records — anything running with no live ChildProcess @@ -704,7 +1098,7 @@ export default function (pi: ExtensionAPI) { // ── Helpers ─────────────────────────────────────────────────────────────── function makeSlug(command: string): string { - const raw = command + const raw = redactForSlug(command) .toLowerCase() .replace(/[/\\.-]+/g, " ") .trim(); @@ -715,9 +1109,14 @@ export default function (pi: ExtensionAPI) { return slug || "job"; } - // Normalize an optional human-readable name: trim, drop blank, cap length. + // Normalize an optional human-readable name: strip control characters + // (newlines, tabs, escape/ANSI bytes) so a name can never forge extra lines + // in the wake, widget, toast, or transcript; collapse whitespace; cap length. function sanitizeName(name: string | undefined): string | undefined { - const trimmed = (name ?? "").trim(); + const trimmed = (name ?? "") + .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ") + .replace(/\s+/g, " ") + .trim(); if (!trimmed) return undefined; return trimmed.slice(0, 80); } @@ -728,19 +1127,6 @@ export default function (pi: ExtensionAPI) { return normalizeType(type); } - function readLastLogLine(logPath: string, maxLen = 200): string | null { - try { - const content = readFileSync(logPath, "utf8"); - const lines = content.split("\n").filter((l) => l.trim().length > 0); - if (lines.length === 0) return null; - const real = lines.filter((l) => !l.startsWith(EXIT_MARKER)); - const last = real[real.length - 1] ?? lines[lines.length - 1]; - return last.length > maxLen ? last.slice(0, maxLen) + "…" : last; - } catch { - return null; - } - } - // Count the log's total lines with a bounded-memory streaming scan (one // fixed-size buffer, no full-file read). Missing/unreadable file → null: // the Stats line then just omits the line count — best-effort, never @@ -777,6 +1163,10 @@ export default function (pi: ExtensionAPI) { // command output. Drop the marker line, plus the blank separator when the // output already ended in a newline. const markerAt = tailText.lastIndexOf("\n" + EXIT_MARKER); + if (markerAt === 0) { + // The file is only the wrapper's "\n\n" — no command output. + return 0; + } if (markerAt !== -1) { let extra = 0; for (let i = markerAt + 1; i < tailText.length; i++) { @@ -793,32 +1183,14 @@ export default function (pi: ExtensionAPI) { } } - function parseExitFromLog(logPath: string): number | null { - try { - const content = readFileSync(logPath, "utf8"); - const lines = content - .split("\n") - .filter((l) => l.startsWith(EXIT_MARKER)); - if (lines.length === 0) return null; - const match = lines[lines.length - 1].match(/^__BGRUN_EXIT__=(\d+)/); - return match ? parseInt(match[1], 10) : null; - } catch { - return null; - } - } - - function pidFromId(id: string): number | null { - // id format: -- - const parts = id.split("-"); - const pid = parseInt(parts[parts.length - 1], 10); - return Number.isFinite(pid) ? pid : null; - } - // ── Live status widget ──────────────────────────────────────────────────── - function updateWidget(ctx: ExtensionContext): void { + function updateWidget( + ctx: ExtensionContext, + opts: { persistRevalidate?: boolean } = {}, + ): void { if (!ctx.hasUI) return; - revalidateStaleJobs(); + revalidateStaleJobs({ persist: opts.persistRevalidate ?? true }); const running: JobRecord[] = []; for (const rec of jobs.values()) { if (rec.exitCode === undefined) running.push(rec); @@ -831,11 +1203,10 @@ export default function (pi: ExtensionAPI) { for (const rec of running) { const startedAt = formatSince(rec.started); const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd; - const label = rec.name ? `${rec.name} · ${cmd}` : cmd.padEnd(40); + const label = rec.name ? `${rec.name} · ${cmd}` : cmd; const tag = rec.adopted ? " (adopted)" : ""; - lines.push( - ` ${rec.id.slice(0, 20)} ${label} (since ${startedAt})${tag}`, - ); + // Full id (not truncated) so it can be copied straight into /bgtail . + lines.push(` ${rec.id} ${label} (since ${startedAt})${tag}`); } ctx.ui.setWidget("bgrun", lines); } @@ -856,7 +1227,10 @@ export default function (pi: ExtensionAPI) { for (const name of names) { if ( !name.startsWith(".bgrun-used-") && - !name.startsWith(".digest-nudge-") + !name.startsWith(".digest-nudge-") && + // Only OUR staging files (`.tmp---.log`), never an + // unrelated `.tmp-*` that happens to live in the dir. + !(name.startsWith(".tmp-") && name.endsWith(".log")) ) continue; try { @@ -873,55 +1247,50 @@ export default function (pi: ExtensionAPI) { days: number, jobsDir: string, ctx?: ExtensionContext, + // The ownership marker protects the AUTOMATIC global sweep from deleting + // logs in an unrelated dir (a stray PI_BGRUN_DIR). An explicit + // `bgclean all` is the user's direct intent, so it bypasses the gate. + opts: { requireOwnership?: boolean } = {}, ): { removed: number; kept: number; skippedRunning: number } { const result = { removed: 0, kept: 0, skippedRunning: 0 }; - let entries: string[]; - try { - entries = readdirSync(jobsDir); - } catch { + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + if ( + opts.requireOwnership !== false && + !existsSync(join(jobsDir, JOBS_DIR_MARKER)) + ) { + // Not recognizably ours — touch NOTHING, marker files included. The gate + // exists so a stray PI_BGRUN_DIR is never emptied. return result; } - const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; - for (const name of entries) { - if (!name.endsWith(".log")) continue; - const logPath = join(jobsDir, name); - let st; - try { - st = statSync(logPath); - } catch { - continue; - } - // mtime check - if (st.mtimeMs > cutoff) { + // Past the gate: sweep our own stale per-project marker files. + sweepStaleMarkers(jobsDir, cutoff); + for (const entry of scanLogFiles(jobsDir)) { + // mtime check FIRST — young files are never candidates, so skip early. + if (entry.mtimeMs > cutoff) { result.kept++; continue; } - const id = name.slice(0, -".log".length); - // Exit marker is the authoritative finished signal — check it BEFORE pid - // liveness, so completed jobs are never mistaken for running (pid reuse - // and shared pids made the old order keep stale jobs forever). - const finished = parseExitFromLog(logPath) !== null; + // A TERMINAL marker means the wrapper finished writing — trust it even + // when the pid looks alive (that is a reused pid; otherwise the log would + // never be reclaimed). A non-terminal marker is not completion evidence, + // so fall through to pid liveness, which protects a job that merely + // printed the string. + const finished = parseExitFromLogPath(entry.logPath) !== null; if (!finished) { - // No marker yet — running only if the pid is alive. - const rec = jobs.get(id); - if (rec && rec.exitCode === undefined) { - result.skippedRunning++; - continue; - } - const pid = pidFromId(id); - if (pid !== null && pid > 0 && isRunningPid(pid)) { + const rec = jobs.get(entry.id); + if (entry.alive && rec?.exitCode === undefined) { result.skippedRunning++; continue; } } + // finished, dead pid, or our record says done → safe to remove. try { - unlinkSync(logPath); + unlinkSync(entry.logPath); result.removed++; } catch { // ignore } } - sweepStaleMarkers(jobsDir, cutoff); if (result.removed > 0 && ctx?.hasUI) { ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info"); } @@ -940,15 +1309,25 @@ export default function (pi: ExtensionAPI) { const result = { removed: 0, kept: 0, skippedRunning: 0 }; const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; for (const rec of jobs.values()) { + // Adopted foreign jobs belong to another session — this session neither + // owns nor reports on them (counting them as "skipped running" was + // misleading). + if (rec.adopted) continue; if (rec.exitCode === undefined) { result.skippedRunning++; continue; } - let st; + let st: ReturnType; try { st = statSync(rec.logPath); } catch { - continue; // already gone + // Log already gone (cleaned by a global sweep). Drop the in-memory + // record once it's past retention so finished jobs can't pin the Map + // (and its ExtensionContext) for the life of the process. + if (rec.exitedAt !== undefined && rec.exitedAt < cutoff) { + jobs.delete(rec.id); + } + continue; } if (st.mtimeMs > cutoff) { result.kept++; @@ -957,6 +1336,7 @@ export default function (pi: ExtensionAPI) { try { unlinkSync(rec.logPath); result.removed++; + jobs.delete(rec.id); } catch { // ignore } @@ -1031,7 +1411,16 @@ export default function (pi: ExtensionAPI) { } catch { // no marker yet — run the sweep } - cleanOldJobs(cfg.cleanupDays, dir, ctx); + // The known machine-global dir is ours even without a .bgrun-jobs marker + // (the project-local default never writes one there), so bypass the + // ownership gate for it only; the project-local dir stays gated. + // The DEFAULT machine-global dir is ours even without a .bgrun-jobs + // marker (the project-local default never writes one there). A custom + // PI_BGRUN_GLOBAL_DIR is gated like any other dir, per the README. + const isGlobal = + !process.env.PI_BGRUN_GLOBAL_DIR && + safeRealpath(dir) === safeRealpath(globalJobsDir()); + cleanOldJobs(cfg.cleanupDays, dir, ctx, { requireOwnership: !isGlobal }); try { mkdirSync(dir, { recursive: true }); writeFileSync(markerPath, String(Date.now())); @@ -1051,10 +1440,43 @@ export default function (pi: ExtensionAPI) { // session's history; the log on disk still covers id lookup + cleanup). // - Reconstructed jobs ARE this session's history: mark them done and // append a done entry so future resumes reconstruct them as done too. - function revalidateStaleJobs(): void { + // `persist: false` is for read-only callers (bgstatus): they still need an + // 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 { + rec.donePersisted = true; + pi.appendEntry("bgrun-job", { + id: rec.id, + pid: rec.pid, + cmd: rec.cmd, + name: rec.name, + type: rec.type, + started: rec.started, + logPath: rec.logPath, + state: "done", + exitCode: exit >= 0 ? exit : undefined, + exitedAt: rec.exitedAt, + }); + } + + function revalidateStaleJobs(opts: { persist?: boolean } = {}): void { + const persist = opts.persist ?? true; for (const [id, rec] of jobs) { - if (rec.child || rec.exitCode !== undefined) continue; - let exit = parseExitFromLog(rec.logPath); + if (rec.child) continue; + if (rec.exitCode !== undefined) { + // Already reconciled. A read-only pass (bgstatus, persist:false) sets + // exitCode WITHOUT persisting, so a later persisting pass must still + // write the done entry — otherwise the transcript card stays "running" + // for the rest of the session. + if (persist && !rec.donePersisted && !rec.adopted) { + persistDoneEntry(rec, rec.exitCode); + } + continue; + } + let exit = parseExitFromLogPath(rec.logPath); + if (exit === null && rec.pid <= 0) { + exit = -1; + } if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) { // pid gone with no marker — killed/crashed before the wrapper could write it, // or the log was already cleaned up @@ -1066,18 +1488,7 @@ export default function (pi: ExtensionAPI) { } else { rec.exitCode = exit; rec.exitedAt = Date.now(); - pi.appendEntry("bgrun-job", { - id: rec.id, - pid: rec.pid, - cmd: rec.cmd, - name: rec.name, - type: rec.type, - started: rec.started, - logPath: rec.logPath, - state: "done", - exitCode: exit >= 0 ? exit : undefined, - exitedAt: rec.exitedAt, - }); + if (persist) persistDoneEntry(rec, exit); } } } @@ -1196,6 +1607,9 @@ export default function (pi: ExtensionAPI) { logPath: d.logPath, exitedAt: d.exitedAt, exitCode: isDone ? (d.exitCode ?? -1) : undefined, + // Mark the done entry as already persisted, or revalidateStaleJobs + // appends a duplicate done card on every resume. + donePersisted: isDone, ctx, }); } @@ -1215,34 +1629,19 @@ export default function (pi: ExtensionAPI) { const cfg = resolveConfig(ctx); const jobsDir = cfg.jobsDir; if (cfg.adoptForeignJobs) { - try { - for (const name of readdirSync(jobsDir)) { - if (!name.endsWith(".log")) continue; - const id = name.slice(0, -".log".length); - if (jobs.has(id)) continue; - const logPath = join(jobsDir, name); - const exit = parseExitFromLog(logPath); - if (exit !== null) continue; // finished — nothing to show in the widget - const pid = pidFromId(id); - if (pid === null || pid <= 0 || !isRunningPid(pid)) continue; // dead pid, marker just not written yet - let started = Date.now(); - try { - started = statSync(logPath).birthtimeMs; - } catch { - // keep fallback - } - jobs.set(id, { - id, - pid, - cmd: "(started by another session)", - started, - logPath, - ctx, - adopted: true, - }); - } - } catch { - // jobs dir doesn't exist — nothing to adopt. + for (const entry of scanJobsDir(jobsDir)) { + if (jobs.has(entry.id)) continue; + if (entry.exit !== null) continue; // finished — nothing to show in the widget + if (!entry.alive) continue; // dead pid, marker just not written yet + jobs.set(entry.id, { + id: entry.id, + pid: entry.pid ?? -1, + cmd: "(started by another session)", + started: entry.birthtimeMs || Date.now(), + logPath: entry.logPath, + ctx, + adopted: true, + }); } } @@ -1328,6 +1727,7 @@ export default function (pi: ExtensionAPI) { if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir); const jobsDir = cfg.jobsDir; mkdirSync(jobsDir, { recursive: true }); + ensureJobsDirMarker(jobsDir); // Evidence-of-use marker (best-effort): lets the digest nudge tell that // THIS project has run bgrun, without scanning the shared jobs dir. try { @@ -1343,213 +1743,277 @@ export default function (pi: ExtensionAPI) { const ts = Math.floor(Date.now() / 1000); // The id must carry the CHILD's pid (liveness checks depend on it), but the // log fd must exist before spawn. Create at a temp path, rename after spawn. + // randomBytes (not Math.random) plus O_EXCL: the temp name is not + // guessable and a pre-planted symlink cannot be truncated through. const tmpPath = join( jobsDir, - `.tmp-${slug}-${ts}-${Math.random().toString(36).slice(2, 8)}.log`, + `.tmp-${slug}-${ts}-${randomBytes(4).toString("hex")}.log`, ); - let logFd: number; + let logFd: number | undefined; + let logPath = tmpPath; try { - logFd = openSync(tmpPath, "w"); + // 0600: job logs can contain secrets pulled from the environment. + logFd = openSync(tmpPath, "wx", 0o600); } catch (err) { throw new Error( `bgrun: cannot create log file: ${(err as Error).message}`, ); } - const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`; - - const child = spawn("sh", ["-c", wrapped], { - stdio: ["ignore", logFd, logFd], - detached: true, - }); - child.unref(); - - const childPid = child.pid ?? -1; - const id = `${slug}-${ts}-${childPid}`; - const logPath = join(jobsDir, `${id}.log`); try { - renameSync(tmpPath, logPath); - } catch (err) { - console.error( - `[pi-bgrun] rename to final log path failed:`, - (err as Error).message, - ); - } - - const record: JobRecord = { - id, - pid: childPid, - cmd: command, - name, - type, - started: Date.now(), - logPath, - child, - ctx, - }; - jobs.set(id, record); - - // Persist a bgrun-job entry (running state) — transcript card + restart recovery. - pi.appendEntry("bgrun-job", { - id, - pid: childPid, - cmd: command, - name, - type, - started: Date.now(), - logPath, - state: "running", - }); + // Pass command as argv — interpolation breaks on #, quotes, heredocs. + const wrapper = `sh -c "$1"; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit "$ec"`; + const child = spawn("sh", ["-c", wrapper, "bgrun", command], { + stdio: ["ignore", logFd, logFd], + detached: true, + }); + child.unref(); - closeSync(logFd); + const childPid = child.pid ?? -1; + const id = `${slug}-${ts}-${childPid}`; + const finalLogPath = join(jobsDir, `${id}.log`); + try { + renameSync(tmpPath, finalLogPath); + logPath = finalLogPath; + } catch (err) { + console.error( + `[pi-bgrun] rename to final log path failed:`, + (err as Error).message, + ); + } - updateWidget(ctx); + const record: JobRecord = { + id, + pid: childPid, + cmd: command, + name, + type, + started: Date.now(), + logPath, + child, + ctx, + }; + jobs.set(id, record); - // ── exit handler: record exit, persist done entry, wake, notify, widget ─ - child.on("exit", async (code, signal) => { - const rec = jobs.get(id); - if (!rec) return; - rec.exitedAt = Date.now(); - rec.exitCode = code ?? -1; - delete rec.child; // release the handle reference - - const exitCode = code ?? parseExitFromLog(logPath) ?? -1; - const exitStr = - exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`; - const exitEmoji = exitCode === 0 ? "✅" : "❌"; - const lastLine = readLastLogLine(logPath); - - // Universal stats — duration + log line count. Non-heuristic, always - // present, never pattern-based. A missing log contributes no line - // count (duration is always known). - const logLines = countLogLines(logPath); - const statsParts = [formatDuration(rec.exitedAt - rec.started)]; - if (logLines !== null) - statsParts.push(`${logLines.toLocaleString("en-US")} lines`); - - // Persist the done-state entry. + // Persist a bgrun-job entry (running state) — transcript card + restart recovery. pi.appendEntry("bgrun-job", { id, - pid: rec.pid, - cmd: rec.cmd, - name: rec.name, - type: rec.type, - started: rec.started, + pid: childPid, + cmd: command, + name, + type, + started: Date.now(), logPath, - state: "done", - exitCode: exitCode >= 0 ? exitCode : undefined, - exitedAt: rec.exitedAt, + state: "running", }); - // Opt-in project-config digest (best-effort, silent-fail). rec.ctx is - // the ExtensionContext captured at tool-call time and retains - // everything resolveConfig needs (cwd + isProjectTrusted), so the - // digest config is resolved here at exit — config edits made while the - // job ran are picked up, and trust is evaluated against the same - // session that spawned the job. No spawn-time capture needed. When a - // digest is configured, the wake is sent only after this bounded - // attempt (≤ ~5.25s: 5s timeout + 250ms kill grace) completes; a digest - // that fails, times out, or prints - // nothing appends nothing, and the exit code / universal part above are - // never affected. - let digestBlock: { label: string; text: string } | undefined; - try { - // First matching entry wins, in config order. The label defaults to - // the entry's label, the entry's type, a matched `match.name`, then - // the entry's preset id (or "command"). - const digestEntries = resolveConfig(rec.ctx).digest; - const digestTarget: DigestJobTarget = { + updateWidget(ctx); + + const finishSpawnFailure = (err: Error) => { + const rec = jobs.get(id); + if (!rec || rec.exitCode !== undefined) return; + rec.exitedAt = Date.now(); + rec.exitCode = -1; + rec.donePersisted = true; + delete rec.child; + try { + appendFileSync( + rec.logPath, + `\n[pi-bgrun] spawn failed: ${err.message}\n${EXIT_MARKER}-1\n`, + ); + } catch { + // best-effort + } + pi.appendEntry("bgrun-job", { + id, + pid: rec.pid, + cmd: rec.cmd, + name: rec.name, + started: rec.started, + logPath: rec.logPath, + state: "done", + exitCode: -1, + exitedAt: rec.exitedAt, + }); + const namePrefix = rec.name ? `"${rec.name}" ` : ""; + const wake = + `❌ Background job ${namePrefix}\`${id}\` failed to start: ${err.message}\n` + + `Command: ${command}`; + try { + if (rec.ctx.isIdle()) pi.sendUserMessage(wake); + else pi.sendUserMessage(wake, { deliverAs: "followUp" }); + } catch { + try { + pi.sendUserMessage(wake, { deliverAs: "followUp" }); + } catch (e2) { + console.error( + `[pi-bgrun] wake failed for job ${id}:`, + (e2 as Error).message, + ); + } + } + if (rec.ctx.hasUI) { + rec.ctx.ui.notify( + `❌ ${(rec.name ?? command).slice(0, 50)} → spawn failed`, + "error", + ); + } + updateWidget(rec.ctx); + }; + + // ── exit handler: record exit, persist done entry, wake, notify, widget ─ + child.on("exit", async (code, signal) => { + const rec = jobs.get(id); + if (!rec) return; + // A spawn that emitted 'error' first already finalized this job; a + // follow-up 'exit' must not append a second done entry or wake. + if (rec.exitCode !== undefined) return; + rec.exitedAt = Date.now(); + rec.exitCode = code ?? -1; + // Set BEFORE the digest await: without it a read-only bgstatus in + // that window could append a second done entry. + rec.donePersisted = true; + delete rec.child; // release the handle reference + + const exitCode = code ?? parseExitFromLogPath(logPath) ?? -1; + const exitStr = + exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`; + const exitEmoji = exitCode === 0 ? "✅" : "❌"; + const lastLine = readLastLogLine(logPath); + + // Universal stats — duration + log line count. Non-heuristic, always + // present, never pattern-based. A missing log contributes no line + // count (duration is always known). + const logLines = countLogLines(logPath); + const statsParts = [formatDuration(rec.exitedAt - rec.started)]; + if (logLines !== null) + statsParts.push(`${logLines.toLocaleString("en-US")} lines`); + + // Persist the done-state entry. + pi.appendEntry("bgrun-job", { + id, + pid: rec.pid, + cmd: rec.cmd, name: rec.name, type: rec.type, - command: rec.cmd, - }; - const selected = selectDigestEntry(digestEntries, digestTarget); - if (selected) { - const raw = await runDigestCommand(selected.command, logPath); - const text = raw === undefined ? undefined : capDigestOutput(raw); - if (text) digestBlock = { label: selected.label, text }; - } else if (digestEntries?.length) { - // Configured but nothing selected — otherwise silent. Surface the - // job's type/name plus the configured types, once per distinct - // diagnostic (capped), so a type mismatch or dead glob is visible. - const warning = digestNoMatchWarning(digestTarget, digestEntries); - if (!digestNoMatchWarned.has(warning)) { - if (digestNoMatchWarned.size < DIGEST_NO_MATCH_WARN_CAP) { - digestNoMatchWarned.add(warning); - console.error(warning); - } else if (!digestNoMatchSuppressed) { - // Don't silently drop further distinct mismatches. - digestNoMatchSuppressed = true; - console.error( - `[pi-bgrun] further digest no-match diagnostics suppressed (cap ${DIGEST_NO_MATCH_WARN_CAP})`, - ); + started: rec.started, + logPath, + state: "done", + exitCode: exitCode >= 0 ? exitCode : undefined, + exitedAt: rec.exitedAt, + }); + + // Opt-in project-config digest (best-effort, silent-fail). rec.ctx is + // the ExtensionContext captured at tool-call time and retains + // everything resolveConfig needs (cwd + isProjectTrusted), so the + // digest config is resolved here at exit — config edits made while the + // job ran are picked up, and trust is evaluated against the same + // session that spawned the job. No spawn-time capture needed. When a + // digest is configured, the wake is sent only after this bounded + // attempt (≤ ~5.25s: 5s timeout + 250ms kill grace) completes; a digest + // that fails, times out, or prints + // nothing appends nothing, and the exit code / universal part above are + // never affected. + let digestBlock: { label: string; text: string } | undefined; + try { + // First matching entry wins, in config order. The label defaults to + // the entry's label, the entry's type, a matched `match.name`, then + // the entry's preset id (or "command"). + const digestEntries = resolveConfig(rec.ctx).digest; + const digestTarget: DigestJobTarget = { + name: rec.name, + type: rec.type, + command: rec.cmd, + }; + const selected = selectDigestEntry(digestEntries, digestTarget); + if (selected) { + const raw = await runDigestCommand(selected.command, logPath); + const text = raw === undefined ? undefined : capDigestOutput(raw); + if (text) digestBlock = { label: selected.label, text }; + } else if (digestEntries?.length) { + // Configured but nothing selected — otherwise silent. Surface the + // job's type/name plus the configured types, once per distinct + // diagnostic (capped), so a type mismatch or dead glob is visible. + const warning = digestNoMatchWarning(digestTarget, digestEntries); + if (!digestNoMatchWarned.has(warning)) { + if (digestNoMatchWarned.size < DIGEST_NO_MATCH_WARN_CAP) { + digestNoMatchWarned.add(warning); + console.error(warning); + } else if (!digestNoMatchSuppressed) { + // Don't silently drop further distinct mismatches. + digestNoMatchSuppressed = true; + console.error( + `[pi-bgrun] further digest no-match diagnostics suppressed (cap ${DIGEST_NO_MATCH_WARN_CAP})`, + ); + } } } + } catch (e) { + // Silent-fail: a broken digest never breaks a wake (ground rule 3). + console.error( + `[pi-bgrun] digest failed for job ${id}:`, + (e as Error).message, + ); } - } catch (e) { - // Silent-fail: a broken digest never breaks a wake (ground rule 3). - console.error( - `[pi-bgrun] digest failed for job ${id}:`, - (e as Error).message, - ); - } - // Wake the agent. - const namePrefix = rec.name ? `"${rec.name}" ` : ""; - let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`; - wake += `Command: ${command}\n`; - wake += `Stats: ${statsParts.join(", ")}\n`; - if (lastLine) wake += `Last output: ${lastLine}\n`; - if (digestBlock) { - wake += `digest (${digestBlock.label}): ${digestBlock.text}\n`; - } - wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`; - try { - if (rec.ctx.isIdle()) { - pi.sendUserMessage(wake); - } else { - pi.sendUserMessage(wake, { deliverAs: "followUp" }); + // Wake the agent. + const namePrefix = rec.name ? `"${rec.name}" ` : ""; + let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`; + wake += `Command: ${command}\n`; + wake += `Stats: ${statsParts.join(", ")}\n`; + if (lastLine) wake += `Last output: ${lastLine}\n`; + if (digestBlock) { + wake += `digest (${digestBlock.label}): ${digestBlock.text}\n`; } - } catch { + wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`; try { - pi.sendUserMessage(wake, { deliverAs: "followUp" }); - } catch (e2) { - console.error( - `[pi-bgrun] wake failed for job ${id}:`, - (e2 as Error).message, - ); + if (rec.ctx.isIdle()) { + pi.sendUserMessage(wake); + } else { + pi.sendUserMessage(wake, { deliverAs: "followUp" }); + } + } catch { + try { + pi.sendUserMessage(wake, { deliverAs: "followUp" }); + } catch (e2) { + console.error( + `[pi-bgrun] wake failed for job ${id}:`, + (e2 as Error).message, + ); + } } - } - // Toast for the human. - if (rec.ctx.hasUI) { - const toastLabel = (rec.name ?? command).slice(0, 50); - rec.ctx.ui.notify( - `${exitEmoji} ${toastLabel} → exit ${exitStr}`, - exitCode === 0 ? "info" : "error", - ); - } + // Toast for the human. + if (rec.ctx.hasUI) { + const toastLabel = (rec.name ?? command).slice(0, 50); + rec.ctx.ui.notify( + `${exitEmoji} ${toastLabel} → exit ${exitStr}`, + exitCode === 0 ? "info" : "error", + ); + } - // Update/clear the widget. - updateWidget(rec.ctx); - }); + // Update/clear the widget. + updateWidget(rec.ctx); + }); - child.on("error", (err) => { - console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message); - jobs.delete(id); - updateWidget(ctx); - }); + child.on("error", (err) => { + console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message); + finishSpawnFailure(err); + }); - const startedLines = [`started: ${id}`]; - if (name) startedLines.push(` name: ${name}`); - if (type) startedLines.push(` type: ${type}`); - startedLines.push( - ` log: ${logPath}`, - ` You'll be woken automatically when it finishes.`, - ); - return { - content: [{ type: "text", text: startedLines.join("\n") }], - details: { id, name, type, logPath, pid: childPid }, - }; + const startedLines = [`started: ${id}`]; + if (name) startedLines.push(` name: ${name}`); + if (type) startedLines.push(` type: ${type}`); + startedLines.push( + ` log: ${logPath}`, + ` You'll be woken automatically when it finishes.`, + ); + return { + content: [{ type: "text", text: startedLines.join("\n") }], + details: { id, name, type, logPath, pid: childPid }, + }; + } finally { + if (logFd !== undefined) closeSync(logFd); + } }, }); @@ -1793,6 +2257,32 @@ export default function (pi: ExtensionAPI) { { lines: number; bytes: number; first: string } >(); + // Resolve a job's log path and read its bounded slice, single-sourcing the + // "in-memory record first, then the configured jobs dir" rule shared by + // bgtail and bggrep. The record's logPath stays correct even if the config + // (and thus the resolved jobs dir) changes mid-session. On failure the caller + // renders tool-specific error details. + function resolveLogForJob( + id: string, + tool: string, + ctx?: ExtensionContext, + ): + | { logPath: string; content: string; size: number } + | { logPath: string; errorText: string; notFound: boolean } { + validateJobId(id, tool); + const logPath = + jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`); + const slice = readLogSlice(logPath, LOG_READ_BYTES); + if (!slice) { + return { + logPath, + errorText: logReadError(id, logPath), + notFound: !existsSync(logPath), + }; + } + return { logPath, content: slice.content, size: slice.size }; + } + // Shared by the bgtail tool (agent-facing) and the /bgtail slash command // (human-facing). async function bgtailCore( @@ -1808,108 +2298,104 @@ export default function (pi: ExtensionAPI) { // tool schema, and lines < 1 would corrupt slicing (slice(-0) = whole log). const lines = Math.max(1, Math.floor(linesParam)); if (!id) throw new Error("bgtail: id is required"); - // Prefer this session's record: its logPath stays correct even if the - // config (and thus the resolved jobs dir) changes mid-session — e.g. a - // user switching to project-local logs right after upgrading. - const logPath = - jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`); - try { - const content = readFileSync(logPath, "utf8"); - // Content lines only: the exit marker and blanks are filtered BEFORE the - // window is sliced, so "last N lines" means the last N content lines - // (matching pre-delta behavior) and bookmarks count content lines. - // /\r?\n/ keeps CRLF logs from leaving a stray \r on every line. - const rawLines = content - .split(/\r?\n/) - .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0); - const total = rawLines.length; - const first = rawLines[0]?.slice(0, 200) ?? ""; - const prev = tailBookmarks.get(id); - // Append-only logs never mutate earlier lines, so a changed first - // content line means the log was replaced or rotated — reset to a full - // tail. Catches same-size replacements the shrink checks cannot see. - // (A previously-empty log growing content is growth, not replacement.) - const replaced = - prev !== undefined && prev.lines > 0 && prev.first !== first; - const shrank = - prev !== undefined && - (prev.lines > total || prev.bytes > content.length); - let window: string[]; - let header: string | undefined; - let newLines: number | undefined; - if (raw || prev === undefined || shrank || replaced) { - // Full tail: first read, raw mode, or a shrunken/replaced log (reset). - window = rawLines.slice(-lines); - if (!raw && (shrank || replaced)) { - header = shrank - ? "log shrank since last read — showing full tail" - : "log was replaced since last read — showing full tail"; - } - } else { - const fresh = rawLines.slice(prev.lines); - newLines = fresh.length; - if (fresh.length === 0) { - tailBookmarks.set(id, { - lines: total, - bytes: content.length, - first, - }); - return { - content: [ - { - type: "text", - text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})`, - }, - ], - details: { - id, - linesShown: 0, - logPath, - notFound: false, - condensed: true, - newLines: 0, - totalLines: total, - }, - }; - } - window = fresh.length > lines ? fresh.slice(-lines) : fresh; - header = - `+${fresh.length} new line${fresh.length === 1 ? "" : "s"} since last read — ` + - `log at ${total} lines${fresh.length > lines ? ` (showing last ${lines})` : ""}`; - } - tailBookmarks.set(id, { - lines: total, - bytes: content.length, - first, - }); - const shown = window; - const { text, truncated } = condenseLogLines(shown, { raw }); - // Delta reads early-return above, so an empty window here can only be - // a first read of an empty log (full-tail path). - const body = shown.length === 0 ? "(empty log)" : text; - const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : ""; - const head = header ? `${header}\n` : ""; + const resolved = resolveLogForJob(id, "bgtail", ctx); + if ("errorText" in resolved) { return { - content: [{ type: "text", text: head + body + notes }], + content: [{ type: "text", text: resolved.errorText }], details: { id, - linesShown: shown.length, - logPath, - notFound: false, - condensed: !raw, - ...(newLines === undefined ? {} : { newLines, totalLines: total }), - ...(truncated.length > 0 ? { condenserNotes: truncated } : {}), + logPath: resolved.logPath, + notFound: resolved.notFound, }, - }; - } catch { - return { - content: [ - { type: "text", text: `No log found for job ${id} at ${logPath}` }, - ], - details: { id, linesShown: 0, logPath, notFound: true }, isError: true, }; } + const { logPath, content, size } = resolved; + // Content lines only: the exit marker and blanks are filtered BEFORE the + // window is sliced, so "last N lines" means the last N content lines + // (matching pre-delta behavior) and bookmarks count content lines. + // /\r?\n/ keeps CRLF logs from leaving a stray \r on every line. + const rawLines = content + .split(/\r?\n/) + .filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0); + const total = rawLines.length; + const first = rawLines[0]?.slice(0, 200) ?? ""; + const prev = tailBookmarks.get(id); + // Append-only logs never mutate earlier lines, so a changed first + // content line means the log was replaced or rotated — reset to a full + // tail. Catches same-size replacements the shrink checks cannot see. + // (A previously-empty log growing content is growth, not replacement.) + const replaced = + prev !== undefined && prev.lines > 0 && prev.first !== first; + const shrank = + prev !== undefined && (prev.lines > total || prev.bytes > size); + let window: string[]; + let header: string | undefined; + let newLines: number | undefined; + if (raw || prev === undefined || shrank || replaced) { + // Full tail: first read, raw mode, or a shrunken/replaced log (reset). + window = rawLines.slice(-lines); + if (!raw && (shrank || replaced)) { + header = shrank + ? "log shrank since last read — showing full tail" + : "log was replaced since last read — showing full tail"; + } + } else { + const fresh = rawLines.slice(prev.lines); + newLines = fresh.length; + if (fresh.length === 0) { + tailBookmarks.set(id, { + lines: total, + bytes: size, + first, + }); + return { + content: [ + { + type: "text", + text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})`, + }, + ], + details: { + id, + linesShown: 0, + logPath, + notFound: false, + condensed: true, + newLines: 0, + totalLines: total, + }, + }; + } + window = fresh.length > lines ? fresh.slice(-lines) : fresh; + header = + `+${fresh.length} new line${fresh.length === 1 ? "" : "s"} since last read — ` + + `log at ${total} lines${fresh.length > lines ? ` (showing last ${lines})` : ""}`; + } + tailBookmarks.set(id, { + lines: total, + bytes: size, + first, + }); + const shown = window; + const { text, truncated } = condenseLogLines(shown, { raw }); + // Delta reads early-return above, so an empty window here can only be + // a first read of an empty log (full-tail path). + const body = shown.length === 0 ? "(empty log)" : text; + const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : ""; + const head = header ? `${header}\n` : ""; + return { + content: [{ type: "text", text: head + body + notes }], + details: { + id, + linesShown: shown.length, + logPath, + notFound: false, + condensed: !raw, + ...(newLines === undefined ? {} : { newLines, totalLines: total }), + ...(truncated.length > 0 ? { condenserNotes: truncated } : {}), + }, + }; } pi.registerTool({ @@ -1964,40 +2450,76 @@ export default function (pi: ExtensionAPI) { // themselves from the context windows (lo > hi no-ops the inner loop). const context = Math.max(0, Math.floor(contextParam)); if (!id) throw new Error("bggrep: id is required"); - // Record-first, same as bgtail — correct across config changes. - const logPath = - jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`); + // resolveLogForJob() below validates the id; no need to double-check. const source = pattern ?? DEFAULT_GREP_PATTERN; - let re: RegExp; try { - re = new RegExp(source); + // Validate up front so a bad pattern fails immediately, without a worker. + void new RegExp(source); } catch (err) { throw new Error( `bggrep: invalid pattern ${JSON.stringify(source)}: ${(err as Error).message}`, ); } - let rawLines: string[]; - try { - const content = readFileSync(logPath, "utf8"); - // /\r?\n/ normalizes CRLF (a trailing \r would break $-anchored patterns - // and leak into output); blank lines are KEPT so L numbers match the - // file. A trailing empty split element is dropped; "" yields zero lines. - const split = content === "" ? [] : content.split(/\r?\n/); - if (split.length > 0 && split[split.length - 1] === "") split.pop(); - rawLines = split.filter((l) => !l.startsWith(EXIT_MARKER)); - } catch { + // Record-first, same as bgtail — correct across config changes. + const resolved = resolveLogForJob(id, "bggrep", ctx); + if ("errorText" in resolved) { + return { + content: [{ type: "text", text: resolved.errorText }], + details: { + id, + matches: 0, + logPath: resolved.logPath, + notFound: resolved.notFound, + }, + isError: true, + }; + } + const { logPath, content } = resolved; + // /\r?\n/ normalizes CRLF (a trailing \r would break $-anchored patterns + // and leak into output); blank lines are KEPT so L numbers match the + // file. A trailing empty split element is dropped; "" yields zero lines. + const split = content === "" ? [] : content.split(/\r?\n/); + if (split.length > 0 && split[split.length - 1] === "") split.pop(); + const rawLines = split.filter((l) => !l.startsWith(EXIT_MARKER)); + // Match under a wall-clock budget in a worker: a caller-supplied regex can + // backtrack catastrophically and would otherwise hang the main thread with + // no way to interrupt it. + const budgetMs = bggrepTimeoutMs(); + const outcome = await matchLinesWithBudget( + source, + rawLines, + BGGREP_LINE_CAP, + budgetMs, + ); + if (outcome.kind === "invalid") { + throw new Error( + `bggrep: invalid pattern ${JSON.stringify(source)}: ${outcome.message}`, + ); + } + if (outcome.kind === "timeout") { return { content: [ - { type: "text", text: `No log found for job ${id} at ${logPath}` }, + { + type: "text", + text: + `bggrep: /${source}/ exceeded the ${budgetMs}ms match budget across ` + + `${rawLines.length} line${rawLines.length === 1 ? "" : "s"} — likely ` + + `catastrophic backtracking; no results computed.`, + }, ], - details: { id, matches: 0, logPath, notFound: true }, + details: { + id, + matches: 0, + linesSearched: rawLines.length, + logPath, + notFound: false, + pattern: source, + timedOut: true, + }, isError: true, }; } - const matchIdx: number[] = []; - for (let i = 0; i < rawLines.length; i++) { - if (re.test(rawLines[i])) matchIdx.push(i); - } + const matchIdx = outcome.matchIdx; const header = `${matchIdx.length} match${matchIdx.length === 1 ? "" : "es"} for /${source}/ ` + `in ${rawLines.length} line${rawLines.length === 1 ? "" : "s"}`; @@ -2056,7 +2578,7 @@ export default function (pi: ExtensionAPI) { name: "bggrep", label: "Grep Background Log", description: - "Search a background job's log with a regex; returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. Runs inside the extension, so it reaches the configured jobs dir (including a global one) that project-sandboxed tools (ctx_execute_file) cannot reach. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).", + "Search the last 2 MB of a background job's log with a regex (each line is pre-truncated to 10k chars before matching); returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. Matching runs under a wall-clock budget (default 2s, PI_BGRUN_GREP_TIMEOUT_MS), so a runaway regex fails instead of hanging. Runs inside the extension, so it reaches the configured jobs dir (including a global one) that project-sandboxed tools (ctx_execute_file) cannot reach. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).", promptSnippet: "Search a bgrun job's log for a pattern", promptGuidelines: [ "Never search a bgrun log with the bash tool — uncapped output can flood context, and it needs manual log-path reconstruction and regex shell-quoting; bggrep is bounded by design.", @@ -2102,11 +2624,26 @@ export default function (pi: ExtensionAPI) { const cfg = resolveConfig(ctx); const jobsDir = cfg.jobsDir; if (id) { + validateJobId(id, "bgstatus"); const rec = jobs.get(id); if (rec) { - const state = rec.exitCode === undefined ? "running" : "done"; - const exit = rec.exitCode === undefined ? "" : ` exit=${rec.exitCode}`; - const lines = [`${id}: ${state}${exit}`]; + // Read-only reconciliation: an in-memory record whose exit event never + // fired (or one reconstructed on restart) can lag its log. Derive the + // real state from the marker / pid liveness WITHOUT mutating or + // persisting — the list path and the 30s poller own persistence. This + // keeps by-id and list from disagreeing for up to a poll interval. + let exit = rec.exitCode; + if (exit === undefined && !rec.child) { + // No live handle (reconstructed/adopted) — derive from the log or a + // dead pid. A live child is authoritative: a running job whose own + // output contains a spurious __BGRUN_EXIT__ line must not read done. + const fromLog = parseExitFromLogPath(rec.logPath); + if (fromLog !== null) exit = fromLog; + else if (rec.pid <= 0 || !isRunningPid(rec.pid)) exit = -1; + } + const state = exit === undefined ? "running" : "done"; + const exitStr = exit === undefined ? "" : ` exit=${exit}`; + const lines = [`${id}: ${state}${exitStr}`]; if (rec.name) lines.push(` name: ${rec.name}`); if (rec.type) lines.push(` type: ${rec.type}`); lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`); @@ -2115,7 +2652,7 @@ export default function (pi: ExtensionAPI) { details: { id, state, - exitCode: rec.exitCode ?? undefined, + exitCode: exit ?? undefined, cmd: rec.cmd, name: rec.name, type: rec.type, @@ -2124,30 +2661,33 @@ export default function (pi: ExtensionAPI) { }; } const logPath = join(jobsDir, `${id}.log`); - try { - const exit = parseExitFromLog(logPath); - const state = exit === null ? "running" : "done"; - return { - content: [ - { - type: "text", - text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`, - }, - ], - details: { - id, - state, - exitCode: exit ?? undefined, - recovered: true, - }, - }; - } catch { + if (!existsSync(logPath)) { return { content: [{ type: "text", text: `No job found with id ${id}` }], details: { id, state: "unknown" }, isError: true, }; } + let exit = parseExitFromLogPath(logPath); + if (exit === null) { + const pid = pidFromId(id); + if (pid !== null && (pid <= 0 || !isRunningPid(pid))) exit = -1; + } + const state = exit === null ? "running" : "done"; + return { + content: [ + { + type: "text", + text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`, + }, + ], + details: { + id, + state, + exitCode: exit ?? undefined, + recovered: true, + }, + }; } // List: this session's jobs (running by default; finished only when // includeDone / showCompletedJobs is set). Other sessions' RUNNING jobs @@ -2155,8 +2695,10 @@ export default function (pi: ExtensionAPI) { // from the shared dir can also appear when finished jobs are included. // Hidden disk logs get a one-line count instead of spamming the listing. const showDone = params.includeDone ?? cfg.showCompletedJobs; - revalidateStaleJobs(); - updateWidget(ctx); + // Read-only: asking for status must not append transcript cards. The stale + // poller (when a job is unsupervised) persists independently. + revalidateStaleJobs({ persist: false }); + updateWidget(ctx, { persistRevalidate: false }); const lines: string[] = []; const seen = new Set(); for (const [jid, rec] of jobs) { @@ -2170,30 +2712,33 @@ export default function (pi: ExtensionAPI) { } } let hiddenOnDisk = 0; - try { - for (const name of readdirSync(jobsDir)) { - if (!name.endsWith(".log")) continue; - const jid = name.slice(0, -".log".length); - if (seen.has(jid)) continue; - const logPath = join(jobsDir, name); - const exit = parseExitFromLog(logPath); - if (exit !== null) { + if (!showDone && !cfg.adoptForeignJobs) { + // Default listing: every on-disk log is just a hidden count. Skip the + // exit-marker parse (a 256 KB tail read per file) — the cheap scan's + // names are all we need. + for (const entry of scanLogFiles(jobsDir)) { + if (!seen.has(entry.id)) hiddenOnDisk++; + } + } else { + for (const entry of scanJobsDir(jobsDir)) { + if (seen.has(entry.id)) continue; + if (entry.exit !== null) { // finished log on disk (other or older session) if (showDone) { - lines.push(` ${jid}: done exit=${exit} (from log)`); + lines.push(` ${entry.id}: done exit=${entry.exit} (from log)`); } else { hiddenOnDisk++; } - } else if (cfg.adoptForeignJobs) { - // running foreign job — only surfaced when adoption is enabled - lines.push(` ${jid}: running (from log)`); + } else if (cfg.adoptForeignJobs && entry.alive) { + lines.push(` ${entry.id}: running (from log)`); } else { hiddenOnDisk++; } } - } catch { - // jobs dir doesn't exist — nothing to scan. } + // Count jobs, not display lines: capture before appending the "(N more…)" + // footer, which is a note rather than a job. + const jobCount = lines.length; if (hiddenOnDisk > 0) { lines.push( ` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean all to prune)`, @@ -2207,7 +2752,7 @@ export default function (pi: ExtensionAPI) { } return { content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }], - details: { count: lines.length }, + details: { count: jobCount }, }; } @@ -2238,8 +2783,6 @@ export default function (pi: ExtensionAPI) { // ── bgclean: remove old job logs ─────────────────────────────────────────── - // ── bgclean: remove old job logs ────────────────────────────────────── - // Shared by the bgclean tool (agent-facing) and the /bgclean slash command // (human-facing). async function bgcleanCore( @@ -2260,7 +2803,9 @@ export default function (pi: ExtensionAPI) { // "all" spans every shared jobs dir — the current project's plus the // machine-global default (so pre-project-local logs are still reachable). for (const dir of sharedJobsDirs(cfg.jobsDir, cfg.jobsDirProjectLocal)) { - const r = cleanOldJobs(days, dir, ctx); + // An explicit `bgclean all` is the user's direct intent — bypass the + // ownership marker gate so it always works. + const r = cleanOldJobs(days, dir, ctx, { requireOwnership: false }); result.removed += r.removed; result.kept += r.kept; result.skippedRunning += r.skippedRunning; diff --git a/package.json b/package.json index 095b381..41c392f 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.5.0", "description": "Run long shell commands detached in the background for pi; get woken on completion. Output lands in a file; context stays clean.", "type": "module", + "engines": { + "node": ">=20" + }, "license": "MIT", "publishConfig": { "access": "public" @@ -25,6 +28,7 @@ }, "files": [ "extension/", + "!extension/index.test.ts", "skill/", "README.md", "LICENSE" @@ -57,6 +61,7 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*", + "@types/node": "^24.0.0", "typebox": "^1.3.0", "typescript": "^5.7.0" } diff --git a/scripts/bootstrap-publish-v0.1.0.sh b/scripts/bootstrap-publish-v0.1.0.sh index 1a29fd1..5092346 100755 --- a/scripts/bootstrap-publish-v0.1.0.sh +++ b/scripts/bootstrap-publish-v0.1.0.sh @@ -5,7 +5,8 @@ # 3. restore package.json and verify both on the registry # # Run from your own terminal: each publish triggers the browser passkey challenge. -# Safe to re-run: already-published versions fail fast without side effects, +# Safe to re-run: each publish is skipped if that name/version is already on the +# registry (handles a partial publish where E409 aborted before the alias step), # and package.json is always restored via trap. set -euo pipefail cd "$(dirname "$0")/.." @@ -14,18 +15,32 @@ PKG_JSON=package.json BACKUP=package.json.bootstrap-backup # --- preconditions ----------------------------------------------------------- -git diff --quiet || { echo "❌ working tree dirty — commit first"; exit 1; } +# --porcelain sees staged AND unstaged changes; `git diff --quiet` alone misses +# staged work, which would let a half-committed state publish. +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "❌ working tree dirty (staged or unstaged) — commit first" + exit 1 +fi cp "$PKG_JSON" "$BACKUP" restore() { mv -f "$BACKUP" "$PKG_JSON"; } trap restore EXIT VERSION=$(node -p "require('./$PKG_JSON').version") -echo "▶ Publishing pi-background-run@${VERSION} (primary)…" -npm publish --access public -echo "▶ Publishing @stablekernel/pi-background-run@${VERSION} (alias)…" -node -e "const p=require('./$PKG_JSON'); p.name='@stablekernel/pi-background-run'; require('fs').writeFileSync('$PKG_JSON', JSON.stringify(p,null,2)+'\n')" -npm publish --access public +if npm view "pi-background-run@${VERSION}" version >/dev/null 2>&1; then + echo "⏭ pi-background-run@${VERSION} already published — skipping" +else + echo "▶ Publishing pi-background-run@${VERSION} (primary)…" + npm publish --access public +fi + +if npm view "@stablekernel/pi-background-run@${VERSION}" version >/dev/null 2>&1; then + echo "⏭ @stablekernel/pi-background-run@${VERSION} already published — skipping" +else + echo "▶ Publishing @stablekernel/pi-background-run@${VERSION} (alias)…" + node -e "const p=require('./$PKG_JSON'); p.name='@stablekernel/pi-background-run'; require('fs').writeFileSync('$PKG_JSON', JSON.stringify(p,null,2)+'\n')" + npm publish --access public +fi restore trap - EXIT diff --git a/skill/run-bg/SKILL.md b/skill/run-bg/SKILL.md index 6e72caa..074e920 100644 --- a/skill/run-bg/SKILL.md +++ b/skill/run-bg/SKILL.md @@ -50,7 +50,7 @@ no polling. - `done exit=` → failure; analyze the log. - `running` but the job should have finished long ago → likely crashed (the process died without writing the exit marker). Analyze the log with - `ctx_execute_file`. + `bggrep` (any jobs dir) or `ctx_execute_file` (project-local logs only). ### Reading results without flooding context @@ -63,7 +63,10 @@ positional-peek tool for everything else. - **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker. The first read returns the last-40 tail; repeat reads return only lines appended since your last read (delta tailing) — polling a running job is nearly free. - **Failure extraction:** `bggrep(, "pattern")` — line-numbered matches with optional context lines, capped and condensed. Reaches the configured jobs dir (including a global one) that project-sandboxed `ctx_execute_file` cannot (it runs inside the extension). Pass your own pattern whenever you know the tool's output format; the default only catches common failure signatures. -- **Whole-log failure analysis:** `ctx_execute_file` on the log path: +- **Whole-log failure analysis:** `ctx_execute_file` on the log path — **project-local + `jobsDir` only** (e.g. `.pi-bgrun/jobs` in `pi-bgrun.json`). The default global + dir (`~/.pi-bgrun/jobs`) is outside the project sandbox; use `bggrep` there instead. + Copy the `log:` path from `bgrun`'s `started:` line (do not use `~` — it may not expand). ```javascript ctx_execute_file( @@ -73,7 +76,7 @@ positional-peek tool for everything else. const fails=L.filter(l=>/(--- FAIL|FAIL|panic:|Error:)/.test(l)); \ console.log(`lines: ${L.length}, failures: ${fails.length}`); \ console.log(fails.slice(0,40).join('\\n'));" - ) + }) ``` A 10 000-line `make test` log collapses to a ~30-line summary in context. @@ -83,7 +86,9 @@ positional-peek tool for everything else. - `bash grep` output is uncapped — a retry-storm log can dump thousands of matching lines (megabytes) straight into context, and staying safe depends on remembering `| head` on every single call. `bggrep` is bounded by design - (~50 matches, ~2KB/line, ~8KB). + (last 2 MB of the log, per-line 10 000-char pre-truncation, ~50 matches, + ~8KB, plus a wall-clock match budget so a runaway regex errors instead of + hanging). - It takes the job id — no log-path reconstruction, no shell-quoting of the regex — and reaches the configured jobs dir (including a global one) that project-sandboxed `ctx_execute_file` cannot. @@ -93,7 +98,7 @@ positional-peek tool for everything else. Plain `grep` via bash is fine only for a one-off search you know is tiny. **Never `cat`, `Read`, `bash cat`, or `bash grep` a full bgrun log.** Always -`bgtail`, `bggrep`, or `ctx_execute_file`. +`bgtail`, `bggrep`, or (for project-local logs) `ctx_execute_file`. ## After a pi restart or session switch @@ -102,10 +107,10 @@ Plain `grep` via bash is fine only for a one-off search you know is tiny. - After a restart/switch, run `bgstatus()` — the id still resolves via the log's `__BGRUN_EXIT__=N` marker. To browse everything on disk, use `bgstatus(includeDone: true)`. -- Each session only tracks its own jobs by default. Other sessions' *running* - jobs appear only when `adoptForeignJobs` is enabled in +- Each session only tracks its own jobs by default. Running jobs from other + sessions appear only when `adoptForeignJobs` is enabled in `~/.pi/agent/pi-bgrun.json` (or `PI_BGRUN_FOREIGN_JOBS=1`); finished foreign - logs from the shared dir can also appear when finished jobs are included. + logs appear with `bgstatus(includeDone: true)` regardless. ## Rules @@ -124,5 +129,4 @@ Plain `grep` via bash is fine only for a one-off search you know is tiny. pass covers the current project's jobs dir AND the machine-global `~/.pi-bgrun/jobs`; an explicit absolute `jobsDir` is swept alone. Retention is `cleanupDays` (default 7, configurable). -- To stop a running job, use `bash` with `kill ` (the pid is in the `bgstatus` - output). There is no `bgkill` tool. +- To stop a running job, use `bash` with `kill -- -` (process group — required because the child is spawned detached). The pid is the last `--`-separated segment of the job id; it is not shown as a separate field in `bgstatus` output. There is no `bgkill` tool.