Skip to content

fix(bin): classify composer rows by bytes, not locale character classes - #1976

Open
nhwoodward wants to merge 7 commits into
kunchenguid:mainfrom
nhwoodward:fm/composer-locale-nbsp
Open

fix(bin): classify composer rows by bytes, not locale character classes#1976
nhwoodward wants to merge 7 commits into
kunchenguid:mainfrom
nhwoodward:fm/composer-locale-nbsp

Conversation

@nhwoodward

Copy link
Copy Markdown

Intent

The developer's goal was to fix a fleet-wide locale bug in firstmate's shared composer classifier, where an idle Claude Code composer row (U+276F followed by U+00A0) classified as pending instead of empty under the C/POSIX locale, because bash's [[:space:]] does not match NBSP and ${content#?} strips one byte rather than one character. They required the bug be reproduced first end-to-end with a real tmux capture-pane of an idle claude under LANG=C as baseline evidence, then fixed in bin/fm-composer-lib.sh so character operations are byte-safe, covering the coherent class of sibling ghost/whitespace forms the lib already models without over-engineering, and keeping the verdict vocabulary and every existing tested behavior unchanged. They asked for regression tests colocated with the existing tests/fm-composer-*.test.sh files proving the idle row reads empty under both LANG=C and en_US.UTF-8, shellcheck-clean code, a full repo suite run with pre-existing failures explicitly baselined, and adherence to the firstmate coding guidelines and CONTRIBUTING.md since this ships upstream. Scope was confined to the classifier fix plus its tests and docs with no drive-by refactors, delivered from the nhwoodward/firstmate fork as a PR against upstream referencing PR #1948, and the worker was told to recover a prior interrupted worker's uncommitted patch read-only rather than redo the work. When the validation gate surfaced two ask-user findings about overstated claims, the developer decided to fix only the first: narrow the header comment so it claims just the U+00A0 fold rather than full locale independence, explicitly leaving bin/fm-tmux-lib.sh and the second finding alone as out of scope for a future change, then respond at the gate and drive the pipeline to completion.

What Changed

  • fm_composer_classify_content in bin/fm-composer-lib.sh now folds U+00A0 NO-BREAK SPACE to a plain space through an explicit two-byte match (FM_COMPOSER_NBSP) and re-trims both content and plain_content, and strips each multibyte agent glyph (, , ) as its own literal byte sequence instead of ${content#?} / ${content#??}. Previously the trailing NBSP survived [[:space:]] trimming and ? sheared a single byte off a 3-byte glyph, so an idle Claude composer row ( + U+00A0) classified as pending instead of empty and silently deferred away-mode injections and failed steer verification. The verdict vocabulary and every previously tested verdict are unchanged.
  • tests/fm-composer-lib.test.sh gains six locale cases that re-exec bash under both C and a probed UTF-8 locale (rather than a hardcoded en_US.UTF-8, and failing loudly when no UTF-8 locale is installed): the idle row through the classifier and through the real fm_tmux_composer_row_state capture path, all three agent glyphs, typed text after the NBSP staying pending, an idle placeholder after a multibyte glyph, and a bare shell prompt staying unknown.
  • docs/verification/runtime-backends.md gains a "Composer classification locale independence" record holding the capture-pane hexdump of the real idle row, the probed per-platform [[:space:]] matrix for the other fixed-width Unicode spaces, and a two-locale re-verification loop for the composer and tmux-adapter suites that are not self-parameterized. The header comment and doc scope the guarantee to the U+00A0 fold and the glyph strip specifically, not to general locale independence; bin/fm-tmux-lib.sh geometry handling of NBSP is explicitly left out of scope and recorded as a deferred gap. The one Test-phase finding (tests/fm-backend-orca.test.sh metadata case) is pre-existing and reproduces identically on base 833a9a2.

Risk Assessment

✅ Low: The only change since the previously reviewed commit is comment and documentation prose that corrects the stated blast radius and hardens a manual verification loop, with the classifier logic, verdicts, and test assertions provably untouched, and the two empirical claims I could check on this host verified accurate.

Testing

Beyond running the committed suites, I reproduced the bug the way an end user hits it: a real idle claude in a live tmux pane, whose composer row genuinely carries + U+00A0. Under LC_ALL=C the base classifier called that live pane pending, so fm_pane_input_pending reported the composer as not proven empty and an away-mode injection would defer forever; the fix commit calls the same live pane empty under both C and en_US.UTF-8. Typing real unsubmitted text into the same composer keeps every configuration at pending, so the safety half is intact, and the matrix also shows the fix closing a macOS-UTF-8 hole where a bare $ prompt plus NBSP used to read empty. Targeted suites are green: the composer lib suite (with its 6 new locale cases) and the doc-prescribed composer/tmux-adapter set under both locales, plus the herdr, cmux and documentation suites. Visual evidence is three rendered screenshots of the live pane, the byte-level capture and the verdict matrix. The only failure seen is one orca spawn case that reproduces unchanged on the base commit and is unrelated to the classifier.

  • Evidence: Screenshot: live idle Claude composer, its bytes, and before/after verdicts (local file: /var/folders/22/8q8yl6t93s192drlxs4hkp5r0000gn/T/no-mistakes-evidence/01KZHNKVFPBWPX6K3SQX7PMWHM/screenshots/evidence-1-live-idle-pane.png)
  • Evidence: Screenshot: same live pane with unsubmitted human text stays pending (local file: /var/folders/22/8q8yl6t93s192drlxs4hkp5r0000gn/T/no-mistakes-evidence/01KZHNKVFPBWPX6K3SQX7PMWHM/screenshots/evidence-2-unsubmitted-text.png)
  • Evidence: Screenshot: classifier verdict matrix and two-locale suite runs (local file: /var/folders/22/8q8yl6t93s192drlxs4hkp5r0000gn/T/no-mistakes-evidence/01KZHNKVFPBWPX6K3SQX7PMWHM/screenshots/evidence-3-regression.png)
Evidence: Rendered HTML: live idle pane evidence page
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Composer NBSP fix - live idle pane</title>
<style>
 :root { --bg:#11131a; --card:#181b24; --ink:#e6e8ef; --muted:#98a0b3; --line:#2a2f3d;
         --bad:#ff6b68; --good:#7fd18a; }
 * { box-sizing:border-box; }
 body { margin:0; background:var(--bg); color:var(--ink); padding:26px 22px;
        font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
 .wrap { width:100%; max-width:940px; margin:0 auto; }
 h1 { font-size:20px; margin:0 0 4px; letter-spacing:-.01em; }
 h2 { font-size:11.5px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted);
      margin:20px 0 8px; font-weight:600; }
 .sub { color:var(--muted); margin:0; font-size:13px; }
 .card { background:var(--card); border:1px solid var(--line); border-radius:9px;
         padding:12px 14px; }
 .term { font:10.5px/1.32 "SF Mono",Menlo,monospace; background:#0c0e13; color:#c8c8c8;
         border:1px solid var(--line); border-radius:9px; padding:11px 13px;
         white-space:pre; overflow:hidden; }
 .term .mark { background:#33270f; box-shadow:inset 0 0 0 1px #7a5f21; border-radius:3px; }
 .term .gap { color:#4d5464; font-style:italic; }
 pre { font:11px/1.45 "SF Mono",Menlo,monospace; margin:0; white-space:pre; }
 table { border-collapse:collapse; width:100%; font-size:12.5px; }
 th,td { border-bottom:1px solid var(--line); padding:7px 9px; text-align:left; }
 th { color:var(--muted); font-weight:600; font-size:10.5px; text-transform:uppercase;
      letter-spacing:.05em; }
 code { font:11.5px "SF Mono",Menlo,monospace; }
 .bad { color:var(--bad); font-weight:700; }
 .good { color:var(--good); font-weight:700; }
 .cap { color:var(--muted); font-size:12px; margin:6px 2px 0; }
 .two { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
 .two pre { font-size:10px; line-height:1.42; }
</style></head><body><div class="wrap">
<h1>Idle Claude Code composer, classified through the real tmux adapter</h1><p class="sub">macOS &middot; tmux 3.7b &middot; claude 2.1.226 &middot; live <code>tmux capture-pane</code>, no fixtures &middot; base <code>833a9a2</code> vs fix <code>69aa5a3</code></p>

<h2>1. The live pane an away-mode injection targets</h2>
<div class="term"><div class="gap">1 blank row</div><div><span style="color:#ff6b68">╭───</span>&nbsp;<span style="color:#ff6b68">Claude&nbsp;Code</span>&nbsp;<span style="color:#c8c8c8">v2.1.226</span>&nbsp;<span style="color:#ff6b68">─────────────────────────────────────────────────────────────────────────────────────────────╮</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="font-weight:700"></span><span style="color:#ff6b68;font-weight:700">Tips&nbsp;for&nbsp;getting&nbsp;started</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="font-weight:700">Welcome&nbsp;back&nbsp;Noah!</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Ask&nbsp;Claude&nbsp;to&nbsp;create&nbsp;a&nbsp;new&nbsp;app&nbsp;or&nbsp;clone&nbsp;a&nbsp;repository&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="color:#ff6b68">───────────────────────────────────────────────────────────────</span>&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">&nbsp;▐</span><span style="color:#ff6b68">▛███▜</span><span style="color:#ff6b68">▌</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="font-weight:700"></span><span style="color:#ff6b68;font-weight:700">What&#x27;s&nbsp;new</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">▝▜</span><span style="color:#ff6b68">█████</span><span style="color:#ff6b68">▛▘</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Bug&nbsp;fixes&nbsp;and&nbsp;reliability&nbsp;improvements&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">&nbsp;&nbsp;▘▘&nbsp;▝▝&nbsp;&nbsp;</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Added&nbsp;gateway&nbsp;spend-limit&nbsp;support&nbsp;to&nbsp;Claude&nbsp;Code&#x27;s&nbsp;usage&nbsp;warni…&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">Opus&nbsp;5&nbsp;with&nbsp;xhigh&nbsp;effort&nbsp;·&nbsp;Claude&nbsp;Max&nbsp;·&nbsp;</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Added&nbsp;a&nbsp;workspace&nbsp;trust&nbsp;prompt&nbsp;to&nbsp;`claude&nbsp;agents`&nbsp;for&nbsp;untruste…&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">nhwoodward@gmail.com&#x27;s&nbsp;Organization</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="font-style:italic"></span><span style="color:#c8c8c8;font-style:italic">/release-notes&nbsp;for&nbsp;more</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">/…/01KZHNKVFPBWPX6K3SQX7PMWHM/idle-claude-cwd</span>&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</div><div class="gap">14 blank rows</div><div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">tmux&nbsp;detected&nbsp;·&nbsp;scroll&nbsp;with&nbsp;PgUp/PgDn&nbsp;·&nbsp;or&nbsp;add&nbsp;&#x27;set&nbsp;-g&nbsp;mouse&nbsp;on&#x27;&nbsp;to&nbsp;~/.tmux.conf&nbsp;for&nbsp;wheel&nbsp;scroll</span></div><div><span style="color:#c8c8c8">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span></div><div class="mark">❯ </div><div><span style="color:#c8c8c8">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span></div><div>&nbsp;&nbsp;<span style="color:#c8c8c8">Opus&nbsp;5</span></div><div>&nbsp;&nbsp;<span style="color:#c8c8c8">⏸&nbsp;manual&nbsp;mode&nbsp;on&nbsp;·&nbsp;←&nbsp;for&nbsp;agents</span></div></div>
<p class="cap">Highlighted row = the composer row. tmux trims trailing ASCII spaces, and U+00A0 is not one, so it reaches the classifier.</p>

<h2>2. The bytes that row actually carries</h2>
<div class="card"><pre>$ tmux capture-pane -p -e -t nbsp | sed -n 29p | hexdump -C
00000000  1b 5b 33 39 6d e2 9d af  c2 a0 0a                 |.[39m......|
0000000b
   1b 5b 33 39 6d = ESC[39m      e2 9d af = U+276F &#10095;      c2 a0 = U+00A0 NO-BREAK SPACE</pre></div>

<h2>3. fm_tmux_composer_state on that live pane</h2>
<table>
 <tr><th>Live pane</th><th>Locale</th><th>Base 833a9a2</th><th>Fix 69aa5a3</th><th>Effect of the fix</th></tr>
 <tr><td>idle claude</td><td>LC_ALL=C</td><td class="bad">pending</td><td class="good">empty</td><td>injection delivered instead of deferred forever</td></tr>
 <tr><td>idle claude</td><td>en_US.UTF-8 (macOS)</td><td class="good">empty</td><td class="good">empty</td><td>unchanged - the one configuration that never reproduced</td></tr>
 <tr><td>unsubmitted text</td><td>LC_ALL=C</td><td>pending</td><td>pending</td><td>still deferred - nothing typed over the human</td></tr>
 <tr><td>unsubmitted text</td><td>en_US.UTF-8</td><td>pending</td><td>pending</td><td>still deferred</td></tr>
</table>
</div></body></html>
Evidence: Rendered HTML: unsubmitted-text safety page
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Composer NBSP fix - safety half</title>
<style>
 :root { --bg:#11131a; --card:#181b24; --ink:#e6e8ef; --muted:#98a0b3; --line:#2a2f3d;
         --bad:#ff6b68; --good:#7fd18a; }
 * { box-sizing:border-box; }
 body { margin:0; background:var(--bg); color:var(--ink); padding:26px 22px;
        font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
 .wrap { width:100%; max-width:940px; margin:0 auto; }
 h1 { font-size:20px; margin:0 0 4px; letter-spacing:-.01em; }
 h2 { font-size:11.5px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted);
      margin:20px 0 8px; font-weight:600; }
 .sub { color:var(--muted); margin:0; font-size:13px; }
 .card { background:var(--card); border:1px solid var(--line); border-radius:9px;
         padding:12px 14px; }
 .term { font:10.5px/1.32 "SF Mono",Menlo,monospace; background:#0c0e13; color:#c8c8c8;
         border:1px solid var(--line); border-radius:9px; padding:11px 13px;
         white-space:pre; overflow:hidden; }
 .term .mark { background:#33270f; box-shadow:inset 0 0 0 1px #7a5f21; border-radius:3px; }
 .term .gap { color:#4d5464; font-style:italic; }
 pre { font:11px/1.45 "SF Mono",Menlo,monospace; margin:0; white-space:pre; }
 table { border-collapse:collapse; width:100%; font-size:12.5px; }
 th,td { border-bottom:1px solid var(--line); padding:7px 9px; text-align:left; }
 th { color:var(--muted); font-weight:600; font-size:10.5px; text-transform:uppercase;
      letter-spacing:.05em; }
 code { font:11.5px "SF Mono",Menlo,monospace; }
 .bad { color:var(--bad); font-weight:700; }
 .good { color:var(--good); font-weight:700; }
 .cap { color:var(--muted); font-size:12px; margin:6px 2px 0; }
 .two { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
 .two pre { font-size:10px; line-height:1.42; }
</style></head><body><div class="wrap">
<h1>The safety half: unsubmitted human text is still never typed over</h1><p class="sub">macOS &middot; tmux 3.7b &middot; claude 2.1.226 &middot; live <code>tmux capture-pane</code>, no fixtures &middot; base <code>833a9a2</code> vs fix <code>69aa5a3</code></p>

<h2>1. The same live pane, now carrying text the human has not submitted</h2>
<div class="term"><div class="gap">1 blank row</div><div><span style="color:#ff6b68">╭───</span>&nbsp;<span style="color:#ff6b68">Claude&nbsp;Code</span>&nbsp;<span style="color:#c8c8c8">v2.1.226</span>&nbsp;<span style="color:#ff6b68">─────────────────────────────────────────────────────────────────────────────────────────────╮</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="font-weight:700"></span><span style="color:#ff6b68;font-weight:700">Tips&nbsp;for&nbsp;getting&nbsp;started</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="font-weight:700">Welcome&nbsp;back&nbsp;Noah!</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Ask&nbsp;Claude&nbsp;to&nbsp;create&nbsp;a&nbsp;new&nbsp;app&nbsp;or&nbsp;clone&nbsp;a&nbsp;repository&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="color:#ff6b68">───────────────────────────────────────────────────────────────</span>&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">&nbsp;▐</span><span style="color:#ff6b68">▛███▜</span><span style="color:#ff6b68">▌</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="font-weight:700"></span><span style="color:#ff6b68;font-weight:700">What&#x27;s&nbsp;new</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">▝▜</span><span style="color:#ff6b68">█████</span><span style="color:#ff6b68">▛▘</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Bug&nbsp;fixes&nbsp;and&nbsp;reliability&nbsp;improvements&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">&nbsp;&nbsp;▘▘&nbsp;▝▝&nbsp;&nbsp;</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Added&nbsp;gateway&nbsp;spend-limit&nbsp;support&nbsp;to&nbsp;Claude&nbsp;Code&#x27;s&nbsp;usage&nbsp;warni…&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">Opus&nbsp;5&nbsp;with&nbsp;xhigh&nbsp;effort&nbsp;·&nbsp;Claude&nbsp;Max&nbsp;·&nbsp;</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;Added&nbsp;a&nbsp;workspace&nbsp;trust&nbsp;prompt&nbsp;to&nbsp;`claude&nbsp;agents`&nbsp;for&nbsp;untruste…&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">nhwoodward@gmail.com&#x27;s&nbsp;Organization</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;<span style="font-style:italic"></span><span style="color:#c8c8c8;font-style:italic">/release-notes&nbsp;for&nbsp;more</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>│&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#c8c8c8">/…/01KZHNKVFPBWPX6K3SQX7PMWHM/idle-claude-cwd</span>&nbsp;&nbsp;&nbsp;<span style="opacity:.55"></span><span style="color:#ff6b68;opacity:.55">│</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#ff6b68">│</span></div><div>╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</div><div class="gap">15 blank rows</div><div><span style="color:#c8c8c8">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span></div><div class="mark">❯ fix&nbsp;findings&nbsp;1&nbsp;and&nbsp;3</div><div><span style="color:#c8c8c8">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span></div><div>&nbsp;&nbsp;<span style="color:#c8c8c8">Opus&nbsp;5</span></div><div>&nbsp;&nbsp;<span style="color:#c8c8c8">⏸&nbsp;manual&nbsp;mode&nbsp;on</span></div></div>
<p class="cap">The composer row is now <code>&#10095;</code> + U+00A0 + <code>fix findings 1 and 3</code>
(<code>1b 5b 33 39 6d e2 9d af c2 a0 66 69 78 ...</code>). Folding U+00A0 must not make this row look idle.</p>

<h2>2. Verdict on that live pane</h2>
<div class="card"><pre># Same live pane, now carrying UNSUBMITTED human text: fix findings 1 and 3
# Reading this row as empty would type an injection over the human.

$ tmux capture-pane -p -e -t nbsp | sed -n 29p | hexdump -C
00000000  1b 5b 33 39 6d e2 9d af  c2 a0 66 69 78 20 66 69  |.[39m.....fix fi|
00000010  6e 64 69 6e 67 73 20 31  20 61 6e 64 20 33 0a     |ndings 1 and 3.|
0000001f

BEFORE  bin/fm-composer-lib.sh @ 833a9a2   LC_ALL=C
  fm_tmux_composer_state -&gt; pending
  away-mode/steer: DEFER - composer not proven empty

AFTER   bin/fm-composer-lib.sh @ 69aa5a3   LC_ALL=C
  fm_tmux_composer_state -&gt; pending
  away-mode/steer: DEFER - composer not proven empty

AFTER   bin/fm-composer-lib.sh @ 69aa5a3   LC_ALL=en_US.UTF-8
  fm_tmux_composer_state -&gt; pending
  away-mode/steer: DEFER - composer not proven empty</pre></div>
<p class="cap">typed-text-live-verdict.txt - the idle row's own transcript is idle-claude-live-verdict.txt.</p>
</div></body></html>
Evidence: Rendered HTML: regression coverage page
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Composer NBSP fix - regression coverage</title>
<style>
 :root { --bg:#11131a; --card:#181b24; --ink:#e6e8ef; --muted:#98a0b3; --line:#2a2f3d;
         --bad:#ff6b68; --good:#7fd18a; }
 * { box-sizing:border-box; }
 body { margin:0; background:var(--bg); color:var(--ink); padding:26px 22px;
        font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
 .wrap { width:100%; max-width:940px; margin:0 auto; }
 h1 { font-size:20px; margin:0 0 4px; letter-spacing:-.01em; }
 h2 { font-size:11.5px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted);
      margin:20px 0 8px; font-weight:600; }
 .sub { color:var(--muted); margin:0; font-size:13px; }
 .card { background:var(--card); border:1px solid var(--line); border-radius:9px;
         padding:12px 14px; }
 .term { font:10.5px/1.32 "SF Mono",Menlo,monospace; background:#0c0e13; color:#c8c8c8;
         border:1px solid var(--line); border-radius:9px; padding:11px 13px;
         white-space:pre; overflow:hidden; }
 .term .mark { background:#33270f; box-shadow:inset 0 0 0 1px #7a5f21; border-radius:3px; }
 .term .gap { color:#4d5464; font-style:italic; }
 pre { font:11px/1.45 "SF Mono",Menlo,monospace; margin:0; white-space:pre; }
 table { border-collapse:collapse; width:100%; font-size:12.5px; }
 th,td { border-bottom:1px solid var(--line); padding:7px 9px; text-align:left; }
 th { color:var(--muted); font-weight:600; font-size:10.5px; text-transform:uppercase;
      letter-spacing:.05em; }
 code { font:11.5px "SF Mono",Menlo,monospace; }
 .bad { color:var(--bad); font-weight:700; }
 .good { color:var(--good); font-weight:700; }
 .cap { color:var(--muted); font-size:12px; margin:6px 2px 0; }
 .two { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
 .two pre { font-size:10px; line-height:1.42; }
</style></head><body><div class="wrap">
<h1>Regression coverage for the U+00A0 composer fold</h1><p class="sub">Same host &middot; both locales &middot; classifier verdicts and the committed suites</p>

<h2>1. fm_composer_classify_content over the whole NBSP row family</h2>
<div class="two">
 <div class="card"><pre>BEFORE 833a9a2   LC_ALL=C
  idle claude row    U+276F + NBSP             pending
  idle codex row     U+203A + NBSP             pending
  idle muse row      U+27E9 + NBSP             pending
  idle placeholder   U+276F + NBSP + Type a..  pending
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  pending
  bare shell + cmd   $ + NBSP + ls -la         pending</pre></div>
 <div class="card"><pre>AFTER  69aa5a3   LC_ALL=C
  idle claude row    U+276F + NBSP             empty
  idle codex row     U+203A + NBSP             empty
  idle muse row      U+27E9 + NBSP             empty
  idle placeholder   U+276F + NBSP + Type a..  empty
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  unknown
  bare shell + cmd   $ + NBSP + ls -la         pending</pre></div>
 <div class="card"><pre>BEFORE 833a9a2   LC_ALL=en_US.UTF-8
  idle claude row    U+276F + NBSP             empty
  idle codex row     U+203A + NBSP             empty
  idle muse row      U+27E9 + NBSP             empty
  idle placeholder   U+276F + NBSP + Type a..  empty
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  empty
  bare shell + cmd   $ + NBSP + ls -la         pending</pre></div>
 <div class="card"><pre>AFTER  69aa5a3   LC_ALL=en_US.UTF-8
  idle claude row    U+276F + NBSP             empty
  idle codex row     U+203A + NBSP             empty
  idle muse row      U+27E9 + NBSP             empty
  idle placeholder   U+276F + NBSP + Type a..  empty
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  unknown
  bare shell + cmd   $ + NBSP + ls -la         pending</pre></div>
</div>
<p class="cap">Idle rows must read <code>empty</code>, real typed text must stay <code>pending</code>, and a bare shell prompt must never read <code>empty</code> - the base tree got each of those wrong in at least one locale.</p>

<h2>2. Composer and tmux-adapter suites, run under both locales</h2>
<div class="card"><pre>locales: C and en_US.UTF-8
ok   fm-backend-tmux-smoke under C
ok   fm-busy-adapter-wiring under C
ok   fm-busy-state under C
ok   fm-composer-ghost under C
ok   fm-composer-lib under C
ok   fm-tmux-agent-liveness under C
ok   fm-tmux-submit-busy under C
ok   fm-backend-tmux-smoke under en_US.UTF-8
ok   fm-busy-adapter-wiring under en_US.UTF-8
ok   fm-busy-state under en_US.UTF-8
ok   fm-composer-ghost under en_US.UTF-8
ok   fm-composer-lib under en_US.UTF-8
ok   fm-tmux-agent-liveness under en_US.UTF-8
ok   fm-tmux-submit-busy under en_US.UTF-8</pre></div>
</div></body></html>
Evidence: Live idle Claude pane: capture-pane bytes and before/after verdicts

# Live idle Claude Code composer, classified through the real tmux adapter # host: macOS, tmux 3.7b, claude 2.1.226 (Claude Code) # pane: tmux -L fm-nbsp-ev session "nbsp", a real claude sitting idle (nothing typed) $ tmux capture-pane -p -e -t nbsp | sed -n 29p | hexdump -C 00000000 1b 5b 33 39 6d e2 9d af c2 a0 0a |.[39m......| 0000000b ESC [ 3 9 m | e2 9d af = U+276F | c2 a0 = U+00A0 NO-BREAK SPACE BEFORE bin/fm-composer-lib.sh @ 833a9a2 LC_ALL=C fm_tmux_composer_state -> pending away-mode/steer: DEFER - composer not proven empty BEFORE bin/fm-composer-lib.sh @ 833a9a2 LC_ALL=en_US.UTF-8 (macOS-only false green) fm_tmux_composer_state -> empty away-mode/steer: DELIVER - composer proven empty AFTER bin/fm-composer-lib.sh @ 69aa5a3 LC_ALL=C fm_tmux_composer_state -> empty away-mode/steer: DELIVER - composer proven empty AFTER bin/fm-composer-lib.sh @ 69aa5a3 LC_ALL=en_US.UTF-8 fm_tmux_composer_state -> empty away-mode/steer: DELIVER - composer proven empty

# Live idle Claude Code composer, classified through the real tmux adapter
# host: macOS, tmux 3.7b, claude 2.1.226 (Claude Code)
# pane: tmux -L fm-nbsp-ev session "nbsp", a real `claude` sitting idle (nothing typed)

$ tmux capture-pane -p -e -t nbsp | sed -n 29p | hexdump -C
00000000  1b 5b 33 39 6d e2 9d af  c2 a0 0a                 |.[39m......|
0000000b
   ESC [ 3 9 m | e2 9d af = U+276F | c2 a0 = U+00A0 NO-BREAK SPACE

BEFORE  bin/fm-composer-lib.sh @ 833a9a2   LC_ALL=C
  fm_tmux_composer_state -> pending
  away-mode/steer: DEFER - composer not proven empty

BEFORE  bin/fm-composer-lib.sh @ 833a9a2   LC_ALL=en_US.UTF-8  (macOS-only false green)
  fm_tmux_composer_state -> empty
  away-mode/steer: DELIVER - composer proven empty

AFTER   bin/fm-composer-lib.sh @ 69aa5a3   LC_ALL=C
  fm_tmux_composer_state -> empty
  away-mode/steer: DELIVER - composer proven empty

AFTER   bin/fm-composer-lib.sh @ 69aa5a3   LC_ALL=en_US.UTF-8
  fm_tmux_composer_state -> empty
  away-mode/steer: DELIVER - composer proven empty
Evidence: Live pane with unsubmitted text: verdict stays pending everywhere
# Same live pane, now carrying UNSUBMITTED human text: fix findings 1 and 3
# Reading this row as empty would type an injection over the human.

$ tmux capture-pane -p -e -t nbsp | sed -n 29p | hexdump -C
00000000  1b 5b 33 39 6d e2 9d af  c2 a0 66 69 78 20 66 69  |.[39m.....fix fi|
00000010  6e 64 69 6e 67 73 20 31  20 61 6e 64 20 33 0a     |ndings 1 and 3.|
0000001f

BEFORE  bin/fm-composer-lib.sh @ 833a9a2   LC_ALL=C
  fm_tmux_composer_state -> pending
  away-mode/steer: DEFER - composer not proven empty

AFTER   bin/fm-composer-lib.sh @ 69aa5a3   LC_ALL=C
  fm_tmux_composer_state -> pending
  away-mode/steer: DEFER - composer not proven empty

AFTER   bin/fm-composer-lib.sh @ 69aa5a3   LC_ALL=en_US.UTF-8
  fm_tmux_composer_state -> pending
  away-mode/steer: DEFER - composer not proven empty
Evidence: Classifier verdict matrix over the NBSP row family (base vs fix, both locales)
fm_composer_classify_content verdicts for the NBSP composer-row family
want: idle rows empty, real typed text pending, bare shell prompt never empty

BEFORE 833a9a2   LC_ALL=C
  idle claude row    U+276F + NBSP             pending
  idle codex row     U+203A + NBSP             pending
  idle muse row      U+27E9 + NBSP             pending
  idle placeholder   U+276F + NBSP + Type a..  pending
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  pending
  bare shell + cmd   $ + NBSP + ls -la         pending

BEFORE 833a9a2   LC_ALL=en_US.UTF-8
  idle claude row    U+276F + NBSP             empty
  idle codex row     U+203A + NBSP             empty
  idle muse row      U+27E9 + NBSP             empty
  idle placeholder   U+276F + NBSP + Type a..  empty
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  empty
  bare shell + cmd   $ + NBSP + ls -la         pending

AFTER  69aa5a3   LC_ALL=C
  idle claude row    U+276F + NBSP             empty
  idle codex row     U+203A + NBSP             empty
  idle muse row      U+27E9 + NBSP             empty
  idle placeholder   U+276F + NBSP + Type a..  empty
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  unknown
  bare shell + cmd   $ + NBSP + ls -la         pending

AFTER  69aa5a3   LC_ALL=en_US.UTF-8
  idle claude row    U+276F + NBSP             empty
  idle codex row     U+203A + NBSP             empty
  idle muse row      U+27E9 + NBSP             empty
  idle placeholder   U+276F + NBSP + Type a..  empty
  typed human text   U+276F + NBSP + real text pending
  bare shell prompt  $ + NBSP                  unknown
  bare shell + cmd   $ + NBSP + ls -la         pending
Evidence: Composer and tmux-adapter suites under C and en_US.UTF-8

Source: Composer and tmux-adapter suites under C and en_US.UTF-8 (local file: /var/folders/22/8q8yl6t93s192drlxs4hkp5r0000gn/T/no-mistakes-evidence/01KZHNKVFPBWPX6K3SQX7PMWHM/two-locale-suite-loop.txt)

locales: C and en_US.UTF-8
ok fm-backend-tmux-smoke under C
ok fm-busy-adapter-wiring under C
ok fm-busy-state under C
ok fm-composer-ghost under C
ok fm-composer-lib under C
ok fm-tmux-agent-liveness under C
ok fm-tmux-submit-busy under C
ok fm-backend-tmux-smoke under en_US.UTF-8
ok fm-busy-adapter-wiring under en_US.UTF-8
ok fm-busy-state under en_US.UTF-8
ok fm-composer-ghost under en_US.UTF-8
ok fm-composer-lib under en_US.UTF-8
ok fm-tmux-agent-liveness under en_US.UTF-8
ok fm-tmux-submit-busy under en_US.UTF-8
- Evidence: Raw styled capture of the live idle pane (tmux capture-pane -p -e) (local file: /var/folders/22/8q8yl6t93s192drlxs4hkp5r0000gn/T/no-mistakes-evidence/01KZHNKVFPBWPX6K3SQX7PMWHM/idle-claude-pane-raw.ansi) - Outcome: ⚠️ 1 info across 1 run (23m35s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ℹ️ bin/fm-composer-lib.sh:61 - The narrowed claim is still Darwin-specific. Lines 60-64 state that U+202F/U+2007/U+3000 "would still be trimmed under UTF-8 and survive under C/POSIX, the same divergence this task fixed for U+00A0". That divergence is macOS locale data: I probed this host and under en_US.UTF-8 both U+00A0 and U+202F are [[:space:]] while under C neither is. glibc excludes the non-breaking spaces (U+00A0, U+202F, U+2007) from the space class in every locale, so on Linux U+202F/U+2007 are not trimmed under UTF-8 either, and, more importantly, the pre-fix false pending on the idle claude row reproduced under en_US.UTF-8 and C.UTF-8 on Linux too, not only under C/POSIX. The fix is byte-exact so it covers both; only the claim needs narrowing. Same wording is mirrored at docs/verification/runtime-backends.md:172 and :174 ("for any home running under C/POSIX") and tests/fm-composer-lib.test.sh:25-28. Suggest scoping the sentence to the verified macOS observation, or restating it as "[[:space:]] coverage of these characters is locale-data dependent (glibc excludes the non-breaking ones in every locale), which is exactly why the fold is byte-exact".
  • ℹ️ docs/verification/runtime-backends.md:199 - The manual re-verification loop hardcodes for loc in C en_US.UTF-8, eight lines after the doc explains (line 191) that the test probes its UTF-8 locale name because glibc images and macOS ship different names. On an image without en_US.UTF-8 (common on Debian/Ubuntu, which ship C.utf8), LC_ALL=en_US.UTF-8 makes bash warn and fall back to C, so the loop runs C twice, prints no FAIL line, and reads as a clean two-locale pass. The suites in that loop (fm-composer-ghost, fm-tmux-*) are not self-parameterized like fm-composer-lib.test.sh, so this loop is the only locale coverage they get. Use the same probe the test uses, or at minimum fall back to C.UTF-8, and have the loop fail loudly when no UTF-8 locale resolves.
  • ℹ️ bin/fm-tmux-lib.sh:175 - Recording the deferred adjacent gap so it stays visible, not asking for it in this change. If a harness ever draws NBSP inside a BORDERED composer, fm_tmux_composer_geometry_spaces still fails it: the LC_ALL=C sed &#39;s/[!-~]/ /g&#39; only maps ASCII printables, so the NBSP bytes survive, and the resulting string can never equal top_spaces (a run of plain spaces). Under C it returns 1 via *[![:space:]]*; under macOS UTF-8 it passes that guard but fails the width-string equality at fm-tmux-lib.sh:283. Either way geometry_ambiguous=1 and fm_tmux_composer_state reports unknown, which fm_pane_input_pending treats as not-empty, reproducing the same deferred-injection symptom in every locale. This is not reachable for the harness that motivated the fix (claude 2.1.226 draws a bare + NBSP row, per the captured hexdump), and the author explicitly scoped bin/fm-tmux-lib.sh out, with the header and doc claims narrowed to match. No action needed for this merge.

🔧 Fix: correct locale blast-radius claims and harden doc locale loop
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 info
  • ℹ️ tests/fm-backend-orca.test.sh:688 - tests/fm-backend-orca.test.sh fails at "Orca spawn should fail when metadata cannot be written" (fm-spawn.sh exits 0 when the metadata path is a directory, instead of failing with "Is a directory"). This is unrelated to the composer classifier and is pre-existing: it reproduces identically on the base commit 833a9a2 (verified by extracting that tree with git archive and running the same suite). Baselined, not a regression from this change.
  • tmux -L fm-nbsp-ev new-session -d -s nbsp -x 120 -y 32 &#34;LANG=C LC_ALL=C claude&#34; — launched a real idle Claude Code (2.1.226) in tmux 3.7b, then tmux capture-pane -p -e -t nbsp | sed -n 29p | hexdump -C to prove the composer row is ESC[39m + U+276F + U+00A0
  • LC_ALL=C bash -c &#39;. bin/fm-tmux-lib.sh; fm_tmux_composer_state nbsp; fm_pane_input_pending nbsp&#39; against the live pane, with bin/fm-composer-lib.sh from base 833a9a2 (pending / DEFER) and from fix 69aa5a3 (empty / DELIVER); repeated under LC_ALL=en_US.UTF-8
  • tmux send-keys -t nbsp -l &#39;fix findings 1 and 3&#39; then the same live fm_tmux_composer_state calls — pending under base-C, fix-C and fix-UTF-8 (unsubmitted text is never typed over); composer cleared with C-u afterwards
  • fm_composer_classify_content verdict matrix over the whole NBSP row family (idle ❯/›/⟩ rows, idle placeholder, typed text, bare $ prompt with and without a command) under base vs fix, LC_ALL=C and en_US.UTF-8
  • LC_ALL=C bin/fm-test-run.sh tests/fm-composer-lib.test.sh (15 cases, includes the 6 new locale cases)
  • Doc-prescribed two-locale loop: LC_ALL=$loc tests/$t.test.sh for fm-backend-tmux-smoke, fm-busy-adapter-wiring, fm-busy-state, fm-composer-ghost, fm-composer-lib, fm-tmux-agent-liveness, fm-tmux-submit-busy under both C and en_US.UTF-8
  • bin/fm-test-run.sh tests/fm-backend-herdr.test.sh tests/fm-backend-cmux.test.sh tests/fm-backend-orca.test.sh tests/fm-documentation-audiences.test.sh — the other consumers of the shared classifier plus the docs-surface suite
  • Pre-fix negative control: ran tests/fm-composer-lib.test.sh against a shadow root carrying the base bin/fm-composer-lib.sh, and re-ran tests/fm-backend-orca.test.sh inside a git archive 833a9a25 extraction to baseline its failure
⚠️ **Document** - 1 info
  • ℹ️ docs/verification/runtime-backends.md:177 - Judgment call, left as is. This maintainer-verification record is headed "Verified on 2026-08-08 with tmux 3.7b and claude 2.1.226 (Claude Code) on macOS", but lines 172 and 177 assert glibc behavior (U+00A0 excluded from the space class in every locale; only U+3000 trimmed of the three fixed-width spaces) that was never probed on a Linux host. I confirmed the macOS half directly on this host (UTF-8 trims U+00A0/U+202F/U+2007/U+3000; C trims none) and left the glibc half alone: line 175 already attributes it to glibc locale data rather than to a probe, and the self-parameterizing regression in tests/fm-composer-lib.test.sh exercises the real glibc behavior wherever Linux CI runs it. Flagging it only so a reviewer with a Linux host can promote it from cited-upstream-behavior to probed evidence, or scope the sentence explicitly.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@nhwoodward

Copy link
Copy Markdown
Author

@kunchenguid when you get a chance, this fork PR is waiting on workflow approval before CI can run. It's a small locale-independence fix for the shared composer classifier (idle Claude composer rows misread as pending under C/POSIX and glibc UTF-8 locales), with tests and the verification record updated. Thanks!

An idle Claude Code composer row is `❯` (U+276F) followed by U+00A0
NO-BREAK SPACE, not an ASCII space. Under C/POSIX two shell primitives
in the shared classifier turned that row into a false `pending`:

- `[[:space:]]` does not cover U+00A0, so the trailing NBSP survived
  trimming and the row never looked empty.
- `${content#?}` strips one BYTE rather than one character, so shearing
  the leading glyph left undecodable debris that read as typed text.

fm_composer_classify_content is the single fleet-wide owner of the
empty/pending/unknown injection-safety verdict, so the false `pending`
silently deferred away-mode injections and failed steer verification on
any home running under C/POSIX - a cron job, a minimal shell, a remote
host - as a quiet supervision degradation rather than a crash.

Classification now depends only on literal byte sequences: NBSP is
folded to a plain space on entry and re-trimmed, and each multibyte
agent glyph is stripped as its own literal instead of through `?`. The
fold matches the exact two-byte sequence rather than a bracket class,
for the same reason FM_BACKEND_HERDR_BARE_PROMPT_RE stays an
alternation. The verdict vocabulary and every previously tested
behavior are unchanged.

The regression asserts the same verdicts under both C/POSIX and a
probed UTF-8 locale, and refuses to pass when no UTF-8 locale is
installed rather than comparing C against nothing. It covers both
load-bearing halves: the idle row must read `empty` so injections are
not deferred forever, and real typed text after the same NBSP must stay
`pending` so nothing is typed over unsubmitted input. The existing
idle-placeholder case was already failing under LC_ALL=C before this
change, so the suite now runs under both locales.
The portable serial shard 4 failure "the serving child ignored TERM" is a
real, racy defect in the remote job worker, not test noise.

When a teardown removes a worker's state root, the serving loop's
stale-record reap reaches fm_remote_job_prepare_state, which recreates the
state root on the very next pass. The heartbeat then keeps succeeding, so
the child never notices anything is wrong - but nothing recreates the
worker.lock directory inside it. From that half-restored state the child
owns nothing (a second worker can take the free lock and serve beside it)
and worker_publish_quarantine can never succeed again, so worker_shutdown
re-arms its trap and returns on every signal. The child becomes unstoppable
by TERM, which is precisely the leak this worker exists to prevent.

Whether CI hit it depended on where the removal landed inside a 0.05s
serving pass: before the reap the child self-terminated on a failed
heartbeat and the test passed, after it the child ran on forever.

Treat losing the lock as fatal to the serving child, exactly like a failed
heartbeat: it exits non-zero and the supervisor starts a replacement that
acquires ownership cleanly. The check runs before the heartbeat so an
unowned worker never publishes another round of readiness.

Pinned by a new case that removes only worker.lock, with the state root
left intact, so the regression is deterministic rather than a race. That
case fails on every run without this fix. The existing teardown case is now
deterministic too, and the script drops from ~9.5s to ~3.6s.
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