Skip to content

feat(harness): give omp an adapter, so a reply stops confirming its pickers - #93

Merged
AltanS merged 10 commits into
AltanS:mainfrom
qaz74107410:feat/omp-harness-adapter
Aug 11, 2026
Merged

feat(harness): give omp an adapter, so a reply stops confirming its pickers#93
AltanS merged 10 commits into
AltanS:mainfrom
qaz74107410:feat/omp-harness-adapter

Conversation

@qaz74107410

@qaz74107410 qaz74107410 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

feat(harness): give omp an adapter, so a reply stops confirming its pickers

Adds a harness adapter for the Herdr agent string omp (oh-my-pi v17.2.12), which currently has no
adapter and falls through to the raw terminal mirror.

Tier 1 — no interactive block kind is emitted, so no tap can become a keystroke:

export function ompBuildBlocks(lines: StyledLine[]): Block[] {
  return [{ kind: "raw", lines: stripChrome(lines) }];
}

One unconditional return. grep -rn "kind:" harness/omp/ returns exactly that line, so a non-raw
block is unreachable by construction rather than by test luck.

What it changes for an omp user

reply-action.ts opens with if (!adapter) return oneShot(args), so an omp pane takes the legacy
one-shot send — type and submit in one call. A reply sent from a phone while one of omp's pickers
holds the keyboard fires the submit key at that picker, confirming whatever row is highlighted.
Registering an adapter swaps that for type-then-verify; this one adds composerReady, a pre-flight
that reads the pane before typing and refuses on a definite false. All eleven modal captures in the
corpus answer false.

Also: omp's own composer box stops drawing underneath collie's on the mirror, a laptop-typed draft
becomes readable, and omp gets a slash palette (commandsFor("omp") currently returns [], so the
button never renders).

One change outside harness/omp/

Flagging this because it touches core rather than the adapter. composer.tsx's destructive pre-clear
sweep (ctrl+k + a Backspace run) fires before sendGuardedReply is called — before anything has
read the live pane. It decides from display, which lags a poll while following and is frozen while
the user has scrolled back, so a dialog opening in that gap takes the burst. This predates the branch
and isn't omp-specific; registering an adapter is just what makes it reachable on omp panes.

The change: the sweep runs only where a live read positively saw the composer, and the burst is bound
to the screen that authorised it. It's structural — the pre-flight returns a runner built on the one
branch where composerReady answered true, so force, a read that threw, and an adapter without
composerReady all get null and skip it. The binding reuses the mechanism dialog-guard.ts already
uses (expected_prompt, with the bridge's 409 prompt_changed). Full reasoning is in
fix(reply): a destructive pre-clear needs a live read, and a binding to it.

Happy to pull this out into its own PR if you'd rather review it separately, or drop it entirely
and ship just the adapter.

Verified on a live pane (2026-08-11)

Real omp v17.2.12 pane, Herdr 0.8.0, bridge built from this branch, driven through the real
sendGuardedReply + lib/api.ts + adapterFor("omp"), with composer.tsx's send() mirrored
line-for-line as the caller. Every request logged, with the bridge's audit.log as an independent
record.

Case Result
Empty composer No sweep, no second read; typed, verified, then submit. omp answered.
Stranded draft Sweep bound to the ╰─ … ─╯ row; guard re-read at +382 ms; submitted text verbatim, no concatenation.
/model picker up Refused blocked. One request total — the pre-flight GET. Pane byte-identical before/after (cmp: 0 differing bytes over 26 179).
force retry, picker up Typed without sweeping (no /keys request at all); submit key withheld; no row confirmed.
Binding race, staged Bridge refused 409 prompt_changed; zero /reply requests; the burst never reached the pane.

Not covered: one pane, one omp version, one terminal width, one modal shape. The tool-approval dialog
wasn't exercised (see below). I didn't reproduce the old oneShot behaviour on main for comparison,
since that would have confirmed a /model row and changed a persistent setting.

Corpus

Twenty byte-faithful captures across three sandbox panes, never a real session: 9 composer states, 11
with a modal owning the screen. No picker's confirm key was pressed — every dialog was driven onto the
screen, captured, and dismissed with Escape.

Sanitized in place, length-preserving, so column alignment and display width survive byte-for-byte and
the captures stay usable as width fixtures. Rewritten: the environment (cwd, MCP server names, session
titles, a machine-local skill: row) and vendor account state (which providers the account is signed
into). Preserved: the shape the detectors read. The corpus README publishes the greps that verify it.

I went with sanitizing since the captures came off a real machine into a public repo, and there's
precedent in the existing corpus. Let me know if you'd rather have them a different way and I'll
recapture.

What this deliberately doesn't include

  • omp's tool-approval dialog — the Tier-2 candidate, and why I split this. It's also not in the
    corpus
    , so to be precise about what's proven: raw is returned unconditionally, so it can't be
    up-levelled either way — but the claim that composerReady answers false on it is inferred from
    the eleven modals that were captured, not measured. Capturing it is the first thing a Tier-2
    follow-up owes.
  • A generic menu lift for omp's pickers — parseKeyHintFooter returns [] for nine of omp's
    footers (omp writes <key> <verb>, not <key> to <verb>), and widening the shared grammar would
    move a contract the existing /model picker is pinned against. Left as a tripwire test instead.
  • The ask tool's select dialogs — declined on the fail-closed contract.
  • A thorough conformance run. With ownFixtures: [] the suite emits nine it.todos. What
    executes is conservative detection (raw-only over all 58 captures, in full) plus the new
    composerPrompt/composerReady pairing. The real gating for this adapter is omp/chrome.test.ts,
    and the code says so rather than letting a green run imply more than it proves.
  • A fully closed pre-clear window. The bridge's re-read and its send_keys are two separate herdr
    RPCs, so a dialog opening between them is unguarded — the same residual the other guarded keystroke
    paths carry. Bounded: the submit key is still withheld, so the worst outcome is an unsubmitted
    message.
  • A proof that ╰─ … ─╯ is the composer's alone — it's a census (once in each of the 9 composer
    captures, nowhere in the other 49), not a proof, and worth re-checking against a future omp version.

Tests

Against a pristine main export, same runner and node_modules: zero new failures, +364 passing
(48 failed | 1670 passed48 failed | 2034 passed | 10 todo). The failing-file list is identical
and the 48 failing test names were diffed rather than counted — byte-identical sets, all the
pre-existing localStorage is not available environment fault on my machine. tsc --noEmit clean in
both packages; root bun run test 545 pass / 0 fail. Capability fence green. No .skip, .only, or
TODO placeholder in any new or modified file.

One edit to HARNESS_CONTRIBUTING.md: the CI-gate bullet, since the conformance suite gained a leg.
CHANGELOG.md, package.json and herdr-plugin.toml are untouched — the version seemed yours to
pick.

First contribution here, so tell me if any of this is shaped wrong and I'll rework it.

qaz74107410 and others added 10 commits August 11, 2026 14:07
The thing that has to exist before any omp detector can be written: twenty
byte-faithful captures of oh-my-pi `omp` v17.2.12 across three sandbox panes,
nine with a live composer and eleven with a modal owning the screen. omp inverts
the reference harness's composer layout in three ways — the statusline is
painted INTO the box's top border, the draft's LAST fragment sits ON the bottom
border with earlier fragments stacked above it, and autocomplete renders BELOW
the box — so not one of its adapter's chrome constants transfers and every
capture had to be re-derived rather than guessed from a screenshot.

Sanitized in place and length-preserving, ASCII for ASCII, so every row's column
alignment and display width survives byte-for-byte and the captures stay usable
as width fixtures. Two classes were rewritten: the environment (cwd, MCP server
names, session titles, a machine-local `skill:` palette row) and vendor account
state — omp prints the provider a session runs on, and `/model` marks with a dot
which providers this account is signed into. What the pass deliberately keeps is
the SHAPE the detectors read: seven configured providers, their model counts, the
signed-in/available split, every column boundary. The catalogue of the rest is
omp's shipped list, identical on every install, so it is not user data and stays
verbatim. The README publishes the greps that verify all of it.

No picker's confirm key was ever pressed. Every dialog was driven onto the
screen, captured, and dismissed with Escape.

Three traps are recorded so the next person doesn't re-learn them:

  - Line endings vary per fixture and must NOT be normalised. Twelve are
    all-CRLF, eight all-LF, none ends in a trailing newline. Edit in binary mode;
    a text-mode pass silently strips the CR and changes every byte count.
  - omp's pickers run on the alternate screen, so `pane.read source=recent`
    returns exactly viewport_rows lines with no scrollback. "There is transcript
    above the dialog" is not available as corroborating evidence the way it is
    for the other harness.
  - omp's `agent_status` stays `idle` while a picker is up; only the ask tool
    flips it to `blocked`. Nothing may gate on `blocked`.
`describeAdapterConformance` has always asserted that an adapter stays raw-only
on another harness's buffers, and that assertion has never once run. With a
single adapter in the tree there was nothing foreign to feed it, so
`foreignFixtures: []` made the leg vacuous — the promise was a comment, not a
gate, and had the reference adapter's matchers been quietly over-eager on some
other TUI's output nothing here would have said so.

The omp corpus is the first foreign cohort there has ever been. The reference
adapter's grammars now provably return raw-only across all twenty of omp's
captures, several of which are precisely the shapes most likely to fool a
matcher tuned for a different TUI: a numbered review screen, three full-screen
pickers, and a two-pane provider/model selector.

Globbed rather than listed, like the own and neutral cohorts beside it, so a
future omp capture joins the cohort the moment it lands.
An omp pane has never had the palette button: `commandsFor` returned [] for it
and composer.tsx renders the button only for a non-empty catalog, so one of the
three things Tier 0 is supposed to give every agent for free was simply missing.
That is worth closing on its own, and it has to close before anything strips
omp's own palette off the mirror — which the adapter in the next commit does,
since the palette is composer chrome and mirroring it would draw one twice.

omp publishes no command reference to read, so this is the one catalog sourced
from somebody's terminal rather than from docs. Two rules come with that, and
both are written down beside the list:

  - Only what the corpus vouches for, and the corpus speaks in three ways, each
    marked per row: a row of omp's own `/` autocomplete; omp's own tip line,
    which names /shake and /compact outright in prose in eight of the twenty
    captures and is where both descriptions come from; and the corpus README's
    capture log, which records /model, /settings and /resume as having been typed
    to produce a fixture — that the command was run and its screen captured is
    stronger evidence it exists than a palette row is, though weaker on what it
    does, so those rows are described by the screen and nothing further.
  - Nothing from the capturing machine. omp's palette mixes in rows assembled
    from the user's own install — the `skill:` entries in this corpus — which are
    neither omp built-ins nor anything another user would have.

Reading all three sources rather than the obvious one matters. The five palette
rows in the capture are everything omp fuzzy-matched for the string "new" before
the unfiltered capture ran off the bottom of the pane: an accident of one search,
not a curated set, and shipping only those would have left an omp user with less
in the app's palette than the raw mirror had been showing them.

The three commands that open a modal are marked uncommon rather than promoted.
The adapter up-levels none of omp's modals, so from a phone they land on the raw
mirror, to be driven with the special-keys pad and dismissed with Escape.
…ickers

`omp` (oh-my-pi) had no adapter, so it fell through to the raw terminal mirror.
That is a working Tier 0, but it costs an omp user two things.

The visible one: the mirror draws omp's own composer box underneath collie's,
statusline and all, so a phone screen carries two input boxes, and a draft typed
on the laptop sits in a box the app cannot read.

The one that matters: reply-action.ts opens with `if (!adapter) return
oneShot(args)`, so every omp pane took the legacy one-shot send — type AND submit
in a single call. A reply sent from a phone while one of omp's pickers held the
keyboard therefore fired the submit key at that picker, which confirms whatever
row it had highlighted. Registering any adapter at all swaps that for
type-then-verify, where the submit key waits until `extractInputDraft` can see
the text in the box, and this one supplies `composerReady` on top: a pre-flight
that reads the pane once BEFORE typing and refuses on a definite false. All
eleven modal captures in the corpus answer false, so the message never reaches
the modal either. Two honest edges: a pre-flight read that throws falls through
rather than blocking a send, and a deliberate `force` retry skips it — in both
cases type-then-verify is still what stands between the send and the submit key.

This is Tier 1 and stays there by construction, not by discipline.
`ompBuildBlocks` is one hard-coded raw block, so no tap anywhere in the app can
turn an omp derivation into a keystroke, and omp.test.ts asserts the raw-only
result over all twenty captures rather than counting cohorts. omp's tool-approval
dialog is a genuine Tier-2 candidate and is deliberately not in this change; it
needs the full bar, maintainer live-verification included.

None of the reference adapter's chrome code transfers and neither do its
constants, because omp inverts the layout. chrome.ts is a new bottom-up scanner
that pins the whole box before any probe reads a field off it — four steps, each
of which can only reject: the `╰─ … ─╯` bottom border, a capped continuation
walk above it, a capped suggestion run below it, and the top border checked
last. That bottom border is the single load-bearing literal here: omp writes the
draft's last fragment into it between a one-space gutter on each side, a shape
that occurs once per composer capture across the whole 58-fixture corpus and
nowhere else, since every other omp box closes corner to corner with an unbroken
rule. Every row is then measured against the box's OWN display width rather than
a constant, which is what keeps omp's welcome panel and its pickers — all 100
cells against this box's 189 — from being spliced into the composer's geometry.
When the shape doesn't fully match, `stripChrome` returns the same reference, so
a caller can read "unchanged" as "no chrome".

The suggestion cap is set to 64 deliberately, above every viewport height in the
corpus (59). Too high costs nothing on its own — the per-row width equality is
the guard, and the cap only stops the search walking a 10,000-line scrollback —
whereas too low is not the safe direction: it would decline an ordinary screen,
leaving the composer duplicated in the mirror and the reply pre-flight refusing a
send with "a menu or dialog is probably up" when none is.

Why every omp modal stays raw is now pinned by a test rather than asserted in a
comment. `parseKeyHintFooter` returns [] for nine of omp's modal footers — omp
writes `<key> <verb>`, not `<key> to <verb>` — and for /settings it yields only
{Jump sections} and {Close}, because `menuKeyFor` rejects the compound tokens
(`Enter/Space`, `←/→`, `Type`) that screen's real actions are named with. A modal
whose only button is "Jump sections" is worse than the raw mirror, and widening
the shared grammar to fit omp would move a contract the reference harness's
/model picker is pinned against. So the test is a tripwire on that shared
module: widen it for some future adapter and omp's file fails, forcing a
re-derivation here.

The registry gains its second entry, and the comments that used to single out
one agent by name now say what they actually mean — the reference adapter is the
only one that emits `wizard` / `preview-select` / `multi-select` / `menu`, which
is a fact about that adapter rather than about the agent string it is registered
under. `hasBlockGrammar` is "is there an adapter for this agent", and its test
says registered rather than verified: verified is the Tier-2 term of art, and
omp has not cleared that bar and does not claim to.
`commandsFor` indexed a plain object with a string that arrives from outside the
app — Herdr's `pane.agent`, i.e. whatever the host process reports itself as. An
agent string that spells an inherited member ("constructor", "toString",
"valueOf", "hasOwnProperty", "__proto__") indexes to that member rather than to a
catalog entry, and a function is truthy, so the guard handed it straight back as
if it were a command array. command-palette.tsx calls `.filter` on what it gets,
so a pane reporting one of those names took the whole palette down with a
TypeError rather than falling back to "no commands, hide the button".

`Object.hasOwn` is what quick-replies.ts already does for its twin lookup and
what `adapterFor` does for the registry; this is the third of the three finally
doing the same thing.
… keyboard

`locateComposer` decided two things at once — where the composer box is, and
whether a phone reply may be typed at all — and it was getting both wrong in
opposite directions. This settles what the rule is allowed to claim.

No measurement, anywhere.

The scanner shipped a display-width equality twice: first as
`displayWidth(a) === displayWidth(b)` across the box's rows, then as an equality
slackened by a per-cluster error bar. Both are unsound for one reason. omp padded
those rows with ITS width table, we re-measure with OURS, and the two rows carry
DIFFERENT content — the user's statusline template on the top border, their draft
tail on the bottom one — so the comparison only ever held when both tables
happened to agree about both strings. text-width.ts is documented as an
approximation of wcwidth; a detector may not depend on that approximation being
exact.

The error bar could not have rescued it, because it cannot be built from these
tables at all. `displayWidth` walks grapheme clusters and scores each by its BASE
code point, while every width table a TUI actually links against (wcwidth,
go-runewidth, Rust unicode-width) sums a cluster's code points independently.
`👨‍💻` is 2 for us and 4 for them, `👨‍👩‍👧‍👦` 2 against 8, a keycap 1 against 2 — and
the bar, built from the same ranges, scored all three as CERTAIN. It read zero
exactly where the divergence was largest, while `🗑`, `▶` and every arrow scored
as doubtful and donated slack the check had no business spending.

What that bought was total and permanent: one ZWJ emoji in a statusline template,
or typed into the operator's own message, and the scanner returns null on EVERY
frame — no statusline, no draft chip, the box duplicated on the mirror, and every
reply refused with "the input box isn't on screen" while no dialog exists. Against
that, the check never discriminated: omp paints its pickers at the composer's own
189 cells, and all nine composer captures measure 189/189 too. So it is gone,
`widthUncertainty` with it, and text-width.ts now carries the note saying why a
width compared against a width another renderer chose over different content is
not a fact at any tolerance. The nine pinned captures resolve to byte-identical
indices without it.

A tail anchor that is actually anchored at the tail.

Removing the equality left the suggestion run below the bottom border bounded only
by a cap of 64 and by having to be non-blank. That is fine for the strip, whose
worst case is cosmetic, but `composerReady` was reading the same verdict — and
what it was then claiming was "a composer bottom border exists somewhere in the
last 64 rows", not "the composer has the keyboard". omp draws its Ask dialog
inline at the tail with live transcript above it, so a dialog stacked onto the
composer answered `true`: the phone showed an idle composer with a stranded draft,
the dialog was cut off the raw mirror entirely, and a Send tap armed the pre-clear
sweep against a live modal. The only thing standing between those two verdicts was
a blank row omp happens to paint above a dialog — measured, and it really was the
whole difference: gap 0 lifted, gap 1 declined.

The rule that replaces it is a glyph predicate like every other one here. Every
widget omp can put in front of the composer — the six pickers, the five ask
screens, the welcome panel, a tool-result box, the `╭─── ✘ Error: … ───╮` banner —
is a BOX drawn at column 0, and omp reserves column 0 for boxes: transcript is
indented, and the slash palette's rows open with `❯ ` or a two-space gutter. So a
box-drawing glyph at column 0 anywhere below the bottom border means something
else owns the screen, and the whole shape is declined. It needs to recognise none
of them individually, it keeps the modal on the raw mirror where the user can read
and answer it, and it makes `composerReady` false on the screens it was invented
for. Both palette captures still lift, which is what that run is there for.

The cap stays at 64 and its comment stops arguing from the wrong quantity: it was
sized against the pane's height, when what it bounds is rows BELOW the border,
whose observed maximum is five. It is no longer load-bearing — read it as the
bound on how much torn transcript the strip may eat, which is the cosmetic cost it
really governs.

`.` is not "any glyph".

All three row predicates matched their body with `.`, which in JS excludes U+2028
LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR. `rstrip` only takes those off the
end of a row, so one anywhere else survives into the parsed text and declined the
row silently — the exact permanent-null failure the width removal was supposed to
end, reintroduced as a character class. They arrive by paste, from a PDF, a Word
document or a JS string literal, and nothing on the send path normalises text.
`[\s\S]*` in all three.

Also here: `composerPrompt`, which names the composer's own `╰─ … ─╯` row so a
destructive write can be BOUND to it (the reply path uses it in the next commit);
the conformance suite gains a leg requiring any adapter that supplies one to name
a region on exactly the screens its `composerReady` approves; and `stripChrome`
returns the same reference for an empty buffer, which its own conservatism
contract always said it would.

What is not closed, and is now said where the tier is claimed: the whole lift
hangs off ONE literal, the bottom border with its one-space gutters. The census is
real — once per composer capture, nowhere in the other 49 — but it is a
measurement of omp 17.2.12's renderer, not a proof about it, and omp already draws
a two-row full-width box at column 0 whose text lives in the top border. A widget
that ever labelled its BOTTOM border the same way would be read as a composer.
What bounds that is the box rule: it can only mislead while it is the last box on
screen, which is not the case that mattered.
…to it

Before `pane.send_text` appends at the cursor, the composer sweeps whatever is
stranded on the agent's input line: `ctrl+k` plus a run of Backspaces. Those are
the most destructive keys this app sends and the only ones nothing downstream can
withhold — the type-then-verify guard protects the SUBMIT key, and once a burst is
on the wire it has already landed in whatever owns the keyboard.

It was fired from the wrong evidence. The composer decided to sweep from
`display`, which is a snapshot: a poll behind while the mirror follows the tail,
and frozen outright while the user has scrolled back or opened find. So its own
fail-fast (`dialogPresent`) could read false against a pane that had since put a
dialog up, and the sweep went into that dialog — the AltanS#34 failure one step upstream
of where AltanS#34 was fixed. The sweep now runs inside `sendGuardedReply`, as
`onComposerSeen`, named for its precondition rather than its position.

The permission is a callable, not a condition. `preflight` returns
`{ refuse, runPreType }`, and `runPreType` is manufactured on exactly one branch:
the one where a live read's `composerReady` answered true. The call site is
`await runPreType?.()`. So `force`, a pre-flight read that threw, an adapter with
no `composerReady`, the no-adapter one-shot path, and whatever path is added next
all skip the sweep by construction rather than by anyone remembering to check —
there is no list to keep in sync. That is what the three holes the previous shape
left open had in common: each SKIPPED the read and then swept anyway, because the
sweep was gated on "did the caller hand me a callback?" instead of on the evidence.

Two behaviour changes fall out, both deliberate. `force` now overrides the
REFUSAL while still paying for the read: it is armed by a `blocked` outcome, i.e.
by the app having just proven a dialog owns the keyboard, so it is the last screen
that may receive keys. It costs nothing real — a detector that cannot see the box
cannot read our text back out of it either, so a forced send stalls the same way
it would have. And an adapter with no `composerReady` no longer sweeps at all,
which is what "no idea" should always have meant.

Ordering is not a freshness bound. `runPreType` existing proves a read SAW the
composer; it cannot prove the composer is still there when the keys land, because
the read's answer describes the pane at the moment the bridge snapshotted it and
the burst goes out a round-trip later, capped only by GET_TIMEOUT_MS. Every other
keystroke path in this app already solves that: dialog-guard.ts binds its write to
the region the guard verified, and the bridge re-reads immediately before
`send_keys` and 409s `prompt_changed` when the region has gone. The reply
pre-clear was the one destructive path not doing it. So the pre-flight now hands
its EVIDENCE forward and not just its permission — `onComposerSeen({ promptRegion })`,
the adapter's `composerPrompt` over the very buffer `composerReady` just approved
— and the composer passes it as `expected_prompt`. For omp that region is the
`╰─ … ─╯` row, which is literally the line the Backspaces are aimed at, so a
refusal is not a proxy for "something changed": it is "the line I am about to
erase is not the line I read". A refused binding aborts the send with nothing
typed. An adapter that names no region keeps today's unbound write.

TOCTOU on the other side of the burst is closed rather than deferred: when the
hook reports it actually sent keys, the guard re-reads and re-checks
`composerReady` before typing, refusing with `blocked` if the composer left the
screen in the meantime. A hook that sent nothing pays for no second read.

Finally, `locked` is re-read from a ref immediately before the burst. `send()`
checks it once, up front, but the burst goes out on the far side of the
pre-flight's round-trip, and unlike every other key this component sends it does
not go through `pressKeys`, which has its own check. A pane that died or a device
that lost write access inside that window was still getting it.

What remains: read and write are separate round-trips, so a window remains by
construction. The binding shrinks it from a network round-trip plus a human's
reaction time to the two local RPCs between the bridge's re-read and its
`send_keys`, which is where the removable risk lives; closing it entirely needs a
conditional-input primitive herdr does not have, and its `revision` is a stub, so
there is no CAS to lean on either.
…y adapter

The `onComposerSeen` docblock framed `dialogPresent` as a snapshot that "can
read false against a pane that has since put a dialog up" — a timing window.
For an adapter that lifts no interactive kind there is no window. The fail-fast
is `buildBlocks(...).some(b => b.kind !== "raw")`, so an adapter returning one
`raw` block by construction can never make it true, whatever the mirror shows.

Confirmed on a live pane with a full-screen picker filling the screen:
`dialogPresent` was false while `composerReady` was the thing that refused the
send. Worth stating where the hook's contract is written, because "one of two
gates" and "the only gate" carry different weight when reading why this hook
keys on the live read rather than on the caller having checked already.
The override test asserted the two things it is about — the retry TYPES, and it
never sweeps — and then ended, while `sendGuardedReply` was still polling. The
fake pane keeps answering with the omp picker, so type-then-verify runs its full
POLL_ATTEMPTS × POLL_DELAY_MS (8 × 350ms, harness/guard.ts) and resolves
`stalled` about 2.8 seconds later. Status is a MODULE-SCOPED singleton, cleared
per test in `beforeEach`, so that write landed in whichever test happened to be
running by then — a different victim each run, none of them near this file.

Measured: this file alone is 5/5 green on main and failed 2 of 6 runs with the
adapter in place, which is what a ~2.8s fuse into a 14s file looks like.

So wait for the terminal outcome inside the test, the way the neighbouring
refusal tests wait for theirs — the status sentinel, `didn't reach the input
box`. The assertions that carry the test's meaning now also run on the far side
of the whole poll window, so "never sweeps" is a claim about the entire send
rather than about its first 50ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`composerPrompt` returns the `╰─ … ─╯` row so the destructive pre-clear sweep can
be bound to it. The bridge only honours that binding within a window:
`verifyExpectedPrompt` (bridge/prompt-binding.ts) accepts a match only when it
ENDS inside the last DEFAULT_PROMPT_TAIL_LINES — 6 — NON-BLANK rows of the fresh
read. A one-row region therefore tolerates five rows beneath it, and omp paints
its slash palette BELOW the box: `omp--slash-palette--filtered.txt` has exactly
five, i.e. zero margin, while MAX_SUGGESTION_ROWS admits sixty-four.

One more palette row and the binding can never verify. Every free-text send on
that pane 409s with "The input box changed while clearing it" while nothing is
wrong and nothing has moved — fail-closed, but a refusal that is permanent, is
not about anything the user did, and reads as the app being broken. The trigger
is typing `/` and having enough commands match.

So the adapter declines a region it knows the bridge cannot use:
`suggestEnd - bottom - 1 > 5`. Every row in that span is non-blank by
construction — step (a) rejects a blank inside the run — and everything past
`suggestEnd` is trailing blanks the bridge's own normalization drops, so the
count is exactly the bridge's count of the rows below the region. Null means an
unbound write, which is precisely what this path did before it named a region at
all: the sweep loses its binding, never its live-read pre-flight.

The conformance suite gains the general form, next to the pairing leg: whenever
an adapter names a region for a fixture, that region must end inside the last six
non-blank rows of that fixture — checked by reproducing the bridge's two steps
(rstrip, drop blanks; last match wins), since nothing in web/ imports bridge code.
It reports the real numbers on the two palette captures (5 and 3), so the margin
this commit is about is visible in CI rather than in a comment. Claude supplies no
`composerPrompt`, so it registers the todo as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AltanS

AltanS commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Excellent work, especially the reply-path fix: the live-read gate closes a hole that predates omp, and the review confirmed it's safer for every harness. I pushed two small commits onto your branch before merging: d76a4c1 pins down a test that could leak its guard poll into later tests, and 2c44255 has composerPrompt decline a binding the bridge's 6-row tail window could never verify (a tall slash palette would have made every send 409). Thanks!

@AltanS
AltanS merged commit 88d329b into AltanS:main Aug 11, 2026
1 check passed
@qaz74107410

Copy link
Copy Markdown
Contributor Author

Thanks for the merge, and for the two follow-ups. The composerPrompt one especially: the corpus had exactly two palette captures, at 3 and 5 rows below the box, and I read the 5 as headroom when it was the ceiling. suggestEnd - bottom - 1 > 5 is the check I should have derived from verifyExpectedPrompt rather than from what the captures happened to show.

@qaz74107410
qaz74107410 deleted the feat/omp-harness-adapter branch August 12, 2026 04:52
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.

2 participants