From fe4f7b5248987e91d248fe1691e9b16e19def84b Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Mon, 21 Sep 2026 10:36:33 -0400 Subject: [PATCH 01/12] feat: run on oh-my-pi alongside pi, with a host-neutral job panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension loads and works on both harnesses from one build: `pi` keeps its existing behavior, and `oh-my-pi` (omp) gets an equivalent surface where the host differs. Load blocker: `pi.registerEntryRenderer` is pi-only. omp has no entry-renderer concept at all (it renders `custom_message` entries, never the `type: "custom"` records `pi.appendEntry` writes), and the missing method aborted the whole extension load. Registration is now capability-probed; the `bgrun-job` records are still persisted and replayed identically on both hosts. Host differences handled: - Tool presentation: omp unmounts any tool that omits `loadMode: "essential"` and re-exposes it as an `xd://` device. All five tools declare it, spread in rather than written literally (a literal trips pi's excess-property check). - Tool guidance: omp ignores `promptSnippet`/`promptGuidelines`, so the same bullets ride in `description` — emitted once per host, never duplicated. Host identity is probed on two independent signals (CONFIG_DIR_NAME, already load-bearing for config paths, plus the omp-only `registerComposerShape`), because mis-detecting omp as pi would drop the guidance silently. - User config: resolved through the host agent dir (`~/.omp/agent`, profile-aware, via `getAgentDir()`) instead of a hardcoded `~/.pi/agent`, and a failure to resolve it is logged rather than silently falling back to the default profile's directory. - Background work: the stale-job poller uses `ctx.setInterval`/`clearTimer` when the host has them (omp treats an uncaught throw from a raw timer as process-fatal and tears the session down) and a wrapped, unref'd raw interval otherwise, since pi's ExtensionContext has neither. - Diagnostics: routed to `pi.logger` when present (omp's TUI owns the terminal) and to the console on pi, through one module-level sink that also covers the config helpers that run outside the factory. Job panel + status line (both hosts, zero context): the editor panel shows running jobs plus the most recently finished ones while anything is running, self-limited to the 10 lines a `string[]` widget is capped at by BOTH hosts so neither appends its own truncation note; the status line holds `N running` while in flight and the latest outcome once idle. Together they carry what the pi-only transcript card showed, which omp cannot render. Digest diagnostics: a `type` that selects no scorecard now reaches the agent on the wake itself (once per distinct mismatch, inside the existing cap) instead of only the host log, which omp writes to a file the agent never reads. The log line and the wake line are built from shared facts so they cannot drift. Skills: `skill/` renamed to `skills/`, which is the directory omp's plugin provider scans (a manifest `skills` key is ignored there); `pi.skills` repointed so pi keeps loading them, and an `omp` manifest entry added alongside `pi`. Known limit, documented rather than hidden: a job started inside a task/subagent session wakes that child, which has usually already returned. omp's own async job machinery would fix it but is not reachable from an extension (ctx exposes only a read-only snapshot; the registering manager lives on the internal ToolSession; `ctx.invokeTool` is same-name only). The log still lands in the shared jobs dir, so `bgstatus`/`bgtail` recover it from any session. Verified: 200 tests pass, tsc clean, and the extension was driven under omp 18.2.6 in both print mode and a live TUI (panel, status line, toast, wake from the detached exit handler, appendEntry persistence, skill discovery, logger routing). --- README.md | 133 +++++-- docs/dogfooding.md | 10 +- docs/log-size-ceiling.md | 11 +- extension/digestPresets.ts | 43 ++- extension/index.test.ts | 307 ++++++++++++++- extension/index.ts | 463 ++++++++++++++++++----- package.json | 15 +- {skill => skills}/digest-config/SKILL.md | 10 +- {skill => skills}/run-bg/SKILL.md | 26 +- 9 files changed, 859 insertions(+), 159 deletions(-) rename {skill => skills}/digest-config/SKILL.md (94%) rename {skill => skills}/run-bg/SKILL.md (89%) diff --git a/README.md b/README.md index e27e8c9..b659b03 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,68 @@ # pi-background-run Run long shell commands (test suites, builds, linters) as detached background jobs -so your pi agent session stays unblocked and its context stays clean. Output lands +so your agent session stays unblocked and its context stays clean. Output lands on disk — the full log plus a trailing exit marker — so nothing large ever enters the conversation; the command returns immediately. When the job finishes, pi-background-run **wakes the live agent session** so it proactively reads a condensed digest of the results and continues — no polling, no human intervention. -Built as a [pi](https://github.com/earendil-works/pi-coding-agent) extension. No +Built as an extension for both [pi](https://github.com/earendil-works/pi-coding-agent) +and [oh-my-pi](https://github.com/can1357/oh-my-pi) (`omp`). No shell runner and no external daemon — the extension spawns the job in-process, detects completion via the child `exit` event, and calls `pi.sendUserMessage` to wake the agent. The log file is self-describing (full output + a trailing -`__BGRUN_EXIT__=N` marker), so exit codes survive 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 +`__BGRUN_EXIT__=N` marker), so exit codes survive the agent restarting. Two small +pieces exist beyond the spawn: a 30s timer that only re-checks jobs whose live child +handle is gone (reconstructed from a restart, or adopted from another session), and a `.last-clean` marker that throttles the **global** orphan sweep (the session-scoped sweep is unthrottled). ## Install ```bash -pi install npm:pi-background-run +pi install npm:pi-background-run # pi +omp plugin install npm:pi-background-run # oh-my-pi ``` 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. +Restart the agent after install so the extension loads. + +### Host differences + +One extension serves both hosts. Paths in this README are written against two +host-supplied names: + +| Name | pi | oh-my-pi | +| --- | --- | --- | +| `$AGENT_DIR` — the user-level agent directory | `~/.pi/agent` | `~/.omp/agent` (profile-aware) | +| `$CONFIG_DIR` — the project config directory | `.pi` | `.omp` | + +The host also decides two presentation details, both handled internally: + +- **Tool visibility.** `omp` mounts any tool that does not opt out as an + `xd://` device (`write xd://bgrun {…}`); bgrun declares `loadMode: "essential"` + so all five tools stay directly callable, exactly as on pi. +- **Tool guidance.** pi reads the tool definition's `promptSnippet` / + `promptGuidelines` into the system prompt; omp reads only `description`, so on + omp the same bullets are folded into the description instead of being dropped. +- **Job cards.** pi renders an `bgrun-job` card per job in the transcript. omp has + no entry renderer (`pi.appendEntry` records are never rendered — it renders only + `pi.sendMessage` entries), so on omp the job card is skipped. The entries are + still persisted on both hosts, and `session_start` replays them identically. + What the card carries — what ran, in order, and how it ended — is covered on + both hosts by the editor panel and the status line (see + [What the human sees](#what-the-human-sees)), which cost no context and need no + entry renderer. omp's transcript still anchors each job through the `bgrun` + tool card and the wake message. +- **Diagnostics.** Warnings and errors go to `pi.logger` when the host has one + (omp writes `~/.omp/logs/omp...log`; the TUI owns the terminal, so + a raw stderr write would corrupt it) and to the console on pi. Anything the + *agent* may need to act on is not left there: the digest type-mismatch note + rides the wake instead (see [Multiple scorecards](#multiple-scorecards-one-per-job-type)). ## Tools registered @@ -60,6 +95,17 @@ wake messages) is the agent's workflow. - Deprecated: the machine-global jobs dir (`PI_BGRUN_GLOBAL_DIR`, `~/.pi-bgrun/jobs`) — see [deprecation](#deprecated-machine-global-jobs-dir). Supported until a future major. - `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. +- **A job started inside a subagent** (`task`/`eval` child) wakes *that* child, not + the parent. Its completion wake is addressed to the session that spawned it, so + a long job outliving the child's run wakes nobody. The **log is not lost**: it + lands in the shared jobs dir, so `bgstatus ` / `bgtail ` read it from + any session in the project. Start long jobs from the main session (`bgrun` is + available to subagents on both hosts; this is about where the wake lands). + oh-my-pi's own async machinery would fix it, but it is not reachable from an + extension: `ctx` exposes only a read-only `getAsyncJobSnapshot()`, the + registering `AsyncJobManager` lives on the internal `ToolSession`, and the + built-in `bash` background mode that does register is not delegatable + (`ctx.invokeTool` is same-name only). ## How it works @@ -79,14 +125,33 @@ child 'exit' event fires: → pi.sendUserMessage(wake) when idle (triggers a turn) or pi.sendUserMessage(wake, { deliverAs: 'followUp' }) when busy → ctx.ui.notify(...) — toast for the human - → ctx.ui.setWidget("bgrun", ...) — updates/clears the live status widget + → ctx.ui.setWidget("bgrun", ...) — live panel: running jobs + recent finishes + → ctx.ui.setStatus("bgrun", ...) — one-line outcome that outlives the panel ``` -The child writes the log directly via its own stdout fd (no pipe to pi), so the job -survives pi crashing and the log completes on disk. The trailing +The child writes the log directly via its own stdout fd (no pipe to the agent), so the job +survives the agent crashing and the log completes on disk. The trailing `__BGRUN_EXIT__=N` marker makes the log self-describing — `bgstatus` recovers the exit code even after a restart. +### What the human sees + +Both hosts get the same two surfaces, so a job's progress and outcome are +visible without the conversation having to carry them: + +- **Editor panel** (only while something is running — it never takes editor + space when idle): a header with the running count, a row per running job + (full id, label, elapsed), and a `── recent ──` section with the most recently + finished jobs and their ✅/❌ exit. Bounded to the 10 lines both hosts cap a + widget at, and dropped whole rather than truncated, so no host ever cuts it. +- **Status line** (always visible): `⏳ N running` while jobs are in flight, + then `✅ exit=0` for the most recent finish, cleared when the session + has neither. + +On pi a `bgrun-job` card is also drawn in the transcript (an entry renderer); +oh-my-pi has no such hook, which is why the panel and the status line carry +that role there. See [Host differences](#host-differences). + ## Reading results without flooding context Two-tier read model — the log file itself stays on disk, capped (see @@ -146,7 +211,7 @@ default **64 MiB**, `0` = unlimited): the readers that depend on the exit marker staying last. A job past 64 MiB is almost always a runaway, so the head is the useful part. - The ceiling lives **inside the detached process tree**, so it still holds - after pi exits or crashes — it is not a pi-side watchdog. + after the agent exits or crashes — it is not an agent-side watchdog. - The job is **not** killed, and its real exit code is preserved: bytes past the cap are drained and discarded instead of SIGPIPE'ing the producer into `141`. - It is **not silent**. The log carries @@ -187,26 +252,27 @@ default **64 MiB**, `0` = unlimited): ## Configuration The jobs dir defaults to `/.pi-bgrun/jobs` when the session cwd is -inside a recognizable project root (`.git` or `.pi`, found by walking up from +inside a recognizable project root (`.git` or `$CONFIG_DIR`, found by walking up from the cwd); otherwise it falls back to `~/.pi-bgrun/jobs`. **Warning:** a `jobsDir` (or a `PI_BGRUN_GLOBAL_DIR` target) equal to your home directory is dangerous — cleanup removes matching `*.log` files directly there. The home -directory itself is never treated as a project root — pi's global `~/.pi/agent` -dir would otherwise make every cwd under `$HOME` resolve to `$HOME` (a symlinked +directory itself is never treated as a project root — the host's global agent +dir (`$AGENT_DIR`) would otherwise make every cwd under `$HOME` resolve to `$HOME` (a symlinked is still recognized). Override via `jobsDir` / `PI_BGRUN_DIR`. Within a project, -the dir is shared by every pi session working in that checkout — that sharing +the dir is shared by every agent session working in that checkout — that sharing enables cross-session job lookup, session-restart reconstruction, and per-project cleanup. By default each session only *tracks its own jobs*: the -widget and `bgstatus` listings show this session's running jobs, and finished -jobs are hidden (ask for them explicitly with `bgstatus includeDone: true`). -Jobs started by other sessions can still be inspected by id, but they don't -clutter your widget. +panel and `bgstatus` listings show this session's running jobs, `bgstatus` keeps +finished jobs out of its listing unless asked (`bgstatus includeDone: true`), and +the panel shows only the few most recent finishes — the status line always holds +the latest outcome. Jobs started by other sessions can still be inspected by id, +but they don't clutter your panel. Configuration is layered (later wins): **defaults ← user config file ← project config file (trusted projects only) ← environment variables**. -- User: `~/.pi/agent/pi-bgrun.json` -- Project: `/.pi/pi-bgrun.json` +- User: `$AGENT_DIR/pi-bgrun.json` +- Project: `/$CONFIG_DIR/pi-bgrun.json` The project file is per-contributor state, not shared policy: it is read only for a trusted project, it changes what every `bgrun` job in that checkout does, and a @@ -244,7 +310,7 @@ Benefits: - The dir is auto-added to the repo's `.git/info/exclude` (local-only — the tracked `.gitignore` is never touched), so logs never pollute `git status`. 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 + **trusted** projects only, so merely opening the agent in an untrusted repo neither edits `.git/info/exclude` nor creates the dir. Works in linked worktrees too (writes to the common git dir, resolved via the worktree's `commondir` file). @@ -264,7 +330,7 @@ Rules and migration notes: `bgclean all` do not also touch `~/.pi-bgrun/jobs`). Set `"jobsDir": "~/.pi-bgrun/jobs"` (or any absolute path) to keep using the machine-global dir inside a repo. -- If the cwd has no `.git`/`.pi` at or above it, the default falls back to +- If the cwd has no `.git`/`$CONFIG_DIR` at or above it, the default falls back to `~/.pi-bgrun/jobs`; a relative override also falls back to the global dir rather than scattering logs across arbitrary directories. - Tools resolve a job's log from the session's job record first, so jobs @@ -321,7 +387,7 @@ Environment variables (same knobs, handy for one-off overrides): | `PI_BGRUN_MAX_LOG_BYTES` | `67108864` (64 MiB) | Byte ceiling for a job's log (stdout+stderr). `0` disables it (unlimited). See [Log size ceiling](#log-size-ceiling). | | `PI_BGRUN_GLOBAL_AUTO_CLEAN` | `true` | Set `0`/`false` to disable the automatic orphan sweep (see below). | | `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)). | +| `PI_BGRUN_USER_CONFIG` | `$AGENT_DIR/pi-bgrun.json` | Override the user-level config file path (see [Configuration](#configuration)). | ### Digest scorecard (opt-in) @@ -362,7 +428,7 @@ Three ways, easiest first — pick the first one you're comfortable with: projects see on session start ("no digest configured") — once per project that has run a bgrun job — is pointing at this same skill. 2. **One-line preset if you know your stack.** Create - `/.pi/pi-bgrun.json` (or merge into an existing one): + `/$CONFIG_DIR/pi-bgrun.json` (or merge into an existing one): ```json { "digest": { "preset": "go-test" } } @@ -435,11 +501,16 @@ bgrun(command: "go test ./...", name: "unit-tests", type: "test") ``` `type` is an optional `bgrun` parameter. The vocabulary is defined by the -`type` fields of the project's digest config in `.pi/pi-bgrun.json`; when the +`type` fields of the project's digest config in `$CONFIG_DIR/pi-bgrun.json`; when the project's digest config defines types, prefer passing the matching one. If a -job's `type` (or name/command) selects no entry, pi-bgrun logs a one-line -diagnostic naming the job and the configured types — so a mismatched type is -visible instead of silently scorecard-less. +job's `type` (or name/command) selects no entry, the **wake itself** carries a +line naming the job and the configured types, with the fix (`pass the matching +type on the next bgrun call`) — the agent is the only party who can correct it, +and on oh-my-pi a log-only diagnostic would be invisible to it. The same fact +goes to the host log. It is emitted **once per distinct mismatch** (at most 3 per +session, then suppressed), so a project that never passes the right type cannot +grow the context per job — a mismatched type is visible without being +scorecard-less *and* without becoming noise. Selection order (exactly one entry, or none): @@ -473,7 +544,7 @@ a scorecard. The legacy single-object form still works unchanged — `{ "digest": { "preset": "go-test" } }` is a one-entry list with no matchers. -Opt in per project via `/.pi/pi-bgrun.json` (read only for trusted +Opt in per project via `/$CONFIG_DIR/pi-bgrun.json` (read only for trusted projects). If both `preset` and `command` are set within one entry, the preset wins. An empty list (or one where every entry is invalid) counts as *not configured*. @@ -508,7 +579,7 @@ Shell safety: the command comes from trust-gated config and runs with your own privileges — the same trust boundary as the `jobsDir` setting. A user-level default digest works too: set `digest` in -`~/.pi/agent/pi-bgrun.json` (path overridable via `PI_BGRUN_USER_CONFIG`), and +`$AGENT_DIR/pi-bgrun.json` (path overridable via `PI_BGRUN_USER_CONFIG`), and any project without its own digest inherits it. The project `digest` section overrides the user-level one **wholesale** (no per-key merge). diff --git a/docs/dogfooding.md b/docs/dogfooding.md index d8e4a32..90ed731 100644 --- a/docs/dogfooding.md +++ b/docs/dogfooding.md @@ -1,7 +1,7 @@ # Dogfooding bgrun in this repo This repo's maintainers run pi-bgrun on its own test suite. The setup is a -*personal* project config, not repo policy: `/.pi/pi-bgrun.json` is read +*personal* project config, not repo policy: `/$CONFIG_DIR/pi-bgrun.json` is read only for a trusted project, it changes what every `bgrun` job in the checkout does, and one of its keys runs a shell command at wake time. So it is gitignored here — copy the example below into your own working copy if you want the same. @@ -22,8 +22,10 @@ here — copy the example below into your own working copy if you want the same. ## What it does -- `showCompletedJobs: true` — finished jobs stay in the widget and in - `bgstatus` instead of disappearing (the extension's default is `false`). +- `showCompletedJobs: true` — finished jobs stay in `bgstatus` instead of + disappearing (the extension's default is `false`). The live panel always shows + the few most recent finishes while a job is still running, and the status line + holds the latest outcome either way. - `digest[0]` — a **scorecard**: at wake time, for a job whose `type` is `test` *and* whose command matches `*bun test*`, the `command` runs with `$1` set to the job's log path, and its stdout is appended to the wake as @@ -62,7 +64,7 @@ through to the type-less/glob pass (and, with no entry there, gets no scorecard) Config layers are `defaults ← user ← project ← env`, so an environment variable beats the file (`PI_BGRUN_SHOW_COMPLETED=0`, `PI_BGRUN_MAX_LOG_BYTES=0`). To drop -the setup, delete `.pi/pi-bgrun.json`; nothing else depends on it. +the setup, delete `$CONFIG_DIR/pi-bgrun.json`; nothing else depends on it. ## See also diff --git a/docs/log-size-ceiling.md b/docs/log-size-ceiling.md index 89de06b..39fa929 100644 --- a/docs/log-size-ceiling.md +++ b/docs/log-size-ceiling.md @@ -20,8 +20,9 @@ machine down. A secondary effect: `countLogLines` streams the whole file at exit Any fix has to respect these; most of the wrapper's odd shape is one of them. -1. The cap must live **inside the detached process tree** — pi can exit at any - time, so no parent-side streaming. A watchdog that dies with pi does not keep +1. The cap must live **inside the detached process tree** — the agent can exit at + any time, so no parent-side streaming. A watchdog that dies with the agent does + not keep the promise. 2. The `__BGRUN_EXIT__` marker must remain the **last non-empty line**. Completion evidence is the *last* non-blank line only, so a marker that ends @@ -168,9 +169,9 @@ They are listed because they explain why the wrapper is not simpler. - **`head -c` without a drain**: producer dies on SIGPIPE (`141`) — hostile to legitimately verbose builds. -- **pi-side watchdog** (stat running logs, kill and truncate the tail): a soft - bound only while pi lives; the overshoot is write-rate × poll interval, and it - is unbounded if pi died. Keeps the tail, loses the promise. +- **agent-side watchdog** (stat running logs, kill and truncate the tail): a soft + bound only while the agent lives; the overshoot is write-rate × poll interval, and it + is unbounded if the agent died. Keeps the tail, loses the promise. - **`ulimit -f`**: caps *every* file the job writes (artifacts, downloads) and kills it (`SIGXFSZ`/`153`). Opt-in material, not a default. - **Document-and-trim-finished-logs**: no bound at all while the job runs. diff --git a/extension/digestPresets.ts b/extension/digestPresets.ts index cf769fb..65ba241 100644 --- a/extension/digestPresets.ts +++ b/extension/digestPresets.ts @@ -222,15 +222,14 @@ function labelFromMatchName(pattern: string): string { } /** - * One-line diagnostic for the silent no-digest case: a digest IS configured - * but no entry selected for this job. The usual causes are a `type` the agent - * never passes (or spells differently) and a `match` glob that never fires. - * Pure — the wake path decides whether to log it. + * The no-match diagnostic's shared facts: how the job was identified and which + * types the config declares. Both the log line and the wake note are built from + * these, so the two can never describe different things. */ -export function digestNoMatchWarning( +function digestNoMatchParts( target: DigestJobTarget, entries: DigestEntry[], -): string { +): { job: string; types: string } { const declaredTypes = [ ...new Set( entries.map((e) => e.type).filter((t): t is string => typeof t === "string"), @@ -245,9 +244,41 @@ export function digestNoMatchWarning( const types = declaredTypes.length ? ` — configured types: ${declaredTypes.join(", ")}` : ""; + return { job, types }; +} + +/** + * One-line diagnostic for the silent no-digest case: a digest IS configured + * but no entry selected for this job. The usual causes are a `type` the agent + * never passes (or spells differently) and a `match` glob that never fires. + * Pure — the wake path decides whether to log it. + */ +export function digestNoMatchWarning( + target: DigestJobTarget, + entries: DigestEntry[], +): string { + const { job, types } = digestNoMatchParts(target, entries); return `[pi-bgrun] digest configured but selected no entry for ${job}${types}`; } +/** + * Agent-facing form of the same diagnostic, for the wake message. oh-my-pi + * routes `pi.logger` output to a file the *agent* never reads, so a bare log + * line would leave a mismatched `type` invisible to the only party that can fix + * it. Phrased as an instruction rather than a log line; the wake path emits it + * at most once per distinct mismatch (see DIGEST_NO_MATCH_WARN_CAP). + */ +export function digestNoMatchWakeLine( + target: DigestJobTarget, + entries: DigestEntry[], +): string { + const { job, types } = digestNoMatchParts(target, entries); + return ( + `digest: no scorecard selected for ${job}${types} — ` + + "pass the matching `type` on the next bgrun call to get one" + ); +} + /** * Select the digest entry for a job and resolve it to a concrete command + * wake label. Selection order: diff --git a/extension/index.test.ts b/extension/index.test.ts index bd82996..602b1ac 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -129,6 +129,10 @@ type FakeTool = Pick< ToolDefinition, "name" | "label" | "description" | "parameters" | "promptSnippet" > & { + /** pi-only guidance field — read by pi, absent (and folded into `description`) on omp. */ + promptGuidelines?: string[]; + /** omp-only presentation field — keeps the tool out of the `xd://` device mount. */ + loadMode?: "essential" | "discoverable"; execute( toolCallId: string, params: any, @@ -180,6 +184,21 @@ interface FakePiHandles { handlers: Map; fireSessionStart: () => Promise; fireSessionShutdown: () => Promise; + /** Messages the extension routed to the host logger (omp path). */ + hostLogs: string[]; +} + +/** + * Which host shape the fake presents. `pi` exposes `registerEntryRenderer` and + * reads `promptSnippet`/`promptGuidelines`; `omp` (oh-my-pi) has neither, and + * instead injects `zod` plus a `logger` — the two differences the extension + * probes for at load time. + */ +type FakeHostKind = "pi" | "omp"; + +interface FakeHostExtras { + registerComposerShape?: (definition: unknown) => void; + logger?: { warn(message: string): void; error(message: string): void }; } function makeFakePi( @@ -187,8 +206,10 @@ function makeFakePi( idle?: boolean; priorEntries?: CapturedEntry[]; ctxFields?: Record; + host?: FakeHostKind; } = {}, ): FakePiHandles { + const host = opts.host ?? "pi"; const wakes: CapturedWake[] = []; const entries: CapturedEntry[] = opts.priorEntries ? [...opts.priorEntries] @@ -197,6 +218,7 @@ function makeFakePi( const commands = new Map(); const entryRenderers = new Map(); const handlers = new Map(); + const hostLogs: string[] = []; const idle = opts.idle ?? true; const ctx = { isIdle: () => idle, @@ -209,7 +231,8 @@ function makeFakePi( // 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 = { + // `Partial` because the omp shape legitimately omits the pi-only members. + const used: Partial & FakeHostExtras = { sendUserMessage(content, options) { wakes.push({ text: content as string, @@ -223,9 +246,6 @@ function makeFakePi( data: data as Record | undefined, }); }, - registerEntryRenderer(customType: string, renderer: EntryRenderer) { - entryRenderers.set(customType, renderer as unknown as FakeRenderer); - }, registerTool(def) { tools.set(def.name, def as unknown as FakeTool); }, @@ -238,6 +258,17 @@ function makeFakePi( handlers.set(event, list); }, }; + if (host === "pi") { + used.registerEntryRenderer = (customType: string, renderer: EntryRenderer) => { + entryRenderers.set(customType, renderer as unknown as FakeRenderer); + }; + } else { + used.registerComposerShape = () => {}; + used.logger = { + warn: (message: string) => hostLogs.push(message), + error: (message: string) => hostLogs.push(message), + }; + } const pi = used as ExtensionAPI; const fireSessionStart = async () => { @@ -263,6 +294,7 @@ function makeFakePi( handlers, fireSessionStart, fireSessionShutdown, + hostLogs, }; } @@ -4505,7 +4537,7 @@ test("shipped presets: ids are stable and every command ends in head (bounded ou test("shipped presets: README and digest-config skill document every preset id", () => { const readme = readFileSync(join(process.cwd(), "README.md"), "utf8"); const skill = readFileSync( - join(process.cwd(), "skill", "digest-config", "SKILL.md"), + join(process.cwd(), "skills", "digest-config", "SKILL.md"), "utf8", ); for (const id of DIGEST_PRESET_IDS) { @@ -5795,6 +5827,146 @@ test("entry renderer: bgrun-job renders running and done/expanded without throwi ); assert.ok(done, "done + expanded entry renders"); }); + +// ── Host contract: oh-my-pi (omp) vs upstream pi ─────────────────────────── +// +// The hosts differ in four ways the extension must handle: +// * omp has no `registerEntryRenderer` at all — calling it aborts the whole +// extension load (the original omp porting blocker); +// * omp mounts a tool that omits `loadMode: "essential"` as an `xd://` device, +// so `bgrun` would stop being directly callable; +// * omp ignores `promptSnippet`/`promptGuidelines` on a tool definition, so the +// guidance has to reach the model through `description` instead; +// * omp exposes a file logger (its TUI owns the terminal), pi does not. + +const BG_TOOL_NAMES = ["bgrun", "bgtail", "bggrep", "bgstatus", "bgclean"]; +const BGGREP_HEADLINE = "Never search a bgrun log with the bash tool"; + +test("host contract (omp): loads without registerEntryRenderer; every tool stays top-level", async () => { + const h = makeFakePi({ host: "omp" }); + await loadExtension(h.pi); // must not throw — regression guard for the load blocker + assert.equal(h.entryRenderers.size, 0, "no entry renderer is registered"); + for (const name of BG_TOOL_NAMES) { + const def = h.tools.get(name); + assert.ok(def, `${name} is registered`); + assert.equal( + def.loadMode, + "essential", + `${name} stays directly callable instead of becoming an xd:// device`, + ); + } +}); + +test("host contract (omp): guidance is folded into the description the host actually reads", async () => { + const h = makeFakePi({ host: "omp" }); + await loadExtension(h.pi); + assert.match(h.tools.get("bggrep")!.description, new RegExp(BGGREP_HEADLINE)); + assert.match( + h.tools.get("bgrun")!.description, + /Use bgrun \(not bash\) for any command expected to run >30s/, + ); +}); + +test("host contract (pi): guidance stays in promptGuidelines and is not duplicated", async () => { + const h = makeFakePi(); + await loadExtension(h.pi); + const bggrep = h.tools.get("bggrep")!; + assert.ok(h.entryRenderers.has("bgrun-job"), "pi keeps the transcript card"); + assert.match(bggrep.promptGuidelines!.join("\n"), new RegExp(BGGREP_HEADLINE)); + assert.doesNotMatch( + bggrep.description, + new RegExp(BGGREP_HEADLINE), + "pi reads promptGuidelines, so the description carries no copy", + ); +}); + +test("host contract (omp): diagnostics go to the host logger, not stderr", async () => { + const h = makeFakePi({ host: "omp" }); + await loadExtension(h.pi); + // One unchecked cast for the dynamic module harness: `loadModule()` is typed + // loosely because tests reach for whatever the extension exports. + const mod = (await loadModule()) as { + resolveConfig(ctx: { + cwd?: string; + isProjectTrusted?: () => boolean; + userConfigPath?: string; + }): unknown; + installHostLogger(logger: { + warn(message: string): void; + error(message: string): void; + }): void; + }; + const userCfg = join(mkTmp("pi-bgrun-user-"), "pi-bgrun.json"); + writeFileSync(userCfg, "{ not json"); + const consoleErrors: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + consoleErrors.push(args.map(String).join(" ")); + }; + try { + mod.resolveConfig({ + cwd: mkTmp("pi-bgrun-proj-"), + isProjectTrusted: () => true, + userConfigPath: userCfg, + }); + } finally { + console.error = originalError; + // The sink is module-level: restore console routing for the tests that + // follow and assert on console.error. + mod.installHostLogger({ + warn: (m: string) => console.error(m), + error: (m: string) => console.error(m), + }); + } + assert.ok( + h.hostLogs.some((m) => m.includes("malformed")), + "the host logger received the config warning", + ); + assert.equal(consoleErrors.length, 0, "nothing was written to stderr"); +}); + +test("host contract (omp): the stale-job poller uses the host's managed interval and clears it the host's way", async () => { + // One unchecked cast for the dynamic module harness, as above. + const mod = (await loadModule()) as { + scheduleManagedInterval( + ctx: unknown, + callback: () => void, + ms: number, + ): { clear: () => void }; + }; + const scheduled: { callback: () => void; ms: number }[] = []; + const cleared: unknown[] = []; + const hostCtx = { + setInterval(callback: () => void, ms: number) { + scheduled.push({ callback, ms }); + return "managed-handle"; + }, + clearTimer(timer: unknown) { + cleared.push(timer); + }, + }; + + let ticks = 0; + const timer = mod.scheduleManagedInterval(hostCtx, () => ticks++, 250); + assert.equal(scheduled.length, 1, "scheduled through the host's managed timer"); + assert.equal(scheduled[0].ms, 250); + scheduled[0].callback(); + assert.equal(ticks, 1, "the wrapped callback runs"); + timer.clear(); + assert.deepEqual( + cleared, + ["managed-handle"], + "stopped through the host's clearTimer, not a raw clearInterval", + ); + + // A host without managed timers (upstream pi) must still work: raw timer, + // unref'd so a running poll never holds the process open, and clearable. This + // necessarily constructs a real timer — the fallback IS setInterval — but it + // is cleared on the next line, so nothing here waits on the clock and no + // fake-timer substitution can exercise the platform path. + const raw = mod.scheduleManagedInterval({}, () => {}, 600_000); + raw.clear(); +}); 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; @@ -7008,3 +7180,128 @@ test("bgclean: a running job's staging files survive an aggressive sweep", async rmSync(dir, { recursive: true, force: true }); } }); + +// ── Live panel + status line: what the pi-only transcript card shows ─────── +// +// oh-my-pi has no entry renderer, so the job card cannot exist there. These two +// surfaces carry the same information on BOTH hosts at zero context cost: the +// editor panel lists what is running plus what just finished, and the status +// line keeps the latest outcome visible once the panel is gone. + +test("panel + status line: running rows, the recent-finished section, and the last outcome", async () => { + const dir = mkTmp("pi-bgrun-test-"); + process.env.PI_BGRUN_DIR = dir; + try { + // Three finished jobs from this session's lineage, with distinct exit times + // so "newest first" is observable. + const doneAt = Date.now(); + const doneEntry = (name: string, exitedAt: number): CapturedEntry => ({ + type: "custom", + customType: "bgrun-job", + data: { + id: `panel-done-${name}-2000000000-11111`, + pid: 11111, + cmd: `echo ${name}`, + name, + started: exitedAt - 5_000, + logPath: join(dir, `panel-done-${name}-2000000000-11111.log`), + state: "done", + exitCode: 0, + exitedAt, + }, + }); + const { pi, wakes, tools, ctx, fireSessionStart } = makeFakePi({ + priorEntries: [ + doneEntry("oldest", doneAt - 3_000), + doneEntry("newest", doneAt - 1_000), + doneEntry("middle", doneAt - 2_000), + ], + }); + ctx.hasUI = true; + const widgetCalls: (string[] | undefined)[] = []; + ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => + widgetCalls.push(lines); + const statuses: (string | undefined)[] = []; + ctx.ui.setStatus = (_key: string, text: string | undefined) => + statuses.push(text); + + await loadExtension(pi); + await fireSessionStart(); + + // Nothing running: no panel (no permanent editor space), but the last + // outcome stays visible on the status line. + assert.equal(widgetCalls.at(-1), undefined, "idle panel is cleared"); + assert.equal(statuses.at(-1), "✅ newest exit=0", "status shows the newest outcome"); + + await tools + .get("bgrun")! + .execute("call-panel", { command: "sleep 1", name: "long-one" }, undefined, undefined, ctx); + + const shown = [...widgetCalls].reverse().find((l) => Array.isArray(l))!; + const flat = shown.join("\n"); + assert.match(flat, /bgrun: 1 running/); + assert.match(flat, /long-one/); + assert.match(flat, /── recent ──/, "finished jobs ride along while something runs"); + assert.match(flat, /✅/, "a finished job keeps its outcome icon"); + assert.match(flat, /newest/, "the most recently finished job is listed"); + assert.ok( + flat.indexOf("newest") < flat.indexOf("middle") && + flat.indexOf("middle") < flat.indexOf("oldest"), + "recent jobs are newest-first", + ); + assert.ok( + shown.length <= 10, + `panel stays inside the host's own cap instead of being truncated by it (${shown.length} lines)`, + ); + assert.equal(statuses.at(-1), "⏳ 1 running", "status counts live jobs"); + + // The job finishes: panel goes away, status keeps the outcome. + await waitForWakes(wakes, 1); + assert.equal(widgetCalls.at(-1), undefined, "panel cleared when nothing runs"); + assert.equal(statuses.at(-1), "✅ long-one exit=0", "status holds the newest outcome"); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("wake digest: a type mismatch reaches the agent once, then stays out of the context", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + // Only a typed entry, so a job that declares the wrong type — or none — + // selects nothing. The agent is the only party who can fix that. + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: [{ type: "test", preset: "go-test" }], + }); + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + await bgrun.execute("call-m1", { command: "printf 'x\\n'", name: "mismatch-a" }, undefined, undefined, ctx); + await waitForWakes(wakes, 1); + await bgrun.execute("call-m2", { command: "printf 'x\\n'", name: "mismatch-a" }, undefined, undefined, ctx); + await waitForWakes(wakes, 2); + + assert.match( + wakes[0].text, + /^digest: no scorecard selected for job name "mismatch-a" — configured types: test/m, + "the wake names the job and the configured types", + ); + assert.match( + wakes[0].text, + /pass the matching `type`/, + "the note says how to fix it", + ); + assert.ok( + !wakes[1].text.includes("no scorecard selected"), + "a repeated mismatch is not re-injected into context", + ); + assert.ok( + !wakes[1].text.includes("digest ("), + "no scorecard ran for either job", + ); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); diff --git a/extension/index.ts b/extension/index.ts index 1de3644..128b03f 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -27,6 +27,7 @@ import { CONFIG_DIR_NAME, + getAgentDir, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; @@ -54,6 +55,7 @@ import { homedir } from "node:os"; import { createHash, randomBytes } from "node:crypto"; import { DIGEST_PRESET_IDS, + digestNoMatchWakeLine, digestNoMatchWarning, selectDigestEntry, type DigestEntry, @@ -61,6 +63,78 @@ import { type DigestMatch, } from "./digestPresets.ts"; +// ── Diagnostics ───────────────────────────────────────────────────────────── +// +// oh-my-pi (omp) exposes a rotating file logger at `pi.logger` and owns the +// terminal — a raw stderr write from an extension can corrupt the TUI. Upstream +// pi exposes no logger at all, so the sink starts on the console and is rebound +// to the host logger during extension load when one exists (installHostLogger). +// Module-level config helpers run through the same sink, since they can be +// reached before/without a factory (unit tests import them directly). +type WriteLog = (message: string) => void; +let logWarn: WriteLog = (message) => console.error(message); +let logError: WriteLog = (message) => console.error(message); + +/** Rebind diagnostics to the host's logger. Hosts without one keep the console. */ +export function installHostLogger(logger: { + warn: WriteLog; + error: WriteLog; +}): void { + logWarn = (message) => logger.warn(message); + logError = (message) => logger.error(message); +} + +/** + * Extension-API fields that only one of the two hosts declares. oh-my-pi adds a + * file logger and composer-shape registration and has no entry-renderer concept; + * upstream pi has the entry renderer and the tool-definition prompt fields. + * Casting the API object once to this intersection lets the capability probes + * read a field the other host's type omits without trusting an unchecked shape + * at each access site. + */ +interface HostExtensionApi { + logger?: { warn: WriteLog; error: WriteLog }; + registerComposerShape?: (definition: unknown) => void; + registerEntryRenderer?: unknown; +} + +/** + * Managed-timer surface oh-my-pi adds to ExtensionContext. Its callbacks are + * throw-contained (a raw timer's throw is process-fatal there) and are cleared + * on session shutdown; upstream pi exposes neither method. + */ +interface HostTimers { + setInterval?: (callback: () => void, ms: number) => unknown; + clearTimer?: (timer: unknown) => void; +} + +/** + * Interval timer that prefers the host's managed timers. Falls back to a raw + * `setInterval`, `unref`'d so a watch loop never keeps the process alive. + * Returns a handle whose `clear()` stops the timer on either path. + * Exported for tests, like formatSince. + */ +export function scheduleManagedInterval( + ctx: ExtensionContext, + callback: () => void, + ms: number, +): { clear: () => void } { + const timers = ctx as ExtensionContext & HostTimers; + const setManaged = timers.setInterval; + if (typeof setManaged === "function") { + const clearManaged = timers.clearTimer; + const handle = setManaged.call(ctx, callback, ms); + return { + clear: () => { + if (typeof clearManaged === "function") clearManaged.call(ctx, handle); + }, + }; + } + const raw = setInterval(callback, ms); + raw.unref(); + return { clear: () => clearInterval(raw) }; +} + // Exit marker appended to every log so the file is self-describing: the exit // code survives pi restarting. `;` (not `&&`) ensures the printf runs even when // the command fails. Never use `set -e` in the wrapper. @@ -718,11 +792,11 @@ function readConfigFile(path: string): BgrunConfigFile { const raw = JSON.parse(text); if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw as BgrunConfigFile; - console.error( + logWarn( `[pi-bgrun] config ${path} is not a JSON object — ignoring its contents`, ); } catch (err) { - console.error( + logWarn( `[pi-bgrun] config ${path} is malformed JSON (${(err as Error).message}) — ignoring its contents`, ); } @@ -1021,7 +1095,7 @@ function appendExcludePattern( // Digest config validation: invalid values are dropped from the resolved // config (best-effort — a malformed digest section must never break a wake or -// the whole config), but the human gets one console.error per distinct invalid +// the whole config), but the human gets one warning per distinct invalid // field so typos are discoverable without flooding the log. The field set is a // fixed, code-defined list (preset / command / match / type / ...), so the // dedupe set is naturally bounded. @@ -1039,7 +1113,7 @@ function warnDigestInvalid(field: string, value: unknown): void { " — digest takes an object or an array of { type, match, label, preset, command } entries"; // field "" means the whole `digest` section was unusable (wrong shape). const where = field ? `digest.${field}` : "digest"; - console.error( + logWarn( `[pi-bgrun] ignoring invalid ${where} in pi-bgrun.json: ${JSON.stringify(value)}${hint}${shapeHint}`, ); } @@ -1207,6 +1281,27 @@ function normalizeDigestEntry(raw: unknown): DigestEntry | undefined { return out; } +// The host's agent directory — `~/.omp/agent` under oh-my-pi (profile-aware), +// `~/.pi/agent` under upstream pi. Derived from CONFIG_DIR_NAME + HOME when the +// host package predates the helper (it is exported by pi >= 0.79 and by omp's +// compat shim), so the config path always follows the running host. +function defaultUserConfigPath(): string { + try { + const dir = getAgentDir(); + if (typeof dir === "string" && dir) return join(dir, "pi-bgrun.json"); + logWarn( + "[pi-bgrun] host getAgentDir() returned no directory — falling back to $HOME", + ); + } catch (err) { + // Not silent: the fallback is the DEFAULT profile's agent dir, so a + // profile-scoped user config would otherwise be ignored without a trace. + logWarn( + `[pi-bgrun] host getAgentDir() failed (${(err as Error).message}) — falling back to $HOME`, + ); + } + return join(homeDir(), CONFIG_DIR_NAME, "agent", "pi-bgrun.json"); +} + // Resolved per call (cheap: at most two small file reads) so env/config // changes are picked up without module reloads — and tests can isolate. // Exported for tests, like formatSince. @@ -1218,19 +1313,20 @@ export function resolveConfig(ctx?: { // os.homedir(). userConfigPath?: string; }): BgrunConfig { - // User config: $HOME/.pi/agent/pi-bgrun.json. Overridable by an explicit + // User config: /pi-bgrun.json. Overridable by an explicit // test seam (ctx.userConfigPath) and by PI_BGRUN_USER_CONFIG (mirrors the // PI_BGRUN_DIR escape hatch). const user = readConfigFile( ctx?.userConfigPath ?? process.env.PI_BGRUN_USER_CONFIG ?? - join(homeDir(), ".pi", "agent", "pi-bgrun.json"), + defaultUserConfigPath(), ); let project: BgrunConfigFile = {}; try { if (ctx?.isProjectTrusted?.()) { // Read the project config from the same root resolveJobsDirPath uses, so - // a session started in a subdirectory still picks up /.pi config. + // a session started in a subdirectory still picks up / + // config. const cwd = ctx.cwd ?? process.cwd(); const projectRoot = projectRootFor(cwd); project = readConfigFile( @@ -1413,6 +1509,62 @@ interface BgStatusDetails { } export default function (pi: ExtensionAPI) { + // One widened view of the host API — see HostExtensionApi for why the cast is + // needed and which fields each host provides. + const hostApi = pi as ExtensionAPI & HostExtensionApi; + + // Route diagnostics to the host's file logger when it has one (oh-my-pi); + // upstream pi writes to the console as before. + const hostLogger = hostApi.logger; + if ( + hostLogger && + typeof hostLogger.warn === "function" && + typeof hostLogger.error === "function" + ) { + installHostLogger(hostLogger); + } + + // Host identity probe, used only to decide where tool guidance is emitted. + // + // Two independent signals, OR'd because their failure modes are asymmetric: + // mis-detecting pi as omp merely duplicates the bullets (they still appear + // in promptGuidelines), while mis-detecting omp as pi drops them silently. + // * `CONFIG_DIR_NAME` is `.omp` on oh-my-pi and `.pi` on upstream pi — a + // constant the extension already depends on for every config path, so a + // host where it lies is already visibly broken rather than quietly so. + // * `registerComposerShape` is an oh-my-pi-only extension surface. + const HOST_IS_OMP = + CONFIG_DIR_NAME !== ".pi" || + typeof hostApi.registerComposerShape === "function"; + + // Does the host render `appendEntry` records in the transcript? Upstream pi + // does (via the entry renderer below); oh-my-pi has no entry-renderer concept + // — it renders only `custom_message` entries, through registerMessageRenderer. + const HOST_HAS_ENTRY_RENDERER = + typeof hostApi.registerEntryRenderer === "function"; + + // Tool guidance the host will actually surface. omp drops the + // `promptSnippet`/`promptGuidelines` fields, so on omp the bullets ride in the + // description — emitted once per host, never duplicated. + function toolDescription( + description: string, + guidelines: readonly string[], + ): string { + if (!HOST_IS_OMP || guidelines.length === 0) return description; + return `${description}\n\n${guidelines.map((g) => `- ${g}`).join("\n")}`; + } + + // omp unmounts any tool that does not declare `loadMode: "essential"` and + // re-exposes it as an `xd://` device — callable, but only through a discovery + // `read` plus `write xd://`. Upstream pi has no such field (and no device + // transport), so the property is spread in rather than written literally: a + // literal would trip pi's excess-property check against its ToolDefinition. + const ESSENTIAL_TOOL = { loadMode: "essential" as const }; + + // `/pi-bgrun.json` as the host spells it (`.omp/...` on oh-my-pi, + // `.pi/...` on upstream pi), for prose that points the model at the file. + const CONFIG_FILE_HINT = `${CONFIG_DIR_NAME}/pi-bgrun.json`; + const jobs = new Map(); // bgtail's delta-tailing bookmarks: one entry per job id ever tailed, holding // the high-water mark of what the caller has already had the opportunity to @@ -1435,7 +1587,7 @@ export default function (pi: ExtensionAPI) { // handle (adopted foreign jobs + jobs reconstructed from transcript entries // after a restart). No exit event exists for those, so their logs/pids are // re-checked on an interval instead. - let stalePoller: ReturnType | undefined; + let stalePoller: { clear: () => void } | undefined; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -1554,7 +1706,29 @@ export default function (pi: ExtensionAPI) { } } - // ── Live status widget ──────────────────────────────────────────────────── + // ── Live status panel + status line ─────────────────────────────────────── + // + // Two host-neutral surfaces carry what the pi-only transcript card shows, at + // zero context cost: + // * the editor widget — running jobs plus the jobs that just finished, + // present only while something is still running (no permanent editor + // space), self-limited to the 10 lines both hosts cap a string[] at; + // * the status line — one always-visible line: the running count while + // jobs are in flight, else how the most recent job ended. This is what + // keeps a job's outcome visible after the panel is gone. + + // Both hosts cap a string[] widget at 10 lines and append their own + // "... (widget truncated)" note past that (pi `MAX_WIDGET_LINES`, oh-my-pi + // the same). Bounding here keeps the two hosts byte-identical and spends the + // budget on the panel's own content instead of the host's truncation line. + const WIDGET_LINE_BUDGET = 10; + const WIDGET_RECENT_MAX = 4; + + /** Compact one-line label for a job: its name, else the id's slug prefix. */ + function jobLabel(rec: JobRecord): string { + const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd; + return rec.name ? `${rec.name} · ${cmd}` : cmd; + } function updateWidget( ctx: ExtensionContext, @@ -1566,22 +1740,74 @@ export default function (pi: ExtensionAPI) { for (const rec of jobs.values()) { if (rec.exitCode === undefined) running.push(rec); } + // Newest finished first. Finished jobs stay in the map for the session + // (only adopted ones are dropped), so the panel can show what just ran + // without any extra bookkeeping. + const recent = [...jobs.values()] + .filter((rec) => rec.exitCode !== undefined) + .sort((a, b) => (b.exitedAt ?? b.started) - (a.exitedAt ?? a.started)) + .slice(0, WIDGET_RECENT_MAX); + + // The status line is independent of whether the panel shows, so it is set + // first: live count while running, else the newest outcome. + setStatusLine(ctx, recent[0]); + if (running.length === 0) { + // No live activity: the panel goes away (the status line keeps the last + // outcome), which is what an idle editor expects. ctx.ui.setWidget("bgrun", undefined); return; } + + // Running rows get priority; the recent section is spent only out of what + // is left, and dropped whole rather than truncated, so the panel never + // reaches a host's own "... (widget truncated)" note. const lines = [`📊 bgrun: ${running.length} running`]; - 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; + // Reserve the overflow line alongside the header before slicing the rows. + const maxRunningRows = WIDGET_LINE_BUDGET - 2; + const shownRunning = running.slice(0, maxRunningRows); + for (const rec of shownRunning) { const tag = rec.adopted ? " (adopted)" : ""; // Full id (not truncated) so it can be copied straight into /bgtail . - lines.push(` ${rec.id} ${label} (since ${startedAt})${tag}`); + lines.push( + ` ${rec.id} ${jobLabel(rec)} (since ${formatSince(rec.started)})${tag}`, + ); + } + const hiddenRunning = running.length - shownRunning.length; + if (hiddenRunning > 0) lines.push(` … ${hiddenRunning} more running`); + + const recentRows = recent.map((rec) => { + const icon = rec.exitCode === 0 ? "✅" : "❌"; + return ` ${icon} ${rec.id} ${jobLabel(rec)} exit=${rec.exitCode ?? "?"}`; + }); + if (recentRows.length > 0 && WIDGET_LINE_BUDGET - lines.length >= recentRows.length + 1) { + lines.push(" ── recent ──", ...recentRows); } ctx.ui.setWidget("bgrun", lines); } + /** Always-visible one-liner: live count while running, else the last outcome. */ + function setStatusLine(ctx: ExtensionContext, lastDone?: JobRecord): void { + let running = 0; + for (const rec of jobs.values()) { + if (rec.exitCode === undefined) running++; + } + if (running > 0) { + ctx.ui.setStatus("bgrun", `⏳ ${running} running`); + return; + } + if (!lastDone) { + ctx.ui.setStatus("bgrun", undefined); + return; + } + const icon = lastDone.exitCode === 0 ? "✅" : "❌"; + const label = (lastDone.name ?? jobLabel(lastDone)).slice(0, 30); + ctx.ui.setStatus( + "bgrun", + `${icon} ${label} exit=${lastDone.exitCode ?? "?"}`, + ); + } + // ── Cleanup ─────────────────────────────────────────────────────────────── // Is the wrapper that owns this staging stem still running? It records its @@ -1906,76 +2132,95 @@ export default function (pi: ExtensionAPI) { function ensureStalePoller(ctx: ExtensionContext): void { if (stalePoller !== undefined || !hasUnsupervisedRunning()) return; - stalePoller = setInterval(() => { - revalidateStaleJobs(); - updateWidget(ctx); - if (!hasUnsupervisedRunning()) stopStalePoller(); - }, STALE_POLL_MS); - stalePoller.unref(); + // The callback runs outside handler dispatch. oh-my-pi treats an uncaught + // throw there as process-fatal (it tears the whole session down), so it is + // wrapped, and the timer itself is scheduled through the host's managed + // timers when they exist (omp contains the throw and unrefs automatically). + const tick = () => { + try { + revalidateStaleJobs(); + updateWidget(ctx); + if (!hasUnsupervisedRunning()) stopStalePoller(); + } catch (err) { + logWarn( + `[pi-bgrun] stale-job poll failed: ${(err as Error).message}`, + ); + } + }; + stalePoller = scheduleManagedInterval(ctx, tick, STALE_POLL_MS); } function stopStalePoller(): void { if (stalePoller !== undefined) { - clearInterval(stalePoller); + stalePoller.clear(); stalePoller = undefined; } } // ── Entry renderer: job cards in the transcript ─────────────────────────── - - pi.registerEntryRenderer( - "bgrun-job", - (entry, { expanded }, theme) => { - const d = - entry.data ?? - ({ - id: "?", - cmd: "", - started: 0, - logPath: "", - state: "running", - } as BgrunJobEntryData); - const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); - const icon = d.state === "done" ? (d.exitCode === 0 ? "✅" : "❌") : "🔄"; - const exitStr = d.state === "done" ? ` exit=${d.exitCode ?? "?"}` : ""; - const namePrefix = d.name ? `"${d.name}" ` : ""; - box.addChild( - new Text( - `${icon} ${theme.fg("accent", "bgrun")} ${namePrefix}${d.id}${exitStr}`, - 0, - 0, - ), - ); - const cmdPreview = d.cmd.length > 60 ? d.cmd.slice(0, 57) + "…" : d.cmd; - box.addChild(new Text(theme.fg("dim", ` $ ${cmdPreview}`), 0, 0)); - if (expanded) { - box.addChild(new Text(theme.fg("dim", ` log: ${d.logPath}`), 0, 0)); + // + // Upstream pi only. oh-my-pi has no entry renderer at all — it renders + // `custom_message` entries (pi.sendMessage) through registerMessageRenderer, + // never the `custom` records pi.appendEntry writes. The job entries are still + // persisted and replayed by session_start on both hosts; only the transcript + // card is pi-only. Calling the missing method would throw and abort the whole + // extension load on omp, so it is registered conditionally. + + if (HOST_HAS_ENTRY_RENDERER) { + pi.registerEntryRenderer( + "bgrun-job", + (entry, { expanded }, theme) => { + const d = + entry.data ?? + ({ + id: "?", + cmd: "", + started: 0, + logPath: "", + state: "running", + } as BgrunJobEntryData); + const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); + const icon = d.state === "done" ? (d.exitCode === 0 ? "✅" : "❌") : "🔄"; + const exitStr = d.state === "done" ? ` exit=${d.exitCode ?? "?"}` : ""; + const namePrefix = d.name ? `"${d.name}" ` : ""; box.addChild( new Text( - theme.fg( - "dim", - ` started: ${new Date(d.started).toLocaleString()}`, - ), + `${icon} ${theme.fg("accent", "bgrun")} ${namePrefix}${d.id}${exitStr}`, 0, 0, ), ); - if (d.exitedAt) { + const cmdPreview = d.cmd.length > 60 ? d.cmd.slice(0, 57) + "…" : d.cmd; + box.addChild(new Text(theme.fg("dim", ` $ ${cmdPreview}`), 0, 0)); + if (expanded) { + box.addChild(new Text(theme.fg("dim", ` log: ${d.logPath}`), 0, 0)); box.addChild( new Text( theme.fg( "dim", - ` finished: ${new Date(d.exitedAt).toLocaleString()}`, + ` started: ${new Date(d.started).toLocaleString()}`, ), 0, 0, ), ); + if (d.exitedAt) { + box.addChild( + new Text( + theme.fg( + "dim", + ` finished: ${new Date(d.exitedAt).toLocaleString()}`, + ), + 0, + 0, + ), + ); + } } - } - return box; - }, - ); + return box; + }, + ); + } // ── session_start: reconstruct Map from entries + auto-cleanup ──────────── @@ -2018,9 +2263,8 @@ export default function (pi: ExtensionAPI) { }); } } catch (err) { - console.error( - "[pi-bgrun] session_start reconstruction failed:", - (err as Error).message, + logError( + `[pi-bgrun] session_start reconstruction failed: ${(err as Error).message}`, ); } @@ -2078,24 +2322,32 @@ export default function (pi: ExtensionAPI) { // ── bgrun tool ──────────────────────────────────────────────────────────── + // Guidance the model needs before it reaches for the wrong tool. Read by + // upstream pi from `promptGuidelines`; on oh-my-pi it is folded into the + // description (see toolDescription). + const BGRUN_GUIDELINES = [ + "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.", + "Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.", + `When the project's digest config defines \`type\` entries, pass the matching \`type\` (e.g. type: 'test') so the wake selects the right scorecard; the vocabulary comes from the project's \`${CONFIG_FILE_HINT}\` digest entries.`, + "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.", + "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search or ctx_execute_file on the log path for whole-log analysis.", + ]; + pi.registerTool({ + ...ESSENTIAL_TOOL, name: "bgrun", label: "Run in Background", - description: + description: toolDescription( "Run a long shell command detached in the background. Returns 'started: ' immediately. " + - "You will be woken automatically when the job finishes. Use this instead of bash for any command " + - "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " + - "short human-readable label used in the job id, status output, and wake messages, and `type` to " + - "select the project's digest scorecard.", + "You will be woken automatically when the job finishes. Use this instead of bash for any command " + + "expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " + + "short human-readable label used in the job id, status output, and wake messages, and `type` to " + + "select the project's digest scorecard.", + BGRUN_GUIDELINES, + ), promptSnippet: "Run a long command detached in the background; get woken on completion", - promptGuidelines: [ - "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.", - "Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.", - "When the project's digest config defines `type` entries, pass the matching `type` (e.g. type: 'test') so the wake selects the right scorecard; the vocabulary comes from the project's `.pi/pi-bgrun.json` digest entries.", - "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.", - "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search or ctx_execute_file on the log path for whole-log analysis.", - ], + promptGuidelines: BGRUN_GUIDELINES, parameters: Type.Object({ command: Type.String({ description: @@ -2113,7 +2365,7 @@ export default function (pi: ExtensionAPI) { description: "Optional job type used to select the project's digest scorecard (e.g. 'test', 'build', 'lint'). " + "The vocabulary comes from the `type` fields in the project's `digest` config entries in " + - "`.pi/pi-bgrun.json`; when the project's digest config defines types, prefer passing the matching one.", + `\`${CONFIG_FILE_HINT}\`; when the project's digest config defines types, prefer passing the matching one.`, }), ), }), @@ -2203,9 +2455,8 @@ export default function (pi: ExtensionAPI) { renameSync(tmpPath, finalLogPath); logPath = finalLogPath; } catch (err) { - console.error( - `[pi-bgrun] rename to final log path failed:`, - (err as Error).message, + logWarn( + `[pi-bgrun] rename to final log path failed: ${(err as Error).message}`, ); } @@ -2273,9 +2524,8 @@ export default function (pi: ExtensionAPI) { try { pi.sendUserMessage(wake, { deliverAs: "followUp" }); } catch (e2) { - console.error( - `[pi-bgrun] wake failed for job ${id}:`, - (e2 as Error).message, + logError( + `[pi-bgrun] wake failed for job ${id}: ${(e2 as Error).message}`, ); } } @@ -2354,6 +2604,11 @@ export default function (pi: ExtensionAPI) { // nothing appends nothing, and the exit code / universal part above are // never affected. let digestBlock: { label: string; text: string } | undefined; + // Set when a NEW distinct mismatch is recorded (bounded by + // DIGEST_NO_MATCH_WARN_CAP): the same fact has to reach the agent, who + // is the only party that can fix a wrong `type`. The host log alone is + // not enough — oh-my-pi routes it to a file the agent never reads. + let digestNoMatchNote: 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 @@ -2389,11 +2644,17 @@ export default function (pi: ExtensionAPI) { if (!digestNoMatchWarned.has(warning)) { if (digestNoMatchWarned.size < DIGEST_NO_MATCH_WARN_CAP) { digestNoMatchWarned.add(warning); - console.error(warning); + logWarn(warning); + // Same facts, phrased for the model, on the wake it reads. + // One occurrence per distinct mismatch keeps this bounded. + digestNoMatchNote = digestNoMatchWakeLine( + digestTarget, + digestEntries, + ); } else if (!digestNoMatchSuppressed) { // Don't silently drop further distinct mismatches. digestNoMatchSuppressed = true; - console.error( + logWarn( `[pi-bgrun] further digest no-match diagnostics suppressed (cap ${DIGEST_NO_MATCH_WARN_CAP})`, ); } @@ -2401,9 +2662,8 @@ export default function (pi: ExtensionAPI) { } } catch (e) { // Silent-fail: a broken digest never breaks a wake (ground rule 3). - console.error( - `[pi-bgrun] digest failed for job ${id}:`, - (e as Error).message, + logWarn( + `[pi-bgrun] digest failed for job ${id}: ${(e as Error).message}`, ); } @@ -2415,6 +2675,8 @@ export default function (pi: ExtensionAPI) { if (lastLine) wake += `Last output: ${lastLine}\n`; if (digestBlock) { wake += `digest (${digestBlock.label}): ${digestBlock.text}\n`; + } else if (digestNoMatchNote) { + wake += `${digestNoMatchNote}\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 { @@ -2427,9 +2689,8 @@ export default function (pi: ExtensionAPI) { try { pi.sendUserMessage(wake, { deliverAs: "followUp" }); } catch (e2) { - console.error( - `[pi-bgrun] wake failed for job ${id}:`, - (e2 as Error).message, + logError( + `[pi-bgrun] wake failed for job ${id}: ${(e2 as Error).message}`, ); } } @@ -2448,7 +2709,7 @@ export default function (pi: ExtensionAPI) { }); child.on("error", (err) => { - console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message); + logError(`[pi-bgrun] spawn error for job ${id}: ${err.message}`); finishSpawnFailure(err); }); @@ -2630,7 +2891,7 @@ export default function (pi: ExtensionAPI) { // best-effort — a marker write failure must never break session_start } } catch (err) { - console.error("[pi-bgrun] digest nudge failed:", (err as Error).message); + logWarn(`[pi-bgrun] digest nudge failed: ${(err as Error).message}`); } } @@ -2909,6 +3170,7 @@ export default function (pi: ExtensionAPI) { } pi.registerTool({ + ...ESSENTIAL_TOOL, name: "bgtail", label: "Tail Background Log", description: @@ -3132,17 +3394,22 @@ export default function (pi: ExtensionAPI) { }; } + const BGGREP_GUIDELINES = [ + "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.", + "Prefer bggrep over bash grep or reading a bgrun log — matches are line-numbered, capped, and condensed.", + "Pass an explicit pattern when you know the tool's output format; the default only catches common failure signatures.", + ]; + pi.registerTool({ + ...ESSENTIAL_TOOL, name: "bggrep", label: "Grep Background Log", - description: + description: toolDescription( "Search the tail of a background job's log with a regex — the last 2 MiB by default, widen with `bytes` (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. Resolves the job id to the configured jobs dir itself, so there is no log path to reconstruct; ctx_execute_file can read the same file, but needs the absolute path. 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).", + BGGREP_GUIDELINES, + ), 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.", - "Prefer bggrep over bash grep or reading a bgrun log — matches are line-numbered, capped, and condensed.", - "Pass an explicit pattern when you know the tool's output format; the default only catches common failure signatures.", - ], + promptGuidelines: BGGREP_GUIDELINES, parameters: Type.Object({ id: Type.String({ description: "Job id (from bgrun's 'started: ' response)", @@ -3322,6 +3589,7 @@ export default function (pi: ExtensionAPI) { } pi.registerTool({ + ...ESSENTIAL_TOOL, name: "bgstatus", label: "Background Job Status", description: @@ -3406,6 +3674,7 @@ export default function (pi: ExtensionAPI) { } pi.registerTool({ + ...ESSENTIAL_TOOL, name: "bgclean", label: "Clean Old Background Jobs", description: diff --git a/package.json b/package.json index efc97da..34a0f0e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pi-background-run", "version": "0.6.0", - "description": "Run long shell commands detached in the background for pi; get woken on completion. Output lands in a file; context stays clean.", + "description": "Run long shell commands detached in the background for pi and oh-my-pi (omp); get woken on completion. Output lands in a file; context stays clean.", "type": "module", "engines": { "node": ">=20" @@ -18,8 +18,13 @@ "./extension/index.ts" ], "skills": [ - "./skill/run-bg", - "./skill/digest-config" + "./skills/run-bg", + "./skills/digest-config" + ] + }, + "omp": { + "extensions": [ + "./extension/index.ts" ] }, "scripts": { @@ -30,7 +35,7 @@ "files": [ "extension/", "!extension/index.test.ts", - "skill/", + "skills/", "README.md", "LICENSE" ], @@ -46,6 +51,8 @@ "pi-package", "pi", "pi-coding-agent", + "omp", + "oh-my-pi", "background", "bgrun", "ai", diff --git a/skill/digest-config/SKILL.md b/skills/digest-config/SKILL.md similarity index 94% rename from skill/digest-config/SKILL.md rename to skills/digest-config/SKILL.md index 633b9a3..5d98940 100644 --- a/skill/digest-config/SKILL.md +++ b/skills/digest-config/SKILL.md @@ -1,11 +1,15 @@ --- name: digest-config -description: Set up the pi-bgrun digest scorecard for this project. Use when the user asks to configure a digest, enable digest heuristics, or when a pi-bgrun nudge points at this skill. Samples the project's real job logs, picks a shipped preset (go-test, jest, pytest, junit-xml) or drafts a custom digest command, validates it against green AND red logs, then writes the digest section into .pi/pi-bgrun.json. +description: Set up the pi-bgrun digest scorecard for this project. Use when the user asks to configure a digest, enable digest heuristics, or when a pi-bgrun nudge points at this skill. Samples the project's real job logs, picks a shipped preset (go-test, jest, pytest, junit-xml) or drafts a custom digest command, validates it against green AND red logs, then writes the digest section into the project's pi-bgrun.json. --- # Configure a project digest scorecard -Goal: a `digest` section in `/.pi/pi-bgrun.json` whose command turns a +Project config paths are host-relative: `$CONFIG_DIR` is `.omp` under +oh-my-pi (`omp`) and `.pi` under pi. Resolve it once from the running host — +never write the other host's directory. + +Goal: a `digest` section in `/$CONFIG_DIR/pi-bgrun.json` whose command turns a job log into a short pass/fail scorecard, appended to every `bgrun` wake as `digest (