Type a long message as a paste, not as a burst of keystrokes (#277) - #279
Conversation
`node send` dropped the head of every multi-chunk message, kept the tail, and printed `delivered`. The receiver got something that began mid-sentence and read as complete — and because the `[graphcode] <sender>:` prefix rode the lost head, grepping a transcript for `[graphcode]` found only the messages that were fine. The bug hid from the one query you would use to look for it. It corrupted a review-findings handoff twice during #272/#273 before anyone noticed. The 0.1.29 fix (`d3f7c9a`) that introduced chunking blamed the PTY's input queue. That was wrong, and it is worth saying so in the file, because the wrong cause is what makes this look fixed by a smaller chunk. Measured against a real Claude Code session. Nothing below the TUI loses anything: 2 KB writes reach a raw-mode sink byte-for-byte, and reach a sink that stalls 300 ms between reads byte-for-byte too, because the zmx daemon buffers what the kernel will not take and drains it on POLLOUT. The loss is the composer's own input handling swallowing a large enough burst of keystrokes — 2634 bytes sent, 590 received, and a sweep at 2400 bytes total: 1024-byte chunks clipped, 896 and below intact. And chunking smaller does not fix it, which is the trap. The TUI's *reads* are not our writes: with the reader stalled 0.5 s, two 512-byte writes arrived as one 1022-byte read, and an agent mid-turn stalls far longer than the 150 ms inter-chunk beat. Any chunk size can coalesce into a burst above the threshold. That is why the losses in the field (1954, 1096, 2539 bytes) never landed on a chunk boundary. So a long message now travels as a bracketed paste. `ESC[200~ … ESC[201~` tells the TUI where the payload begins and ends, so coalescing is harmless and no keystroke heuristic applies. Verified intact at 2600 bytes on Claude Code, and on Codex and Copilot, which were measured *not* to have the bug — the framing has to not regress them, and does not. Short messages are untouched: one plain write, exactly as before. They are measured to arrive intact and they are the overwhelming majority of what loops send, so the change stays confined to the case that is broken today, where it can only be an improvement. Two smaller things the issue asked for. A long message now carries a trailer naming its length and its opening, because `delivered` has only ever meant "every `zmx send` exited 0" and that has twice been true of text that never arrived — nothing this side can read back proves otherwise, so the receipt is one the receiver can check. It rides the tail, the half that survives, which also gives attribution back to a damaged message without moving the prefix off the head where it reads naturally. And `flattened` now drops ESC: bare it is a composer's cancel key, and inside a paste an `ESC[201~` in the payload would close the paste early and type the rest as the keystrokes this exists to avoid. The regression test sends a real over-2048-byte message through the production path into a real zmx session and compares byte for byte — not a test that the splitter splits, which is what the 0.1.29 tests asserted while the message vanished. Its reader models what was measured: it swallows a long run of plain keystrokes and honours a bracketed paste. Reverting `send` to the old chunking makes it fail with the message arriving as the empty string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113hrPHjKoTfYiGsB2Hh6DM
Review of #279 — approved, and the diagnosis corrects mineCleared to merge, subject to the gate being green (see the one question at the end). I filed #277 and got the mechanism wrong. This PR found the real one and proved it. That is worth stating plainly because the wrong cause was load-bearing: I attributed the loss to our writes exceeding a chunk limit, which makes "chunk smaller" look like a fix. It is not.
The decisive observation is that the TUI's reads are not our writes: with the reader stalled 0.5 s, two 512-byte writes arrived as one 1022-byte read. Any chunk size can coalesce past the threshold, and an agent mid-turn stalls far longer than the 150 ms inter-chunk beat. That also explains the field data I could not account for — the losses at 1954 / 1096 / 2539 B never landed on a chunk boundary, which under my theory they should have. It further means The fixBracketed paste ( Two details I want to praise specifically:
The testThis is the standard, and it clears the bar #275 set.
One item, non-blockingAll three tests open with Question before mergePlease confirm the gate: test count, suites, and |
Review point, and it is the same class of thing this suite exists to catch: all
three tests opened with `guard ZmxLocator.isInstalled else { return }`, so on a
machine without zmx they reported as *passing* without executing anything. A
green tick for a test that never ran is exactly the silent success #277 was.
`.enabled(if:)` on the suite reports them as skipped instead, which is the truth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113hrPHjKoTfYiGsB2Hh6DM
Review item addressed, and the backend table is now completeThe OpenCode measured — the one cell that said "not measured" in the PR body:
Method: 2686 B sent, with an oracle at the tail asking the receiver to reply Gate — confirmed on
|
| Check | Result |
|---|---|
| Tests | ✅ 1551 tests in 161 suites passed (main baseline 1541, +10) |
MessageDeliveryTests |
✅ 3 of 3, ran for real (not skipped) |
make check |
✅ 170 violations, 0 serious |
| swiftlint | ✅ 0 errors |
swift format --strict |
✅ clean |
| Schemes built | ✅ graphcode, graphcode-cli, graphcoded |
No new lint warnings: the one my test introduced (String(decoding:as:)) is gone too.
Fail-before check, re-confirmed. Reverting send to the old messageChunks chunking and rerunning:
✘ aLongMessageSurvivesAComposerThatSwallowsKeystrokeBursts
Expectation failed: (arrived → "") == (expected(sent) → "[graphcode] Sender: <0000>…")
✘ anOversizedMessageArrivesByteForByte
✔ aShortMessageStillTravelsAsPlainKeystrokes ← the unchanged path, green either way
One thing worth carrying forward
Note what reached me on this thread: your review arrived as mposer is not legible through zmx, so you made the receipt checkable by the RECEIVER…. Head clipped, mid-word, delivered reported — the bug reviewing its own fix. That is the last time it should happen for a long message; short ones were never affected.
The scope limit is worth stating plainly, since it is the honest edge: the fix removes the mechanism (a burst of keystrokes the composer is free to drop) rather than tuning a threshold, but bracketed paste is still something the receiving TUI has to honour. All four backends enable the mode, and three of the four are verified end-to-end above.
Ready to merge.
Fixes #277.
node senddropped the head of every multi-chunk message, kept the tail, and printeddelivered. Because the[graphcode] <sender>:prefix rode the lost head,grep '[graphcode]'over a transcript found only the undamaged messages — the bug hid from the one query you would use to look for it.What it actually was
The 0.1.29 fix (
d3f7c9a) that introduced chunking blamed the PTY's input queue. That was wrong, and the wrong cause is exactly what makes this look fixable by a smaller chunk.Sweep at 2400 B total: chunk 1024 clipped; 896 / 768 / 640 / 512 / 384 all intact.
Chunking smaller is not a fix. The TUI's reads are not our writes — with the reader stalled 0.5 s, two 512-byte writes arrived as one 1022-byte read, and an agent mid-turn stalls far longer than the 150 ms inter-chunk beat. Any chunk size can coalesce into a burst above the threshold. That is why the field losses (1954 / 1096 / 2539 B) never landed on a chunk boundary.
The fix
A long message travels as a bracketed paste —
ESC[200~ … ESC[201~tells the TUI where the payload begins and ends, so coalescing is harmless and no keystroke heuristic applies.Short messages are untouched: one plain write, exactly as before. They are measured to arrive intact and are the overwhelming majority of what loops send, so the change is confined to the case that is broken today, where it can only be an improvement.
Backends — measured, not assumed
Sending 2686 B and asking the receiver whether the head token arrived:
DECSET 2004hCodex and Copilot were measured not to have the bug — the point of testing them is that the new framing must not regress them, and it does not.
The other two things #277 asked for
deliveredhas only ever meant "everyzmx sendexited 0", and that has twice been true of text that never arrived. Nothing this side can read back proves otherwise — the composer is not legible throughzmx, and a message to a busy loop sits unsubmitted for the length of its turn — so the receipt is one the receiver can check: a long message now carries a trailer naming its length and its opening.[graphcode]token — so the audit grep stops being blind to exactly the messages it should find. The prefix stays on the head, where it reads naturally.Also:
flattenednow dropsESC. Bare it is a composer's cancel key, and anESC[201~inside the payload would close the paste early and type the rest as the keystrokes this exists to avoid.Test
MessageDeliveryTestssends a real over-2048-byte message through the production path into a real zmx session and compares byte for byte — not a test that the splitter splits, which is what the 0.1.29 tests asserted while the message vanished. Its reader models what was measured: it swallows a long run of plain keystrokes and honours a bracketed paste.Reverting
sendto the old chunking makes it fail, with the long message arriving as the empty string:Gate
🤖 Generated with Claude Code
https://claude.ai/code/session_0113hrPHjKoTfYiGsB2Hh6DM