Skip to content

fix(composer): unify safe composer detection across backends - #2066

Open
kunchenguid wants to merge 28 commits into
mainfrom
fm/fm-composer-thin-adapter-refactor-r1
Open

fix(composer): unify safe composer detection across backends#2066
kunchenguid wants to merge 28 commits into
mainfrom
fm/fm-composer-thin-adapter-refactor-r1

Conversation

@kunchenguid

@kunchenguid kunchenguid commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Intent

Follow-up commit on the already-validated composer thin-adapter branch (PR #2066, checks green): one docs-only change updating docs/verification/runtime-backends.md 'Composer classification matrix' with the genuine trusted-checkout live guard rerun performed after checks passed - the already-agreed post-green step resolving the test step's test-live-trust warning. The pipeline's untrusted gate worktree could not verify claude/grok/muse (first-launch trust dialogs, which the guard refuses to confirm by design); the rerun from the trusted checkout at the final validated head verified all six installed harnesses (claude 2.1.226, codex 0.146.0, opencode 1.14.46, pi 0.84.0, grok 1.0.0, muse 0.1.0-R708.1), the strict blank-row deferral, and the hardened zellij false-positive probe live, with no prompt submitted to any harness. Honesty contract: the record states only what actually ran; kimi remains explicitly absent with no live result claimed. Everything else on the branch is already validated; no code changes in this commit.

What Changed

  • Centralize composer shape classification, Unicode whitespace handling, and strict container-proof rules in fm-composer-lib.sh, leaving backend adapters responsible only for capture capabilities.
  • Route Kimi readiness and backend submit verification through the shared classifier, replacing Zellij’s pane-change heuristic with composer-scoped paste and delivery confirmation.
  • Add portable and live matrix coverage for supported harness shapes and document the trusted-checkout verification results, with Kimi explicitly recorded as absent.

Risk Assessment

🚨 High: The shared classifier has a source-proven false-empty path that can overwrite unsubmitted user input on three supported backends and requires an explicit product tradeoff to resolve.

Testing

Confirmed the commit changes only the verification document, the installed harness versions and explicit Kimi absence match its recorded output, the live guard remains safely opt-in, and the updated end-user documentation renders correctly. The live run was not repeated because this gate worktree is intentionally untrusted; the change records the supplied trusted-checkout rerun.

Evidence: Rendered Composer classification matrix documentation
<!doctype html>
<html><head><meta charset="utf-8"><title>Composer classification matrix</title>
<style>
body{max-width:980px;margin:40px auto;padding:0 28px;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2328}h2{border-bottom:1px solid #d0d7de;padding-bottom:.35em}pre{padding:16px;overflow:auto;background:#f6f8fa;border-radius:6px;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}a{color:#0969da}p{margin:1em 0}
</style></head><body id="top">
<h2 id="label-Composer+classification+matrix">Composer classification matrix<span><a href="#label-Composer+classification+matrix">&para;</a> <a href="#top">&uarr;</a></span></h2>

<p>The shared composer classifier (<code>bin/fm-composer-lib.sh</code>, <code>fm_composer_classify_screen</code>) owns every composer shape fleet-wide; each backend contributes only a capture and a capability descriptor. The live half of that guarantee was verified on 2026-08-10 from an already-trusted checkout at the branch&#39;s final validated head, against every installed harness on tmux 3.6a, macOS arm64, on an isolated private socket, with no prompt submitted to any harness. An earlier gate-worktree run could not verify claude, grok, or muse because the pipeline&#39;s isolated worktree is untrusted and their first-launch trust dialogs are a real unreadable-composer state the guard refuses to confirm; this trusted-checkout rerun covers all three, and the hardened zellij probe additionally observed its typed text before Enter and accepted only a non-delivery classifier verdict.</p>

<pre>FM_COMPOSER_MATRIX_LIVE=1 tests/fm-composer-matrix-live-e2e.test.sh</pre>

<p>Observed output:</p>

<pre>ok - claude (2.1.226 (Claude Code)): real idle composer classifies empty
ok - codex (codex-cli 0.146.0): real idle composer classifies empty
ok - opencode (1.14.46): real idle composer classifies empty
ok - pi (0.84.0): real idle composer classifies empty
ok - grok (grok 1.0.0 (3cd0d0cbcebe) [stable]): real idle composer classifies empty
# harness absent, not verified here: kimi
ok - muse (Muse Code 0.1.0 (0.1.0-R708.1)): real idle composer classifies empty
ok - strict posture live: a blank shell row classifies unknown and injection defers
ok - zellij (zellij 0.44.0): unrelated pane change never confirms delivery (verdict: unknown)
ok - live composer-matrix guard verified 8 live surface(s)</pre>

<p>Every installed harness&#39;s real idle composer reached a proven <code>empty</code>, including pi through the tmux foreground-process identity probe, grok through the titled-bottom-border tolerance, and opencode through the left-bar shape; codex and opencode first parked on vendor update-available modals that the strict classifier correctly refused until the guard&#39;s single non-submitting Escape dismissed them. The strict blank-row posture held live (a blank shell row deferred injection), and a zellij pane changing for reasons unrelated to submission never confirmed a delivery, replacing the retired content-diff heuristic&#39;s false positive. Kimi was not installed on the verification machine; its bordered shape is pinned by the portable byte-capture regressions in <code>tests/fm-composer-lib.test.sh</code>, which also carry the other five adapters&#39; capability profiles for every harness under both a UTF-8 locale and <code>LC_ALL=C</code>. This guard is the refresh command after any harness upgrade; rerun it and update the versions above rather than trusting this table across releases.</p>

<p><code>zellij action dump-screen --pane-id &lt;id&gt; --ansi</code> was verified at zellij 0.44.0 to preserve ANSI styling (real Claude Code rendered inside a zellij pane dumped <code>ESC[m</code> <code>❯</code> U+00A0 for its idle composer row), which is the capability the zellij composer classifier reads.</p>
</body></html>
Evidence: Commit scope, installed-version inventory, and guard opt-in transcript
$ git diff-tree --no-commit-id --name-status -r 59eb8f461693e02814da03f732248533230f0d84
M	docs/verification/runtime-backends.md

$ installed harness version inventory
claude   2.1.226 (Claude Code)
codex    codex-cli 0.146.0
opencode 1.14.46
pi       0.84.0
grok     grok 1.0.0 (3cd0d0cbcebe) [stable]
kimi     ABSENT
muse     Muse Code 0.1.0 (0.1.0-R708.1)
tmux     tmux 3.6a
zellij   zellij 0.44.0

$ tests/fm-composer-matrix-live-e2e.test.sh
skip: set FM_COMPOSER_MATRIX_LIVE=1 to run the live composer-matrix guard

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 error
  • 🚨 bin/fm-composer-lib.sh:1221 - A Pi draft containing only between separator rows is classified empty on identity-less backends (Zellij, cmux, Orca), allowing injection to overwrite real input or submit verification to falsely succeed. Tests explicitly demonstrate this at tests/fm-composer-lib.test.sh:249-253. Please decide the intended tradeoff: the earliest shared safety boundary is _fm_composer_classify_bare_pi_overlap, where an overlap without identity should defer as unknown, though that requires another signal to preserve empty Claude detection on identity-less backends.
✅ **Test** - passed

✅ No issues found.

  • git diff-tree --no-commit-id --name-status -r 59eb8f461693e02814da03f732248533230f0d84
  • Queried installed versions for claude, codex, opencode, pi, grok, kimi, muse, tmux, and zellij
  • tests/fm-composer-matrix-live-e2e.test.sh without opt-in, confirming the guard safely skips
  • git diff --check 59eb8f461693e02814da03f732248533230f0d84^ 59eb8f461693e02814da03f732248533230f0d84 -- docs/verification/runtime-backends.md
  • Rendered the updated Composer classification matrix section to reviewer-visible HTML
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.


Reviewer companion: hazard-by-hazard map and evidence

What this is

One comprehensive, captain-approved refactor of the composer-classification architecture (audit data/fm-composer-consolidation-audit-s1, decisions corr=a66f17eb1dffbe7f): every composer shape now lives in ONE shared owner (bin/fm-composer-lib.sh, fm_composer_classify_screen), the five backend adapters are capture-only plus a declarative capability descriptor (styled/cursor/identity/rows as data), fm-spawn's kimi regex (a fourth shape copy) is retired, the whole 30-cell correctness matrix is fixed, and the captain's STRICT blank-row posture applies fleet-wide. A new harness shape is teachable in one place and every backend learns it in the same commit.

Matrix before/after (idle-empty composer; correct verdict = empty)

Before = production code at 85e750a, measured by the audit. After = this branch (portable byte-capture regressions under UTF-8 AND LC_ALL=C; live guard for the tmux column).

harness tmux (UTF-8) tmux (LC_ALL=C) cmux orca herdr zellij
claude empty → empty pending → empty empty → empty unknown → empty empty (C: pending) → empty unknown → empty
codex empty → empty empty → empty unknown → unknown* pending(banner!) → unknown* empty → empty unknown → empty
pi empty(permissive) → empty(identity-proven) same unknown → unknown* unknown → unknown* empty → empty unknown → unknown*
muse empty → empty empty → empty unknown → empty unknown → empty unknown → empty unknown → empty
opencode unknown → empty unknown → empty unknown → empty unknown → empty unknown → empty unknown → empty
grok unknown → empty unknown → empty empty → empty empty → empty empty → empty unknown → empty

* the CORRECT reachable verdict for that capability profile (approved design, report §4.3 pt 7): a plain capture cannot distinguish codex's idle hint from typed text (the old orca pending came from a startup banner, a confidently wrong answer), and a backend without an identity probe cannot prove pi's blank separated row (the strict rule). Net: 13/30 correct before (2 locale-dependent, 1 permissive artifact, 1 false pending) → 25/30 proven-empty + 5 principled defers, 0 false pending, 0 false empty.

The discrete safety fixes, each its own commit

The base commit plus 18 review-round fix commits each prevent a concrete failure. Review rounds were driven gate-by-gate with captain decisions (yolo off); the review agent's adversarial passes found real hazards in both the original code and its own fixes, and each is separately reviewable:

Base commit ba885d0 — the consolidation itself plus the audit's six matrix fixes: (1) locale-safe Unicode-space normalization closing #1988 for tmux/herdr (claude's +U+00A0 read pending under LC_ALL=C, deferring every away-mode escalation in daemon contexts; cmux's adapter-local NBSP case deleted); (2) muse's in the shared glyph set (empty muse composers read unknown forever on herdr/cmux/orca); (3) orca learns the borderless bare shape and drops backward paging (every steer to a borderless harness exited unconfirmed; a codex startup banner classified as pending composer text); (4) tmux tolerates grok's titled bottom border (every grok steer failed delivery unconfirmed; away mode never injected); (5) opencode's left-bar shape (unreadable on every backend); (6) zellij's content-diff submit heuristic replaced by a real dump-screen --ansi classifier (the fleet's only FALSE-POSITIVE: any pane change - a clock, a spinner - confirmed delivery and could close a --resolve-key decision for a message never received). Plus the strict posture, the pi identity probe for tmux, and the tri-state groundwork.

Review fix commits (git log ba885d0..568d205, oldest first):

  1. c69e0e9 — prevent Pi identity-proof bypass: a lone agent-glyph draft between Pi rules read empty after a swallowed Enter; identity now reroutes it to the separated shape (pending) where the capability exists. On identity-less backends bare-wins is the captain-decided tradeoff (byte-identical to Claude's real composer; Pi delivery there is never confirmed anyway; the zellij delta independently protects sends). Also extends the matrix tests toward the full capture x profile x locale cross-product.
  2. 9b6917f — prevent the fix above regressing the fix(cmux): classify borderless Claude composers #2029 cell: absent/non-Pi identity keeps the bare verdict, so Claude's +NBSP row stays empty on tmux; removed the test fixture that masked the regression with an impossible identity.
  3. f8eb6f7 — harden box structure and titled-border geometry against the widened shape set.
  4. 9adb9bb — prevent false delivery confirmation on an unreadable baseline: the submit core's idle baseline is now tri-state and affirmative (an unreadable pre-typing read disables the unknown→empty turn-started conversion entirely); plus the zellij live-guard fails loudly on setup/source/pane-discovery failures instead of passing vacuously.
  5. 29b0844 — prevent a box's bottom border from ever being read as an input row.
  6. 612ba2e — prove the zellij probe actually typed (zellij actions exit 0 while doing nothing): observation before Enter, send-failed fails the guard.
  7. e06a12d — preserve Pi identity uncertainty and scan full left-bar drafts (a multi-row opencode draft below the hint row was missed).
  8. 2c2678d — verify zellij text landed before submitting (typing proof by observation, not exit codes).
  9. 11fc737 — scope that observation to the classified composer (a screen-wide substring passed on transcript echoes while an unrelated draft sat in the composer, and rejected legitimate wrapped sends).
  10. 0b1d28c — prove THIS paste landed by composer-scoped pre/post delta (containment passed when the text was already present, or when furniture like opencode's footer matched a short steer such as "high").
  11. e1a9072 — prevent false send-failed on wrapped pastes: cursorless extraction includes the bare composer's wrap region.
  12. 0b83c7f — prevent dead-shell injection: a stale above an exited agent's live $ prompt read empty cursorlessly; a lower shell-prompt row now invalidates the candidate.
  13. 8b68139 — handle bordered shell-glyph placeholders in extraction (a │ > Type a message... │ baseline broke the paste proof).
  14. cf63973 — prevent injection over streaming output: a bare glyph row with contiguous output directly below no longer reads empty cursorlessly (blank/rule-separated harness footers stay inert).
  15. 9b44a85 — the same claimed-region rule for box and left-bar candidates (opencode's ╹▀▀▀ floor counted as the shape's own boundary).
  16. d1b2a29 — preserve user bytes: prompt-glyph stripping applies only to the shape's actual prompt row, never wrapped continuation rows (steers quoting $ cmd lines were stranded as send-failed).
  17. dd32ccf — prevent steer text executing at a live shell: extraction can never absorb a shell-prompt-shaped row as wrap continuation (a paste landing AT the shell could delta-pass, and Enter would have executed it); agent-glyph continuations stay preserved.
  18. 3b7822e — keep wrapped glyph continuations through submit retries (transcript prompt echoes re-anchor to the live bottom prompt; wrap regions are non-glyph-leading rows only).
  19. 174c3b7 — prevent typed placeholder text confirming delivery: the idle pattern proves emptiness only in a shape's own placeholder position; styled text surviving ghost-stripping elsewhere stays pending; plain-capture regex collisions on glyph rows fail safe.
  20. 75dd1d4 — restore the two cells the previous fix over-tightened (plain glyph-bearing boxed placeholder stays empty - the grok cell; row-leading agent glyphs re-anchor so normal transcripts select the live bottom prompt instead of blocking away-mode injection indefinitely).
  21. d25a77b — align the zellij paste proof with those final semantics (boxed placeholder baselines; superseded fixture reworked).

3cea3ae/568d205 (docs alignment), 81cb5df (lint), 74d0ede (CI fixes), 59eb8f4 (trusted-checkout live rerun record) complete the branch.

Away-mode validation under STRICT (captain decision blank-row-injection-posture)

Path: inject_msgpane_input_pendingfm_backend_composer_state → thin capture → fm_composer_classify_screen (strict rule applied once, in the shared owner).

  • Defers on everything not positively proven: blank cursor rows, dead-shell prompts, bare busy-footer rows, unidentified text, modal dialogs (live-observed: codex/opencode update modals correctly refuse classification), stale composers above live activity. Pinned by tests/fm-daemon.test.sh, tests/fm-composer-lib.test.sh (test_strict_blank_row_divergence - the posture cannot silently regress), tests/fm-composer-ghost.test.sh, and live.
  • Still injects when proven: real-tmux end-to-end (tests/fm-afk-inject-e2e.test.sh) - defers on human partial input then delivers cleanly after idle; retries a swallowed Enter (wrapped digests included) with exactly one submission; delivers exactly one clean digest into a proven-empty composer. Escalation buffers survive every deferral.
  • Send path: only exact empty confirms; the tmux turn-started conversion requires an affirmatively proven idle baseline; zellij requires a composer-scoped typed-text delta - a changed pane is never again a delivery confirmation.

Live verification

FM_COMPOSER_MATRIX_LIVE=1 tests/fm-composer-matrix-live-e2e.test.sh from the trusted checkout at this branch's final head (2026-08-10): claude 2.1.226, codex 0.146.0, opencode 1.14.46, pi 0.84.0, grok 1.0.0, muse 0.1.0-R708.1 all classify empty live; strict blank-row deferral held; the hardened zellij probe (typing observed before Enter) confirmed the false-positive dead. Kimi is not installed here - no live result exists or is claimed; its bordered shape is pinned by the portable regressions. Recorded in docs/verification/runtime-backends.md "Composer classification matrix".

Sequencing vs open PRs #1995 / #2047

Both target issue #1988 in the shared owner and were open at intake. This branch implements the locale-safe normalization itself using #1995's exact function names (fm_composer_normalize_spaces_var / fm_composer_normalize_trim_var) so the implementations converge: if this merges first, #1995's normalization hunks and #2047's fm-composer-lib hunk become redundant (their doc/test additions may still be worth cherry-picking); if either merges first, this branch rebases onto the same names. Captain sequencing call.

…le matrix fixed

Consolidate every composer shape - bordered boxes (all families, geometry,
titled bottom borders), bare agent-glyph rows and their wrap regions,
opencode's left bar, and pi's identity-gated separator pair - into
fm_composer_classify_screen in bin/fm-composer-lib.sh. Adapters now
contribute only a capture and a declarative capability descriptor
(styled/cursor/identity/rows); capability differences change how confidently
a shape is judged, never what the shapes are, so a new harness shape is
teachable in exactly one place.

Correctness fixes landed as part of the consolidation (audit
data/fm-composer-consolidation-audit-s1):
- locale-safe Unicode-space normalization in the shared owner (closes the
  fleet-wide half of #1988; cmux's local byte-exact NBSP case deleted;
  naming converges with PR #1995's normalization primitive)
- muse's bare glyph joins the shared set, unbreaking muse on herdr/cmux/orca
- orca learns the borderless bare shape, drops its backward-paged composer
  window, and can no longer classify a stale startup banner as the composer
- tmux tolerates a titled bottom border, unbreaking grok steering
- the left-bar shape makes opencode readable on every backend
- zellij gets a real classifier through dump-screen --ansi, replacing the
  content-diff submit heuristic that could confirm an undelivered message
  and close a --resolve-key decision (the fleet's only false positive)
- fm-spawn's kimi launch-readiness regex (the fourth shape copy) now routes
  through the shared classifier

The strict blank-row posture applies fleet-wide (captain decision
blank-row-injection-posture): no positive container proof = unknown = defer,
replacing tmux's permissive blank-cursor-row rule. Away-mode injection was
re-validated end to end on real tmux (defer on partial input and unproven
rows, clean delivery with swallowed-Enter retry into proven-empty
composers). The tmux submit core gains a baseline-idle turn-started
conversion so pi steering stays confirmed while its working screen hides
the composer; busy conversion without that baseline remains forbidden.

Plain-capture backends now degrade a glyph row carrying trailing text to
unknown instead of a false pending, per the approved capability rule.

Portable regressions pin the full byte-capture matrix from the audit under
a UTF-8 locale and LC_ALL=C, the strict-vs-permissive divergence, and
deliberate signal separation; the opt-in live guard
(tests/fm-composer-matrix-live-e2e.test.sh) verified every installed
harness against the real classifier, recorded in
docs/verification/runtime-backends.md.
The pipeline's isolated gate worktree is untrusted, so claude, grok, and
muse stopped at first-launch trust dialogs there (the guard refuses to
confirm them by design). This rerun from the trusted checkout at the final
validated head verified all six installed harnesses, the strict blank-row
deferral, and the hardened zellij false-positive probe live.
@kunchenguid kunchenguid changed the title fix(bin): unify composer classification across backend adapters fix(composer): unify safe composer detection across backends Aug 10, 2026
@Bloh09

Bloh09 commented Aug 11, 2026

Copy link
Copy Markdown

Superseded by #2102, which carries the same composer classification work on a validated branch with green checks.

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