Skip to content

fix(composer): keep a titled composer rule from discarding a live composer - #2170

Closed
Inthuson wants to merge 1 commit into
kunchenguid:mainfrom
Inthuson:fm/composer-fix-h9
Closed

Inthuson wants to merge 1 commit into
kunchenguid:mainfrom
Inthuson:fm/composer-fix-h9

Conversation

@Inthuson

Copy link
Copy Markdown
Contributor

Summary

A focused Claude pane's composer classified unknown instead of empty under the herdr backend, which indefinitely defers away-mode escalation injection: the daemon reads unknown as "cannot prove this composer is safe to type into" and correctly declines. The composer was in fact idle and empty.

This is the herdr counterpart of the cmux borderless-composer fix (#2029). Same underlying shape, different backend surface, and it survived that fix because it fails through a different rule.

Root cause

Claude draws its borderless composer between two horizontal ─ rules. While a pane is focused, herdr overlays the pane's terminal title on the top rule, so that rule renders as dashes plus text:

─────────────────── [ Check Firstmate setup readiness ] ─────────────────
❯
─────────────────────────────────────────────────────────────────────────

The dashes-only separator predicate correctly rejects that titled row, so the separated-shape pair never opens. The composer's own bottom rule is then an unmatched separator sitting below the ❯ row the scan had already matched correctly. The separated-shape staleness rule reads an unmatched separator below a candidate as proof the candidate is stale scrollback, discards the correct match, and returns unknown.

Only a focused pane carries the title overlay, which is why supervisor panes failed while worker panes stayed readable — the asymmetry that makes this easy to miss.

The fix: narrow the consumer, not the predicate

The dashes-only separator predicate also feeds the Pi identity conjunction, where being wrong about what closes a separated composer would promote an unidentified blank region into an injection target. That is a genuine safety gate, so it is left strictly as-is.

Instead the staleness invalidation now spares a bare agent glyph that is immediately sandwiched between a rule above (solid or titled) and the window's only separator directly below — Claude's composer box. Adjacency on both edges is what preserves the staleness rule everywhere else: a glyph genuinely stranded in scrollback has transcript rows, not its own box edges, between it and the separator below.

_fm_composer_rule_row is anchored at both ends and requires every non-dash residue to be ASCII-printable or whitespace, so a row carrying box-drawing structure of its own can never pass as a plain rule.

Why two distinct defects had to be fixed for this to work

This shape needs the glyph row read as empty and the composer row kept from being discarded. Either one alone still yields a wrong verdict:

Top rule Glyph row Before After
titled (focused) ❯ + U+00A0 NBSP unknown empty
titled (focused) ❯ + ASCII space unknown empty
clean (unfocused) ❯ + U+00A0 NBSP empty empty
clean (unfocused) ❯ + ASCII space empty empty

Claude renders its idle prompt as ❯ followed by U+00A0, not an ASCII space. The NBSP half of this pair — accepting a glyph row whose only content is a non-ASCII whitespace character — was fixed for all backends by #2102 via the shared FM_COMPOSER_UNICODE_SPACES normalisation, which is why the bottom two rows already pass on main. That normalisation is character-safe rather than byte-slicing, so it holds under LC_ALL=C too (the hazard behind #883).

This PR fixes the remaining titled-rule half. The two are independent: on main the titled cases fail regardless of which whitespace character the glyph row carries, because the row is discarded before its content is ever judged.

Verification

Live probe, before and after on identical bytes. The real focused pane was captured once (2504 bytes of ANSI) and classified with both versions of the library, so no pane state change between runs can explain the difference:

pre-fix  library on captured bytes -> unknown
post-fix library on captured bytes -> empty

Live guard (FM_COMPOSER_MATRIX_LIVE=1 tests/fm-composer-matrix-live-e2e.test.sh), herdr 0.8.0 + claude 2.1.226.634:

ok - herdr (herdr 0.8.0) + claude (claude 2.1.226.634 ...): focused pane w1:p9 titled composer rule is recognized as a rule
ok - herdr (herdr 0.8.0) + claude (claude 2.1.226.634 ...): idle pane w1:p1 (focused=false) classifies empty
ok - herdr (herdr 0.8.0) + claude (claude 2.1.226.634 ...): idle pane w1:p9 (focused=true) classifies empty

The focused=true line is the exact shape that failed. Because the title overlay exists only on a focused pane, no tmux fixture can render it — this is the harness-dependent half of the required test pair, and the guard emits an explicit note rather than passing silently when no focused Claude pane was available to exercise it.

Portable regression covers all four matrix cells with no harness present and asserts the divergence deliberately, so the case cannot go vacuously green. Reverting only the consumer narrowing reproduces the exact failure (expected empty, got 'unknown').

Negative cases confirm no widening of empty. Wrongly reading a composer as empty is worse than the original bug, since it would let the daemon type into a shell prompt or over half-typed input:

  • dead shell glyphs $ > % # on a titled row -> unknown
  • blank row between two rules -> unknown (strict blank-row posture preserved)
  • typed text, titled and clean -> pending
  • glyph stranded above a genuinely live pair, non-adjacent separator, no rule above, bordered row above -> unknown
  • Pi: idle -> empty, working -> unknown, identity probe absent -> unknown

The strict separator predicate that gates Pi identity still rejects a titled rule, so recognizing the rule did not relax that gate.

Testing

  • tests/fm-composer-lib.test.sh — 29 ok, 0 failures
  • tests/fm-composer-ghost.test.sh — 33 ok, 0 failures
  • tests/fm-backend-herdr.test.sh — 171 ok, 0 failures
  • tests/fm-crew-state.test.sh — 49 ok, 0 failures
  • bin/fm-test-run.sh --changed — 57 scripts selected, 0 failures
  • bin/fm-lint.sh — clean (pinned ShellCheck 0.11.0)
  • bin/fm-doc-audience-check.sh — clean (surfaces=67 local_links=236)

Dated per-harness evidence recorded in docs/verification/runtime-backends.md under a new "Herdr focused-pane titled composer rule" section, pointing at the live guard as the refresh command.

…poser

Claude draws its borderless composer between two horizontal `─` rules. When
the pane is focused, herdr overlays the terminal title on the TOP rule, so
that rule renders as dashes PLUS text and fails the dashes-only separator
predicate. The pair therefore never opens, and the composer's own BOTTOM rule
becomes an unmatched separator sitting below the `❯` row the scan had already
matched. The separated-shape staleness rule reads an unmatched separator below
a candidate as proof the candidate is scrollback, discards the correct match,
and returns `unknown` - which defers away-mode injection indefinitely.

Only a focused pane carries the title overlay, so supervisor panes failed
while worker panes stayed readable.

Narrow the consumer rather than loosen the predicate: the dashes-only
separator test also feeds the pi identity conjunction, where being wrong about
what closes a separated composer would promote an unidentified blank region
into an injection target. It is left strictly as-is. Instead, the staleness
invalidation now spares a bare agent glyph that is immediately sandwiched
between a rule above (solid or titled) and the window's only separator
directly below - Claude's composer box. Adjacency on both edges preserves the
staleness rule everywhere else, because a glyph genuinely stranded in
scrollback has transcript rows, not its own box edges, between it and the
separator below.

This is the herdr counterpart of the cmux borderless-composer fix (kunchenguid#2029).

Verification:
- Portable regression covers the four-way titled/clean rule by NBSP/ASCII-space
  glyph matrix and asserts the divergence, so the case cannot go vacuously
  green; reverting only the consumer narrowing reproduces the exact failure.
- Live guard exercises the real focused herdr pane, which no tmux fixture can
  render, and reports explicitly when no focused pane was available.
- Negative cases confirm unchanged verdicts: a dead shell row and a blank row
  still read `unknown`, and typed text still reads `pending`.
@Inthuson

Copy link
Copy Markdown
Contributor Author

Closing this in favour of a replacement raised through the required no-mistakes submission path.

Root cause of the stuck check on this PR: no-mistakes had no fork URL recorded for this repo, so it could not push and the PR was raised by hand, which is why the submission-signature check never passed. That configuration is now fixed.

The replacement also widens the scope deliberately: the titled-rule fix here refuses a title containing a non-ASCII character, and the real failing title on the affected host began with one, so this fix would not have covered the case it was written for. The replacement corrects the width proof for non-ASCII titles instead, and refuses strictly more malformed input than this version did.

@Inthuson Inthuson closed this Aug 14, 2026
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