Skip to content

feat: run on oh-my-pi, reporting its background jobs too - #19

Open
lloydsk wants to merge 9 commits into
mainfrom
lloydsk/omp-compatibility
Open

lloydsk wants to merge 9 commits into
mainfrom
lloydsk/omp-compatibility

Conversation

@lloydsk

@lloydsk lloydsk commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

feat: run on oh-my-pi, reporting its background jobs too

One extension, two hosts: pi-background-run now runs under oh-my-pi as well as
upstream pi, and treats oh-my-pi's own backgrounded bash jobs as first-class
background work instead of a second namespace the agent has to learn.

Why

On oh-my-pi the harness backgrounds long bash calls itself
(bash.autoBackground, or async: true) with ids like bg_1 — and those were
invisible to the extension. Live sessions showed the cost: an agent reads a
bg_5 id, calls bggrep bg_5, and gets "no log found" with no way to find out
what bg_5 is or where its output went. Two background systems, one of them
unexplained.

What changed

Both hosts, one codebase. The extension resolves the host's config dir
(.pi / .omp), agent dir, logger, managed timers and tool-guidance fields
through capability probes — never a host name check. Everything oh-my-pi adds is
absent on pi, where each path degrades to bgrun-only.

  • Job panel (ctx.ui.setWidget) + status line (ctx.ui.setStatus), the two
    surfaces that carry job state on both hosts at no context cost. The panel is
    live-only (running jobs), bounded to the 10 lines both hosts cap a widget at,
    with … N more running instead of a silently dropped tail.
  • pi keeps its transcript job card (entry renderer); oh-my-pi has no entry
    renderer, so there the panel and status line carry the same information.
  • On oh-my-pi all five tools declare loadMode: "essential" — otherwise the
    host demotes them to xd:// devices that have to be discovered before use.

Native jobs are visible and readable.

  • bgstatus lists them under their own heading, bgstatus bg_5 answers with
    its state (never a bare "not found"), the panel tags them native, and the
    status line counts them.
  • When oh-my-pi truncates a native job's output it spills the full text to a
    session artifact and advertises the id on the delivered async result. The
    extension resolves that id through the session artifact store, so
    bgtail bg_5 / bggrep bg_5 read the host's own spill through the same
    bounded readers used for bgrun logs — stamped with its provenance (no exit
    marker, not ours to clean), and bgstatus bg_5 names the path.
  • Native ids also name their remedy (hub cancel ids:["bg_5"], hub jobs,
    /jobs). Those jobs are the host's to steer; the extension only reports them.
  • The status line adds N result pending in the window between a native job
    settling and its result being injected — the one state nothing else reports.

The index follows the transcript. oh-my-pi does not re-emit session_start
when the transcript changes: /new, /resume, a fork and a tree branch emit
session_switch / session_branch instead (its own internal event bridge
re-initialises on all three). A fresh session used to keep reporting the previous
one's jobs. Finished jobs are dropped with the transcript that ran them; running
jobs stay, since they outlive it.

Guidance. Spawning a job that could have been scored now says so: when a
digest is configured with type entries and the job has none, the started:
line names the vocabulary and the type to pass — the one moment the answer is
still actionable, and no extra I/O since the config is already resolved. The
bgrun description gained the durability clause that makes the native-vs-bgrun
choice decidable on turns that never read the skill, and the skill states the
rationale behind its thresholds and which parts are oh-my-pi-only.

Fixes found along the way.

  • A detached pi.on aborted the entire extension load on oh-my-pi: on is a
    class method that reads this.extension, so saving it to a variable threw and
    the host dropped the extension. The test harness now enforces the same
    receiver rule, so this cannot return unnoticed.
  • tool_execution_end fires on every tool call; on oh-my-pi each one re-ran the
    stale revalidation (a 256 KiB tail read per handle-less record) and two
    UI re-renders. The rebuild is now gated on the host's job set actually
    differing.
  • bgstatus' own not-found path was the last dead end left: it omitted the
    id-shape hint that bgtail/bggrep give, which triggered exactly when a
    bg_N had aged out of the snapshot's 5-row window.
  • condenseLogLines folded an exact repeated pair into one bare line, so the
    reader saw fewer lines than the log had with nothing to say a line was gone.
    A pair is now emitted verbatim; runs of 3+ still fold into one line carrying
    its [xN] count, and total-cap budgeting counts what is actually emitted.
  • ctx_execute_file references left the agent-facing strings: it is
    context-mode's sandboxed reader, not a pi or oh-my-pi tool (neither host ships
    it), so telling an agent to run it could point at nothing. Those strings now
    say to read the log path directly; the skills keep the recipe but mark it
    environment-provided.

Adversarial review

Four independent reviews (runtime paths, host parity + test quality, security,
docs-vs-code) ran against the branch before this PR. Everything they found is
fixed; the three that a reviewer should care about:

  • A recycled bg_N id served the previous job's output. oh-my-pi mints
    bg_N from the first free slot after evicting a settled row (~30s after its
    result is consumed) while the spilled artifact stays on disk for the session,
    so bgtail bg_1 could read — and stamp as this job's — the previous job's
    log. Entries now carry the job's startTime (looked up settled-first at
    delivery, since running may already hold the newcomer), are dropped when the
    id's row no longer matches, and the map is cleared on a transcript change,
    where the host recycles the whole id space.
  • A remapped artifact read as a delta. The tail bookmark was keyed by id
    with no path, so a new file with a matching first line and a non-shrinking size
    looked like "the same log, one new line", hiding its head. The bookmark now
    carries the resolved path and a change resets to a full tail.
  • Host strings reached terminal surfaces unstripped. oh-my-pi takes a job's
    label verbatim from the command line, so a label carrying ESC could set a
    window title, plant a hyperlink, or forge an extra panel row. The strip the
    extension already applied to its own names now covers host text at the
    snapshot read and at every display site. The artifact path is also required to
    sit inside the session's artifact directory rather than being believed on
    existsSync alone, and failed reads are no longer stamped as the host's spill.

Also fixed from that pass: unknownJobHint no longer describes a job manager
(and a hub remedy) to a host that has neither; four tests that could not fail
were rewritten so they can (the session_branch re-index, the refresh gate, the
managed-timer integration, and the async-result filter); the fake session manager
now enforces the receiver rule its real methods have; and the bggrep bytes
guidance no longer names a value that stops short of a capped log.

Verified

  • oh-my-pi 18.2.6 (Homebrew binary) and upstream pi via the installed type
    surface; tsc --noEmit clean on both.
  • 216 tests, including new ones for the native artifact bridge (with provenance
    and containment), id recycling, the artifact remap, a hostile label, the
    pending-delivery count, the transcript-change re-index, the managed-timer
    integration, the panel's native rows, the spawn-time digest hint, and the
    condenser's fold boundary. The guards bite: reverting the recycling check or
    the control-char strip fails the matching test.
  • Live on oh-my-pi: a 165 KB seq 1 30000 backgrounded with async: true
    bgstatus bg_1 reported the artifact path, bggrep bg_1 returned
    line-numbered matches out of 30,000 lines, bgtail bg_1 read the tail, all
    stamped as the host's spill (re-checked after the containment rule landed).
    The panel picks up a native job from its tool result and drops it on the
    delivered async result with no bgrun involvement; the condenser fix was
    re-checked through the real tool path (dup / dup / spinner [x3] / done).
  • npm pack --dry-run ships 7 files (extension, both skills, README, LICENSE,
    package.json) and no test files; bun install --frozen-lockfile is a no-op.

Deliberate non-goals

  • Native jobs stay read-only. No adopt, cancel or clean, and no use of the
    internal @oh-my-pi/pi-coding-agent/async subpath that would allow it:
    unsupported, host-version-fragile, and absent on pi. The extension reports
    them and names the host's own remedy.
  • AsyncJobManager/delivery control, session_stop deferral, transcript
    cards via registerMessageRenderer, widget component factories, saveArtifact
    digests, compaction and memory hooks
    — each considered and skipped, with the
    reasoning in the relevant commit bodies. Transcript cards in particular would
    put custom_message entries into LLM context, which is what this plugin exists
    to avoid.
  • No pre-exec bash interception (tool_call rewrite/block): silently
    redirecting a user's command is magic, and blocking would fight the harness's
    own auto-background.
  • The artifact map is in-memory by construction. bg_N ids restart at 1 and
    artifact ids are session-scoped, so a persisted mapping could resolve to a
    different job's output after a restart.

For the reviewer

  • Rebased on current main (7d10856), 9 commits, nothing pushed to this
    branch yet. The repo squash-merges, so the title above is what release-please
    parses: feat → minor bump pre-1.0 (0.6.0 → 0.7.0); the docs, refactor
    and test commits are hidden from the changelog.
  • CI is expected to run bun install --frozen-lockfile, tsc --noEmit,
    bun test, and the npm pack allowlist check — all four pass locally.

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).
The recent-finished section in the editor panel was more distracting than
useful: a list of old jobs above the editor competes with the work in progress,
and `bgstatus` already answers "what ran" on demand.

The panel is back to live state alone — header plus one row per running job —
and the newest-finished lookup it used remains, feeding the status line only
(`⏳ N running`, then `✅ <name> exit=0`), which is what carries an outcome after
the panel goes away. Bounding is unchanged in effect and simpler in form: up to
eight running rows, then `… N more running`, so a host's own
"... (widget truncated)" note still cannot appear.

Tests: the finished jobs now have to stay OUT of the panel (and no `exit=` row
may appear), the status-line transitions are still pinned, and a 14-job burst
proves the panel stays inside the 10-line cap while stating how many rows it
hid.

Docs updated to match, including the Host differences bullet, which now says
plainly that neither surface is a history view.

Unreleased (refines d04fc84, which has not shipped), hence no changelog entry.
…pawn

Two gaps that made the plugin harder to use than it needs to be on a host that
backgrounds long bash calls itself.

Native jobs are now visible. oh-my-pi backgrounds long `bash` calls on its own
(`bash.autoBackground`, or an explicit `async: true`), tracks them in-process,
and delivers their output as an async result. Those are not bgrun jobs — ids
look like `bg_1`, there is no log here, and they are cancelled on session switch
— but they are background work the session is running, so:

- the editor panel lists them after the bgrun rows, tagged `native`, and the
  status line counts them (`📊 bgrun: 1 running · 1 native`, `⏳ 2 running`);
- `bgstatus` lists them under their own heading (running always, finished only
  with `includeDone`), and `bgstatus bg_5` answers with what that job is and
  where its output went instead of a bare "no job found";
- `bggrep`/`bgtail` given a `bg_N` id say the same thing, and any unresolvable
  id now names the expected shape (`<name>-<epoch>-<pid>`).

Read-only throughout: bgrun never adopts, cleans, or cancels these. It reads
them through `ctx.getAsyncJobSnapshot()`, which upstream pi does not have, so
every path degrades to bgrun-only there. The panel is otherwise event-driven and
a native job fires none of our events, so it also refreshes on the host's own
`tool_execution_end` and on the delivered `async-result` message; both hooks bail
out immediately where the snapshot API is absent.

Spawning a job that could have been scored now says so. The digest selector keys
off `type`, but the vocabulary lives in the project's config, which the agent has
no reason to read — the only feedback was a mismatch *after* the job ran, capped
per session. When a digest with typed entries is configured and a job is started
without a type, the `started:` line names the configured types and the one to
pass. The config is already resolved at spawn, so this costs no extra I/O and
nothing on the happy path.

Guidance, for whichever host the model is on:

- the `bgrun` description gains the durability clause (the log survives a
  restart and stays greppable) — most turns never read the skill, and this is
  what makes the native-vs-bgrun choice decidable without it;
- the skill states why the thresholds differ (>30s is the session, >100 lines is
  the transcript), which generalizes to hosts not seen yet;
- the skill declares which parts are oh-my-pi-only, so a pi-side reader is not
  hunting for mechanisms that do not exist there;
- `bgstatus`'s snippet now advertises host-managed jobs too.

Verified on omp 18.2.6: the panel picks up a native job from its tool result and
drops it on the async result with no bgrun involvement; `bgstatus` lists and
explains native ids; the spawn hint renders as
`digest: configured types test, build — pass type: "test" to attach a scorecard`.
208 tests, tsc clean.
The bgrun guideline bullet and the `type` parameter description both ended by
telling the model where the digest vocabulary lives (the project's config file),
which is an instruction to go read a file the tool now reports itself. Both now
name the actual mechanism: pass the matching `type`, and bgrun's `started:` line
lists the configured types when you leave it out.

The `CONFIG_FILE_HINT` constant existed only to spell that path for those two
strings, so it goes with them. Behaviour is unchanged; this is wording the model
reads on every request (a promptGuidelines bullet on pi, folded into the
description on oh-my-pi), and it is now shorter as well as accurate.

Unreleased (refines 78076f2), hence no changelog entry.
Review of the omp port plus four integration gaps found by reading oh-my-pi's
source (an adversarial pass and a capability survey of the host).

Fixed — all reachable in the shipped code:

- A detached `pi.on` aborted the whole extension load on oh-my-pi. `on` is a
  class method that reads `this.extension` (loader.ts:200), so extracting it to
  register the host-only events threw `undefined is not an object` and the host
  dropped the extension entirely. Every method that gets saved to a variable is
  now called on its owner, and the test harness's `on` throws when detached, so
  this cannot come back unnoticed. (Caught by loading the extension on a live
  omp, not by the suite.)
- `tool_execution_end` fires on every tool call, and on omp each one re-ran the
  stale revalidation (a 256 KiB tail read per record with no live child handle)
  plus two host-side UI re-renders. The rebuild is now gated on the host's job
  set actually differing — the same signature the status line derives.
- `bgstatus`' own not-found path was the one dead end left: it said "No job
  found" without the id-shape hint that bgtail/bggrep give, so a `bg_N` that had
  aged out of the snapshot's 5-row window read as if bgrun had never heard of
  the namespace. It now shares `unknownJobHint`.
- `digestTypes` is single-sourced (spawn hint, no-match log line, wake note), so
  the three surfaces reporting the project's digest vocabulary cannot disagree.

Added:

- **Native output is readable.** When oh-my-pi truncates a backgrounded bash
  job's output it spills the full text to a session artifact and advertises the
  id on the delivered async result. bgrun resolves that id through the session's
  artifact store, so `bgtail bg_5` / `bggrep bg_5` read the host's own spill
  through the same bounded readers used for bgrun logs, stamped with its
  provenance (no exit marker, not ours to clean), and `bgstatus bg_5` names the
  path. In-memory on purpose: `bg_N` ids restart at 1 and artifact ids are
  session-scoped, so a persisted mapping could point at another job's output.
  Nothing is written, adopted, or cleaned. Verified live on omp 18.2.6 with a
  165 KB `seq 1 30000`: artifact `0` read back by both readers.
- **Native ids now name their remedy** — `hub cancel ids:["bg_5"]`, `hub jobs`,
  `/jobs` — in every message that mentions a native job. Those jobs are the
  host's to steer; bgrun only says how.
- **A settled-but-undelivered result is visible.** The snapshot's delivery queue
  is the one native state nothing else reports, so the status line adds
  `N result pending` in the window between a job settling and its result being
  injected.
- **The index follows the transcript.** oh-my-pi does not re-emit session_start
  when the transcript changes: `/new`, `/resume`, a fork and a tree branch emit
  `session_switch`/`session_branch` (its own internal bridge re-initialises on
  all three), so a fresh session kept reporting the previous one's jobs. Finished
  jobs are dropped with the transcript that ran them; running jobs stay.

211 tests (3 new: the artifact bridge with provenance, the pending-delivery
count, and the transcript-change re-index), tsc clean.
`condenseLogLines` merged every consecutive duplicate but annotated only runs of
3+, so an exact pair was emitted as one bare line: the reader saw fewer lines
than the log had, with nothing on screen to say a line was gone. A pair now
emits verbatim (twice); runs of 3+ still fold into one line carrying `[xN]`,
which is what makes a fold legible.

Reported as "not condensing the correct number of lines", and confirmed against
the code's own rule: 8 lines in, 5 out — `["A","dup","dup","B","trip"x3,"C"]`
became A / dup / B / trip [x3] / C.

- Total-cap budgeting moved inside the per-emission loop. It counted one line
  per run, so an unfolded run could overrun the cap.
- Pinned the boundary end-to-end (spawn → wake → bgtail): a pair reaches the
  reader twice with no note, a triple folds to `spinner  [x3]` with exactly one
  run note. The pair case was untested before, which is why this survived.
- The bgtail/bggrep notes and descriptions no longer name `ctx_execute_file`.
  It is context-mode's sandboxed reader, not a pi or oh-my-pi tool — neither
  host ships it, and neither host ships an equivalent — so telling an agent to
  run it could point at nothing. They now say to read the log path directly;
  the skills keep the recipe but mark it environment-provided, and the
  surrounding prose says how to use it when it exists.

Public docs state the rule the same way ("repeated lines collapsed") and already
described the size/condensation behavior, so only the pair clause needed adding.

212 tests, tsc clean.
The `/bgtail` slash command calls the same core as the `bgtail` tool but skipped
`annotateNative`, so a human reading a native job's spilled artifact got the
host's file with no indication of its provenance — the one thing the stamp
exists to say (no exit marker, not ours to clean). The tool path and the command
path now behave identically, and the test asserts it through the command
handler. The core's note records the convention: it returns the read unstamped,
because the stamp needs the job id the caller already has.

Found by reviewing the branch before the PR, not by the suite.
…eview

Four reviews (runtime, parity/tests, security, docs) over the branch. The two
that mattered were data defects the suite could not see, plus one reachable
injection path.

Native output could be served for the wrong job:

- `nativeOutputs` was never invalidated. oh-my-pi mints `bg_N` from the first
  free slot after evicting a settled row (~30s after its result is consumed),
  while a spilled artifact stays on disk for the session — so the same id comes
  back as a *different* job, and `bgtail bg_1`/`bgstatus bg_1` would read and
  stamp the previous job's output. Entries now carry the job's `startTime`
  (looked up settled-first at delivery time, deliberately: `running` may already
  hold the newcomer) and are dropped when the id's row no longer matches; the map
  is also cleared on a transcript change, where the host recycles the whole id
  space.
- The delta-tail bookmark was keyed by job id with no path, so a remapped
  artifact whose first line matched and whose size had not shrunk read as "the
  same log, one new line" — silently hiding the head of a file the reader had
  never seen. The bookmark now carries the resolved path and a change resets to a
  full tail under its own header.

Host strings reached terminal-interpreted surfaces unstripped:

- oh-my-pi takes a background job's `label` verbatim from the command line, and
  the panel, status line and `bgstatus` rendered it (and ids) raw — so a label
  carrying ESC could set a window title, plant a hyperlink, clear the screen, or
  forge an extra panel row. The strip the extension already applied to its own
  job names is now the single rule for host text too: applied at the snapshot
  read (label/type) and at every site that displays an id, including the bgrun
  command fallback in the new status line.
- A native artifact path was believed on `existsSync` alone. It is now required
  to sit inside the session's artifact directory (realpath-checked) and the id to
  be well-formed; the write side validates, caps the delivered batch and bounds
  the map. The branch's own test had pinned the trusting behaviour — it now
  models a real artifacts directory and asserts both directions.
- `annotateNative` stamped failed reads as "the host's spill", asserting
  provenance for output nobody read. Error results pass through unstamped.

Parity:

- `unknownJobHint` inferred "native" from the id shape alone, so upstream pi —
  no job manager, no artifacts, no `hub` tool, no `/jobs` — was told its `bg_N`
  id was a host-managed job and to cancel it with a tool that does not exist
  there. The hint now takes the same probe the callers already hold.
- Four tests could not fail: the `session_branch` re-index (asserted state that
  was already true), the native-refresh gate (never exercised in either
  direction), the managed-timer integration (the fake never provided managed
  timers), and the "other messages are ignored" filter (the gate masked it).
  Each now changes the state only the behaviour under test can account for.
- The fake session manager now enforces the receiver rule its real methods have,
  the way the fake `on` already did.

Docs (from the documentation review):

- The `bggrep` `bytes` guidance named 67108864 as "the whole capped log", which
  is short of it by the wrapper's 4 KiB of notices and exit marker — the head it
  was asked for stays unread. README rows aligned to the same "ceiling + 4 KiB".
- README's "two presentation details" now lists what it actually lists, the CI
  workflow's hand-run test count is current, and two source comments no longer
  spell pi's config dir where the code uses `$CONFIG_DIR`.

Verified: 216 tests (4 new: recycled id, remapped artifact, hostile label,
managed-timer integration), tsc clean, and re-run live on oh-my-pi 18.2.6 —
a 165 KB spilled artifact still resolves and reads under the containment rule,
and the bgrun path is unchanged end to end.
@github-actions

Copy link
Copy Markdown

CI report

Check Result
tsc --noEmit success
tests success
npm pack --dry-run success (7 files in tarball)

Ref: a37691fbb0051b3ecf09ddfa8d625c61f6f4dc04

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant