You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An afx send message can land in the middle of a user's half-typed input in the receiving terminal, corrupting both. Reproduced by typing a message in the architect terminal while a builder ran afx send — the builder's message was appended to the in-progress input line and submitted as one blob.
How afx send reaches a terminal today
There's already a "typing awareness" system (Spec 403), but it's built on the wrong signal, which is why the corruption still happens.
Pipeline (afx send → POST /api/send → handleSend):
packages/codev/src/agent-farm/servers/tower-routes.ts:1516 — the deliver-vs-defer decision:
packages/codev/src/terminal/pty-session.ts:502 — isUserIdle is purely a timer: Date.now() - lastInputAt >= 3000. True whenever the user hasn't pressed a key in the last 3 seconds.
If deferred, SendBuffer (packages/codev/src/agent-farm/servers/send-buffer.ts) holds the message and re-checks every 500ms, flushing when the user goes idle or after a 60s max age.
On delivery, packages/codev/src/agent-farm/servers/message-write.ts:47 writes the message text, then ~50ms later writes \r (Enter) — which submits whatever is on the line, i.e. the user's half-typed input + the injected message as one command.
Root cause
Idle-time is a bad proxy for "is the input line empty?" A user who types a few words then pauses >3s (reading, thinking) becomes "idle" and looks identical to someone at an empty prompt — so the message delivers immediately onto a non-empty line and Enter submits the mess.
A real line-occupancy signal does exist — session.composing (pty-session.ts:527), set on non-Enter keystrokes and cleared on Enter — but it was deliberately removed from the defer decision in bugfix #492 (comment at tower-routes.ts:1514) because it "got stuck true" after Ctrl+C / arrows / Tab. So today nothing checks whether the line actually has text.
Target-app context
The default architect/builder command is claude (packages/codev/src/agent-farm/servers/tower-instances.ts:491), and Codex is the other common agent. Both are TUIs with their own line editors (Ink-based), not bash/readline — which matters for any keystroke-injection delivery tactic.
Ctrl+U (kill line), Ctrl+E (cursor to end), and Ctrl+Y (yank) are confirmed working in both Claude Code and Codex, and also work in a readline/zsh shell target. So a kill-ring–based restore is viable on all the agents in practice.
The kill/yank delivery tactic and its remaining risks
One delivery idea: when a message arrives and the line is non-empty, run a kill/yank maneuver — Ctrl+E (cursor to end) → Ctrl+U (kill whole line into the kill-ring) → write the message → Enter (submit the message cleanly) → Ctrl+Y (yank the user's draft back onto a fresh prompt). This preserves and restores the user's input, and since Ctrl+U/E/Y work on both default agents the restore is dependable.
Two open risks remain:
Multi-line drafts (confirmed limitation). Claude Code / Codex input can be multi-line, and Ctrl+U clears only the current line, not the whole composition. So the kill/yank tactic partially destroys a multi-line draft and cannot restore it — a byte-replay approach (Option H) is required whenever the draft may span more than one line.
Concurrency race. Between Ctrl+U and Ctrl+Y (message write + Enter, ~150ms) the user may keep typing; new keystrokes interleave with the injection and the yank restores stale text on top. Mitigated by Option I (atomic single write).
Options
Two independent axes — detection (whether/when to deliver) and delivery (how to inject non-destructively) — plus a channel axis (change the model so injection isn't destructive at all). Fixing detection is the root cause; the kill/yank trick is a delivery tactic.
Detection — decide when it's safe to deliver
A — Fix + re-enable composing deferral (recommended core). Feed a corrected line-occupancy signal back into shouldDefer: buffer while the line is non-empty, flush when it's empty/submitted. Fix the "stuck true" bug properly — clear composing on Ctrl+C (0x03), Ctrl+U, Escape, Ctrl+G, not just Enter. Keep the 60s max-age valve so a message can't be starved. Low risk, app-agnostic (it only defers). Completes what af send: Enter (\r) doesn't submit multi-line formatted messages #492 tore out, done right.
E — Event-driven flush on submit. The buffer currently flushes on a 500ms idle timer. Instead, piggyback on the natural "line just became empty" moment: the websocket handler already calls session.stopComposing() when it sees \r/\n (tower-websocket.ts:96). Turn that into an event that immediately flushes buffered messages onto the now-known-empty prompt. Near-zero corruption because delivery happens at the instant the line is provably empty. Highest-value detection change; pairs with A.
F — Active terminal probe (DSR). Send ESC[6n to query the cursor column before delivering; a column past the prompt start means the line is non-empty. More direct than timing, but fragile — the response interleaves with app output and the prompt-end column is unknown. Lower confidence; mention-only.
G — PTY output-stream line modeling. Tower already keeps a ring buffer of rendered output (pty-session.tsringBuffer). Parse it to model the current input line. Most accurate occupancy signal, but the heaviest to build and keep correct across apps. Overkill unless A/E prove insufficient.
Delivery — inject non-destructively
B — Kill/yank trick. Deliver onto a non-empty line but preserve/restore via the kill-ring (see the tactic above). Restores single-line drafts on both default agents; it cannot restore a multi-line draft — Ctrl+U clears only the current line (confirmed) — and is still subject to the race.
C — Hybrid (A + B as the max-age tactic). Buffer while composing (A). On idle/empty-line flush → plain write (safe). On the 60s max-age flush (line still busy) → kill/yank restore (single-line only), else deliver with noEnter (already plumbed) so it doesn't submit garbage. Because the restore breaks on multi-line drafts, the max-age tactic is better served by H below.
H — Tower-side draft capture + verbatim replay. Tower sees every user keystroke in the websocket handler. Accumulate the un-submitted bytes into a per-session draft buffer (cleared on Enter/Ctrl+C/Ctrl+U). To deliver onto a busy line: clear the line → write message + Enter → replay the captured byte stream verbatim to reconstruct the draft. Advantages over the kill/yank: no dependency on the app's kill-ring, and replaying the exact bytes (including the user's own backspaces/arrows) reproduces the exact draft — so it handles multi-line correctly. The robust version of B.
I — Atomic single-write kill/yank (race fix for B/C). Send the entire Ctrl+E Ctrl+U <msg> Enter Ctrl+Y as one write() string so the user can't interleave keystrokes mid-sequence, shrinking the race window to ~zero. Tension: message-write.ts deliberately paces multi-line writes (bugfix af send: multi-line messages (>3 lines) treated as paste, final Enter swallowed #584) to dodge paste-detection swallowing the Enter, which conflicts with a single atomic write for multi-line messages. Clean for short messages; multi-line needs H or J.
J — Bracketed-paste framing. Wrap the injected message in ESC[200~ … ESC[201~. Claude Code and Codex both honor bracketed paste: control chars in the message won't be interpreted as commands, and it sidesteps the paste-detection/Enter-swallow problem (af send: multi-line messages (>3 lines) treated as paste, final Enter swallowed #584) without line-by-line pacing. Doesn't solve occupancy alone — a complement to H or I that makes the message body itself safe.
Channel — change the model so injection isn't destructive
D — Side channel (no keystroke injection). Stop modeling inter-agent messages as synthetic typing. Large architectural change; the umbrella for K and L.
K — Escalate to a side channel after max-age instead of forcing. Today at 60s max-age the buffer force-injects even onto a busy line (the destructive path). Instead, if the user is still holding a draft, post the message to the dashboard/notification (broadcastMessage already exists) and log it, and don't inject keystrokes. The draft is never corrupted; the agent gets the message on the user's next submit. A pure safety-valve.
L — Notification-only + agent mailbox. Deliver to a per-agent inbox the agent reads when it next acts, rather than injecting into the input line. Cleanest, but changes the core interaction model (the point of afx send today is to inject into the agent's input so it acts immediately).
Recommendation
Primary: Option A (fixed composing occupancy) + E (event-driven flush on submit) — this alone eliminates the common case, delivering only when the line is provably empty.
For the max-age case (user holds a draft too long), use H (byte replay). Ctrl+U in Claude Code / Codex clears only the current line, not a multi-line draft (confirmed), so the B/C kill/yank cannot safely restore multi-line input — reserve it for the single-line case only, if at all. Frame the message body with J and, if a kill/yank path is kept for single-line, harden it with I; or use K to never risk the busy line at all.
The building blocks already exist — the composing flag, the SendBuffer, the 60s max-age valve, noEnter, the output ring buffer, and broadcastMessage.
Problem
An
afx sendmessage can land in the middle of a user's half-typed input in the receiving terminal, corrupting both. Reproduced by typing a message in the architect terminal while a builder ranafx send— the builder's message was appended to the in-progress input line and submitted as one blob.How
afx sendreaches a terminal todayThere's already a "typing awareness" system (Spec 403), but it's built on the wrong signal, which is why the corruption still happens.
Pipeline (
afx send→POST /api/send→handleSend):packages/codev/src/agent-farm/servers/tower-routes.ts:1516— the deliver-vs-defer decision:packages/codev/src/terminal/pty-session.ts:502—isUserIdleis purely a timer:Date.now() - lastInputAt >= 3000. True whenever the user hasn't pressed a key in the last 3 seconds.SendBuffer(packages/codev/src/agent-farm/servers/send-buffer.ts) holds the message and re-checks every 500ms, flushing when the user goes idle or after a 60s max age.packages/codev/src/agent-farm/servers/message-write.ts:47writes the message text, then ~50ms later writes\r(Enter) — which submits whatever is on the line, i.e. the user's half-typed input + the injected message as one command.Root cause
Idle-time is a bad proxy for "is the input line empty?" A user who types a few words then pauses >3s (reading, thinking) becomes "idle" and looks identical to someone at an empty prompt — so the message delivers immediately onto a non-empty line and Enter submits the mess.
A real line-occupancy signal does exist —
session.composing(pty-session.ts:527), set on non-Enter keystrokes and cleared on Enter — but it was deliberately removed from the defer decision in bugfix #492 (comment attower-routes.ts:1514) because it "got stuck true" after Ctrl+C / arrows / Tab. So today nothing checks whether the line actually has text.Target-app context
The default architect/builder command is
claude(packages/codev/src/agent-farm/servers/tower-instances.ts:491), and Codex is the other common agent. Both are TUIs with their own line editors (Ink-based), not bash/readline — which matters for any keystroke-injection delivery tactic.Ctrl+U(kill line),Ctrl+E(cursor to end), andCtrl+Y(yank) are confirmed working in both Claude Code and Codex, and also work in a readline/zsh shell target. So a kill-ring–based restore is viable on all the agents in practice.The kill/yank delivery tactic and its remaining risks
One delivery idea: when a message arrives and the line is non-empty, run a kill/yank maneuver —
Ctrl+E(cursor to end) →Ctrl+U(kill whole line into the kill-ring) → write the message →Enter(submit the message cleanly) →Ctrl+Y(yank the user's draft back onto a fresh prompt). This preserves and restores the user's input, and since Ctrl+U/E/Y work on both default agents the restore is dependable.Two open risks remain:
Ctrl+Uclears only the current line, not the whole composition. So the kill/yank tactic partially destroys a multi-line draft and cannot restore it — a byte-replay approach (Option H) is required whenever the draft may span more than one line.Ctrl+UandCtrl+Y(message write + Enter, ~150ms) the user may keep typing; new keystrokes interleave with the injection and the yank restores stale text on top. Mitigated by Option I (atomic single write).Options
Two independent axes — detection (whether/when to deliver) and delivery (how to inject non-destructively) — plus a channel axis (change the model so injection isn't destructive at all). Fixing detection is the root cause; the kill/yank trick is a delivery tactic.
Detection — decide when it's safe to deliver
composingdeferral (recommended core). Feed a corrected line-occupancy signal back intoshouldDefer: buffer while the line is non-empty, flush when it's empty/submitted. Fix the "stuck true" bug properly — clearcomposingon Ctrl+C (0x03), Ctrl+U, Escape, Ctrl+G, not just Enter. Keep the 60s max-age valve so a message can't be starved. Low risk, app-agnostic (it only defers). Completes what af send: Enter (\r) doesn't submit multi-line formatted messages #492 tore out, done right.session.stopComposing()when it sees\r/\n(tower-websocket.ts:96). Turn that into an event that immediately flushes buffered messages onto the now-known-empty prompt. Near-zero corruption because delivery happens at the instant the line is provably empty. Highest-value detection change; pairs with A.ESC[6nto query the cursor column before delivering; a column past the prompt start means the line is non-empty. More direct than timing, but fragile — the response interleaves with app output and the prompt-end column is unknown. Lower confidence; mention-only.pty-session.tsringBuffer). Parse it to model the current input line. Most accurate occupancy signal, but the heaviest to build and keep correct across apps. Overkill unless A/E prove insufficient.Delivery — inject non-destructively
Ctrl+Uclears only the current line (confirmed) — and is still subject to the race.noEnter(already plumbed) so it doesn't submit garbage. Because the restore breaks on multi-line drafts, the max-age tactic is better served by H below.Ctrl+E Ctrl+U <msg> Enter Ctrl+Yas onewrite()string so the user can't interleave keystrokes mid-sequence, shrinking the race window to ~zero. Tension:message-write.tsdeliberately paces multi-line writes (bugfix af send: multi-line messages (>3 lines) treated as paste, final Enter swallowed #584) to dodge paste-detection swallowing the Enter, which conflicts with a single atomic write for multi-line messages. Clean for short messages; multi-line needs H or J.ESC[200~ … ESC[201~. Claude Code and Codex both honor bracketed paste: control chars in the message won't be interpreted as commands, and it sidesteps the paste-detection/Enter-swallow problem (af send: multi-line messages (>3 lines) treated as paste, final Enter swallowed #584) without line-by-line pacing. Doesn't solve occupancy alone — a complement to H or I that makes the message body itself safe.Channel — change the model so injection isn't destructive
broadcastMessagealready exists) and log it, and don't inject keystrokes. The draft is never corrupted; the agent gets the message on the user's next submit. A pure safety-valve.afx sendtoday is to inject into the agent's input so it acts immediately).Recommendation
composingoccupancy) + E (event-driven flush on submit) — this alone eliminates the common case, delivering only when the line is provably empty.Ctrl+Uin Claude Code / Codex clears only the current line, not a multi-line draft (confirmed), so the B/C kill/yank cannot safely restore multi-line input — reserve it for the single-line case only, if at all. Frame the message body with J and, if a kill/yank path is kept for single-line, harden it with I; or use K to never risk the busy line at all.The building blocks already exist — the
composingflag, theSendBuffer, the 60s max-age valve,noEnter, the output ring buffer, andbroadcastMessage.Relevant files
packages/codev/src/agent-farm/servers/tower-routes.ts(defer decision,handleSend)packages/codev/src/agent-farm/servers/send-buffer.ts(SendBuffer, idle/max-age flush)packages/codev/src/agent-farm/servers/message-write.ts(paced write + Enter)packages/codev/src/terminal/pty-session.ts(isUserIdle,composing,ringBuffer)packages/codev/src/agent-farm/servers/tower-websocket.ts(records input, togglescomposing)Related: Spec 403 (typing awareness), bugfix #450, bugfix #492 (removed the
composingcheck), bugfix #584 (paced writes).