Repository navigation
fix(server): drop mirrored typing whose sender left the pane before its turn - #576
Conversation
…ts turn On a pane without a pty (Windows: herdr cannot attach a terminal, so the screen is mirrored), typed text waits its turn behind a message in flight and then goes through pane.send_text. It never kept the sender's claim, so text queued before a detach, or a detach and a new attach, still reached the pane. Keep the claim and the attachment the sender was in, and check them after the text is shaped, as queued keys and queued pty typing do. Fixes #546
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review. 📝 SummarySummary by CodeRabbit
WalkthroughThe mirrored-terminal fallback now checks attachment state before sending queued input. Contract tests cover detachment and detachment followed by reattachment. The changelog records the fix. ChangesMirrored Input
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to Queued mirrored input is guarded against delivery after detachment, while input from connections that never attached retains its existing behavior. No merge-blocking issue was identified. Security Architecture ReviewSecurity architecture risk: ⚪ Minimal · up to The change narrows queued input delivery by rejecting text after its original attachment changes. It preserves existing access checks and does not introduce a new endpoint, privilege or delivery path. No material security risk introduced or worsened by this change was identified. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
# Conflicts: # CHANGELOG.md
devswha
left a comment
There was a problem hiding this comment.
Independent review (merge-own lane).
Classification: serious bug fix (#546) on the input path. On a mirrored pane (Windows x64, no PTY), text typed while a message was being sent still reached the pane after the sender left it, or left and opened it again. That breaks the bridge rule that nothing typed outlives the context it was typed in.
What I checked against the bridge invariants:
- Before queueing, the mirror branch of the WS
inputhandler now records the sender's claim (client.data.attached) and the attachment the sender is a member of. Inside itsserializeturn, after paste shaping, it answersinput_failedand sends nothing when the claim changed or that attachment was replaced or no longer holds the sender. This matches what the pty branch and thekeyshandler already do. Nothing is queued while offline, and nothing is resent. - A connection that never attached keeps its old path, since both values stay
undefined. A connection that attaches while its typing waits now getsinput_failed, which is the conservative side. No wire shape changes, soshared/protocol.ts, the demo transport and the remote bundle need nothing. - The test can fail: with the whole guard replaced by
if (false), the new #546 case fails (21 pass, 1 fail). With only the claim half disabled it stays green, as the PR body says, because the attachment check covers both rounds. - Codex (gpt-6.1-sol, xhigh, adversarial, read-only) found no material issue. It checked cancellation after detach, reattach, attachment replacement, disconnect, and detach during paste shaping.
What I ran (separate worktree):
bun run check run bun test --timeout 30000 ./server/submit.contract.test.ts: 22 pass, 0 fail.- Updated with
main(df0bf4a, CHANGELOG conflict resolved after #574).bun run check fast: 1715 pass, 0 fail. Fast checks and Integration and browser are green on the merged head. There are no review threads.
## Change Fixes for six findings from the pre-release Codex review of 0.4.1 (findings 2, 3, 5, 7, 8 and 9), one commit each, plus a CHANGELOG commit. Findings 1, 4 and 6 and `REMOTE_BUNDLE_VERSION` are left alone. | # | Commit | What | |---|--------|------| | 3 | 9373398 | A Claude Code slash command's answer is stripped of terminal escapes in one linear pass. Also closes the three gaps of #584. | | 2 | 5375765 | The secret handler records the sender's attach claim before the live-screen read and answers `not_attached` when it changed. | | 8 | ca4ca92 | Regression for #576 with a second client keeping the attachment alive, so it fails when the claim check is removed. | | 7 | a717619 | The plugin's `appOnPort` takes only a 2xx answer with the app's bridge-health shape. | | 9 | 36651a5 | Three doc statements corrected after checking the code. | | 5 | e6ceb43 | The command palette has its own layer token, above every dialog. | Closes #584. ## Reproduced first on 5979118 - **3**: `server/conversation.test.ts` got two tests. On the unchanged code a cut-off OSC 8 link leaked its address into the notice (`done 8;;https://example.test/?token=hidden`), `ESC ( B` left a stray `B`, an entry with stdout and stderr showed one stream, and doubling a malformed answer made parsing 4.0x slower (limit 3; linear is 2). The growth test compares two input sizes rather than a wall-clock threshold; it passed 8/8 repeat runs after the fix. - **2**: new case in `server/submit.contract.test.ts`. A keeper client and the sender attach to a mirrored pane; the secret's `pane.read` is held on a deferred barrier while the sender detaches and attaches again. Unchanged code answered `secret-result ok: true` and entered the secret. - **8**: new case in the same file for ordinary typing. The message ahead of the typing is held at herdr (`paneSendText` on a deferred barrier) while the sender detaches and rejoins; the keeper keeps the attachment, so only the claim comparison can reject the typing. - **7**: `scripts/plugin.test.ts` got a stranger that answers bridge health with `{"ok":true}` (foreign JSON) and with the app's shape under 503. Unchanged code took the first for the app (`herdr web ui is running at … but cannot reach herdr: not us`) and kept the port. - **5**: not reproducible on the demo: its fixtures hold no file link, so no preview can be opened there. Reproduced instead with `scripts/file-viewer-regression.ts`, which drives the real client in Chromium: a preview, then Settings (raised above it), then the palette. The palette's search box was focused but not the topmost element. The case is now in that script. - **9**: no test (docs only). Each corrected statement was checked against the code: - `server/AGENTS.md` said submit IDs are idempotency receipts; only `delivery: "queue"` submits enter `PendingRequestBook` (`server/index.ts`), so an immediate submit sent twice runs twice. - `server/AGENTS.md` said that with a token set every client needs it; `decideAccess` accepts a paired device's cookie before it asks for the token. - The #563 CHANGELOG entry implied any `CLAUDE_CONFIG_DIR` is found on Windows; `windowsClaudeStores` looks only in the server's own `CLAUDE_CONFIG_DIR`, `~/.claude` and `~/.claude-*` in the home folder. ## Mutation runs Each new test was run with its fix removed (file restored byte for byte afterwards, checked with `cmp`): - Secret claim check removed from the `secret` handler: only the secret case fails (`ok: true` instead of `not_attached`). - Claim comparison removed from mirrored typing: only the typing case fails. - `response.ok` check removed from `appOnPort`: the 503 variant fails. - `z-index: var(--z-palette)` removed: the three-overlay case fails with `{ above: false, focused: true }`; the screenshot shows the palette hidden under Settings. ## Validation All through `dori heavy` on head e6ceb43, Bun 1.4.2: - `bun run check fast`: workflow syntax, generated types, typecheck, build, unit tests (1725 pass, 0 fail). - `bun run check run bun test ./server/submit.contract.test.ts`: 24 pass. - `bun run check run bun test ./server/secret.contract.test.ts`: 5 pass. - `bun run check run bun scripts/file-viewer-regression.ts` after `bun run build`: every step passes, twice in a row, including the new three-overlay case. - `HERDR_TEST_MODE=unit bun test ./server/conversation.test.ts`: 52 pass; the growth test 8/8 on repeat. - `HERDR_TEST_MODE=unit bun test ./scripts/plugin.test.ts ./scripts/windows-plugin.test.ts ./scripts/plugin-port.test.ts`: 20 pass. The browser lane as a whole and the integration suite run in CI. ## Review round 1 ([review](#588 (review))) | | Commit | What | |---|--------|------| | B1 | d2c6249 | `start()` asks the full `/api/health` first, and `health()` took any 200 `{ok:true}`. It now requires the shape every release since v0.1.0 has answered (`ok`, a `herdr` object, `auth` with `required` and `authenticated`). New test: a stranger answering `{"ok":true}` on both URLs; on 378013b `start` said "already running". The plugin and phone-setup fakes answer the real shape. | | B2 | c55eb28 | A stream ends only at a closing tag at the end of the entry or before the next stream, so output printing the tag itself is shown again (on 378013b the review's example gave no notice). The growth test also covers output full of closing tags. | | B3 | 689a1b5 | `DESIGN.md` and the `CommandPalette.css` comment say the palette layer is over scrim dialogs: `MachineDialog` uses `showModal()`. | On 689a1b5 through `dori heavy`: `bun run check fast` (1726 pass, 0 fail) and `bun run check run bun test ./scripts/phone-setup.contract.test.ts` (1 pass); `server/conversation.test.ts` 52 pass, growth test 8/8 on repeat.
## Change Prepares v0.4.1 and remote bundle 21. - `package.json` and `herdr-plugin.toml`: 0.4.0 → 0.4.1. A patch release: fixes from the audit and the pre-release reviews (#566, #570, #588, #592), Windows chat discovery (#563, #582), the plugin's port handling (#577), GJC menus (#593) and more. The new pieces are small and opt-in or additive: the sidebar's Activity order and Quiet opened finishes (#529, both off by default), the `/effort` card (#594), the clipboard-from-a-pane switch (#570) and the Portal guide (#229). - `shared/machines.ts`: `REMOTE_BUNDLE_VERSION` 20 → 21. `server/` code that ships in the bundle changed since v0.4.0 (#566, #563, #572, #576, #582, #583, #588, #519, #570, #592, #593, #594), so remote PCs need a new bridge. - `CHANGELOG.md`: the Unreleased notes become `[0.4.1] - 2026-10-08`, with the compare links. #592 merged without entries; its three user-visible fixes are added under Fixed (forwarded-address token limit, https-only web push that never follows a redirect, Tab and Escape in Add PC over a file preview). - `release-summaries.json`: 0.4.1 in English, Korean, Japanese and Chinese: four new, seven improved and eight fixed lines (eight is the most a list keeps). ## After the merge 1. `git tag remote-v21 <merge commit> && git push origin remote-v21` → the remote-bundles workflow publishes the five bundles and `manifest.json` as release `remote-v21`. 2. Only then `gh workflow run release.yml --ref main -f version=0.4.1`: preflight validates version, notes and summaries, CI runs on the exact commit, and the tag `v0.4.1` with its GitHub release is created. ## Validation - `bun run check fast` (workflow syntax, generated types, typecheck, build, unit tests). - `bun scripts/release-notes.ts 0.4.1` prints the notes (version sources agree, one heading, summaries in all four languages).
…e notes (#598) Two changes the owner asked for, one commit each. ## 1. Clipboard from a pane (OSC 52) is on by default #570 turned **Settings → Terminal → Clipboard from a pane** off by default, so copying from vim, tmux and Claude Code silently did nothing. It is on again; the switch stays for anyone running output they do not trust. **Migration: a new storage key.** Settings are saved as one whole record, so any 0.4.1 install whose user changed *any* setting has `terminalOsc52: false` stored without choosing it. The choice now lives under `paneClipboard` (default `true`) and `terminalOsc52` is ignored and dropped by `sanitizeSettings`, which already discards unknown keys. That is the simplest correct option: - a 0.4.1 record loads as on (its `false` cannot be told apart from a real choice, so it is not trusted); - an off chosen from now on is written under `paneClipboard` and stays off across saves and reloads; - no version field or one-off migration state is needed. A settings version would do the same with more code. Known edge: a tab still running the 0.4.1 bundle that saves settings writes its own record, which has no `paneClipboard`, so a new "off" would read as on again until it is re-chosen. Tabs pick up the new bundle on reload, and settings already have no cross-tab sync (each tab saves its in-memory record), so this is no worse than any other setting. Tests (`src/lib/settings.test.ts`, "clipboard from a pane"): fresh default on; a 0.4.1 record with `terminalOsc52: false` loads as on; an off chosen after the change survives a save, a reload and an unrelated change. A mutation that falls back to the old key fails the second case. Also: the setting's description and code comments say what is true now, ko/ja/zh entries are updated, and there is a CHANGELOG line under Unreleased → Changed. No doc in `docs/`, `DESIGN.md`, `INSTALL.md` or `README.md` mentions the default. ## 2. Patch-note style GitHub release notes `scripts/release-notes.ts` printed the whole CHANGELOG section (181 lines for v0.4.1). It now prints the English lists from `release-summaries.json` (`### New features`, `### Improvements`, `### Bug fixes`, a list with no lines left out), then the full section inside `<details><summary>Full changelog</summary>` with blank lines around it so GitHub renders the markdown. Lines come through `readSummary`, so they are trimmed and capped exactly as an install shows them. Every validation is unchanged (version format, the three version sources, exactly one heading, nonempty notes, all four languages). `scripts/release-notes.test.ts` pins the shape, the order and an empty list left out. `docs/development.md` → Releasing and `scripts/AGENTS.md` describe the new body. The published v0.4.1 release is not touched here; the lead regenerates it after the merge. ### v0.4.1 rendered with this script (`bun scripts/release-notes.ts 0.4.1`) ### New features - Typing /effort in a Claude Code chat opens a card to pick the effort for this session - Settings → Appearance can keep waiting and the latest agents on top of the Agents list - A finished agent you opened in the web UI can lose its dot, as it would in herdr (opt-in) - The guide shows how to give the app a public HTTPS address with Portal ### Improvements - A wrong access token waits longer after five tries, up to a minute - A program in a pane copies to your clipboard only once you allow it in Settings → Terminal - Dialogs keep Tab inside them, and screen readers announce tabs, panes and status lines - Add PC, Reconnect PC and Update remote bridge open as a bottom sheet on a phone - Updating a remote PC that is already current reuses its bridge without a new download - Alerts go only to https push services and never follow a redirect - Long drafts wrap, and the scrollbar no longer covers text when the page is zoomed ### Bug fixes - A long line of unclosed brackets or broken escape codes no longer freezes the chat - On Windows, more Claude Code and Codex panes show their chat instead of an error - Your own devices are recognised by their Tailscale login again, without pairing - A Claude Code chat shows what a slash command answered, and /goal is suggested - Settings, the palette, Add PC and file previews stack in order; Escape closes the top one - The plugin's start keeps the app on its port while herdr is away - A secret or typed text no longer reaches a pane you have already left - Gajae Code's selection and startup menus show their choices, not only arrow keys <details><summary>Full changelog</summary> ### Added - `/effort` sent from a Claude Code chat opens a card with the effort levels, low to max, so the level no longer has to be set in the terminal. A pick applies to this session only, as a pick from the `/model` card does, and leaves the default for new sessions alone. `/effort` is also in the chat's command list now. ([#594](#594), [#523](#523) by @suho-han) - Settings → Terminal has a **Clipboard from a pane** switch, off by default: a program running in a pane can no longer put text on your clipboard unless you turn it on. Programs that copy this way (vim, tmux, Claude Code) copy again once it is on. ([#570](#570) by @radicor) - The guide's **Behind a reverse proxy** shows how to give the app a public HTTPS address with [Portal](https://github.com/gosuda/portal-tunnel) v2.6.1 or later, behind a long random token and with a visitor's `Tailscale-User-Login` header dropped. ([#229](#229) by @rabbitson87) - **Settings → Appearance → Agents order → Activity** keeps a waiting agent on top of the Agents list and orders the rest by their latest state change, as herdr's agents panel keeps the latest work in view: the agent you just sent a message to stays on top while it runs and after it finishes, and a new one starts there. herdr's own order and the workspace rows are unchanged. **Workspaces** (herdr's order) remains the default. ([#529](#529) by @phirschybar) - **Settings → Appearance → Quiet opened finishes** (off by default): a finished agent you have opened in the web UI loses its dot and reads as ready, as viewing it in herdr would make it. herdr's DONE otherwise stands until herdr itself shows the pane. It is remembered per PC in this browser. ([#529](#529) by @phirschybar) ### Changed - A wrong access token is refused with a growing wait after five tries, up to a minute, whether it is typed into the sign-in form or sent with a request. Each visitor behind `tailscale serve` has their own count, and a browser holding an old token never stops you signing in with the new one. ([#570](#570) by @radicor) - The app now sends itself a Content-Security-Policy, so third-party content rendered in a chat — math, agent marks — cannot run script in the app. ([#570](#570) by @radicor) - A web-push subscription must be an https endpoint. ([#570](#570) by @radicor) ### Fixed - Gajae Code's selection menus show their choices instead of only arrow-key buttons, including startup selectors shown before the pane reports that it is waiting. Answers move to the selected row and recheck the menu before confirming. ([#593](#593)) - Secret input and the Codex follow-up fallback validate the live screen, so a password prompt or collapsed question queue in scrollback cannot send input into the current program. ([#566](#566)) - The PC's Tailscale login is read from a real Tailscale user id. These ids are too large for a JavaScript number, so the owner was not recognised and the owner's own devices had to pair. Two logins with neighbouring ids are also no longer taken for one. ([#572](#572)) - On Windows, a Claude Code pane started with a second account's `~/.claude-*` directory as its `CLAUDE_CONFIG_DIR` shows its chat. Before, the pane fell back to `~/.claude`, so the chat said **Conversation unavailable** and only the terminal worked. Windows does not let the server read another process's environment, so the store is the one among the server's own `CLAUDE_CONFIG_DIR`, `~/.claude` and the `~/.claude-*` directories beside it that holds the Claude process's own record, checked against the time the process started. A directory elsewhere is not looked in. Claude Code processes reported as `claude.exe` are recognized too. ([#563](#563) by @David-Sousa-Web) - A long line of brackets, `\(` or underscores that never close, as an agent prints in a log or a minified file, no longer freezes the chat: a megabyte of them took a minute or more to read, and now takes milliseconds. What every message shows is unchanged. ([#574](#574)) - On a mirrored pane (Windows, where herdr cannot attach a terminal), text typed while a message was still being sent no longer reaches the pane once you have left it, or left it and opened it again, before the text's turn came. ([#576](#576)) - The plugin's `start` keeps the app on its port when the app's own server holds it but cannot reach herdr. It used to move the app to another port beside the running one and blame another program; now it says that the app runs there without herdr and exits, and the app answers again on its port once herdr is back. ([#577](#577)) - On Windows, a Codex pane shows its chat when Codex stored its paths with the `\\?\` prefix, as it does for a canonical Windows path (`\\?\D:\work` for `D:\work`). Before, the chat said **Conversation unavailable**: the session's file seemed to lie outside Codex's store, and none of the threads matched the pane's directory as herdr reports it. ([#582](#582) by @David-Sousa-Web) - In a Claude Code pane, the chat shows what a slash command answered, so a `/goal` that Claude Code refuses says why in the chat instead of only in the terminal. `/goal` is also among the commands the message box suggests. ([#583](#583)) - Settings opened over a file preview is visible above it; Escape and Back close Settings first, preserving the preview and its history entry until the file itself is closed. ([#568](#568)) - Command palette buttons keep their native Enter action; IME commit and cancel keys stay with text input, and arrow navigation keeps the selected result visible. ([#567](#567)) - Long drafts in the message box wrap and keep a narrow scroll cue in reserved space, so the scrollbar no longer covers text at fractional zoom. ([#522](#522) by @suho-han) - A secret sent from a pane you then left and opened again, while another browser kept the pane open, is no longer typed into the pane: you send it again from the pane you opened. ([#588](#588)) - A Claude Code slash command whose answer holds a long run of broken terminal escape codes no longer stalls the server while the chat reads it. Its answer also drops a link cut off before its end and a stray `B` after a charset switch, and an answer with both output and errors shows both. ([#588](#588)) - The command palette opened over Settings and a file preview shows above both, instead of taking the keyboard unseen beneath them. ([#588](#588)) - The plugin's `start` no longer takes another program on the app's port for the app because its answer says `ok`: it treats it as any other program there, moving the app to a free port, or saying the port is taken when `PORT` is set. ([#588](#588)) - A 500 from the server no longer repeats the system's own error text, which carried absolute paths and the herdr socket location; it names a short id you can quote in a bug report instead. ([#570](#570) by @radicor) - Settings, the command palette, the file viewer, the file browser and the new-workspace dialog keep Tab inside them and give the focus back to whatever opened them. ([#570](#570) by @radicor) - The tabs of a workspace name the pane region they govern, so a screen reader announces the tab and the pane together. ([#570](#570) by @radicor) - Agent headings in a chat no longer pose as the app's own page structure; they sit below the app's own headings and look the same as before. ([#570](#570) by @radicor) - The "reconnecting" line and the composer's terminal-only hint are announced when they appear. ([#570](#570) by @radicor) - A chat locked out by the token gate, the "Last checked" line under Settings → About (with its date in your language) and a remote PC's state word in the sidebar are translated like the rest of the UI. ([#570](#570) by @radicor) - The alerts menu item now says the same thing the same way in every state. ([#570](#570) by @radicor) - Held terminal input typed while disconnected is forgotten after a day. ([#570](#570) by @radicor) - A link printed in the terminal opens only if it is an http(s) address, on both link paths. ([#570](#570) by @radicor) - A second tab open on one pane no longer sends a message the first tab is already sending: it sees that send on its way and holds back. Two tabs that press Send at nearly the same moment can still both send it. ([#570](#570) by @radicor) - A row's ⋯ menu is capped to the room its button leaves and scrolls instead of being cut off by the viewport, so the pane picker of a tab with many panes keeps every entry reachable with the pointer as well as the keyboard. Before, items below the fold were rendered but unreachable. ([#570](#570) by @radicor) - A row menu open while the window crosses the 640 px breakpoint now switches between bottom sheet and popover instead of keeping the form it opened with. ([#570](#570) by @radicor) - The workspace drawer a narrow window opened is closed again when the window is widened past 768 px, so narrowing it no longer brings back a drawer and its scrim unasked. ([#570](#570) by @radicor) - **Add PC**, **Reconnect PC** and **Update remote bridge** open as a bottom sheet on a phone, like every other dialog, and keep clear of the on-screen keyboard. Before, the one native dialog stayed a centred card on a phone. ([#570](#570) by @radicor) - **Remove PC**'s first click is a quiet ghost button that only arms the removal; the second is the red one, as revoking a device already was. ([#570](#570) by @radicor) - A PC's rename, connect and disconnect buttons disable while their request is in flight, so a double click no longer sends two overlapping requests. ([#570](#570) by @radicor) - A failed pane or workspace rename keeps the field open with what you typed, so a network blip no longer makes you write the name again. ([#570](#570) by @radicor) - A workspace reorder that fails no longer undoes a later, successful reorder. ([#570](#570) by @radicor) - A tab watching several busy panes gives up its oldest cached conversation answers when they grow past a byte budget, not only past sixteen of them. ([#570](#570) by @radicor) - The usage meters' note is the same size as every other advisory and empty state. ([#570](#570) by @radicor) - The PDF viewer's page colour, the pill radii, the tab dot and the pairing-code size come from design tokens now, and the pairing code follows the compact density setting. ([#570](#570) by @radicor) - Updating an already current remote bridge verifies and reuses it without downloading or restarting it again, while a bridge from a newer app is left running instead of downgraded. A PC that waits on such a conflict says so in the sidebar and under the header, with a **Reconnect** button, instead of asking for setup approval. ([#519](#519) by @suho-han) - A wrong access token sent through a proxy on this PC with a made-up `X-Forwarded-For` address that itself says " via " now counts against the limit every visitor through that proxy shares, like any other wrong token. Before, each such try started a fresh count. ([#592](#592)) - A web-push subscription must be an https address, with no exception for this PC, and an alert is never sent on where a push service redirects it, so an alert can never be posted to a service running on this PC. ([#592](#592)) - In the **Add PC** dialog opened over a file preview, Tab moves through the dialog's own controls and Escape closes the dialog alone, leaving the preview beneath it open. ([#592](#592)) </details> ## Checks - `bun run check fast`: ok (1817 pass, 6 platform skips, 0 fail). - No browser regression covers OSC 52 (`terminal-copy-regression.ts` covers drag and Ctrl+C copy), so none was run for it. ## Codex review (gpt-6-astra, read-only) - **Fixed** (P2): summary text was put into the markdown as-is, so `"…\n\n<!--"` could hide the whole changelog fold. The gate now refuses a summary line with a line break in any language, and the English lines escape `<`. A test covers both and fails without the fix. - **Declined, documented** (P2): a tab still on the 0.4.1 bundle that saves settings drops `paneClipboard`, so a new "off" reads as on until it is chosen again. A versioned storage key would stop that, but then every setting changed in such a tab would be lost instead. It lasts only until that tab reloads, and every setting added before this one had the same gap. - **Pre-existing, not in this diff** (P2): settings have no cross-tab sync, so a stale tab's next save writes its own copy of every setting, this one included. Reported to the lead as a follow-up.
What
On a mirrored pane (Windows x64: herdr cannot
terminal attach, soserver/mirror.tsmirrors the screen), text typed while a message is still being sent no longer reaches the pane when the sender leaves it, or leaves and opens it again, before the text's turn comes. The sender getsinput_failed, the same answer queued keys and queued pty typing give.Why
The mirror branch of the WS
inputhandler inserver/index.tswaits its turn inserialize(...)and then callspaneSendText, checking only that the connection is still open. Unlike the pty branch and thekeyshandler, it never kept the sender's claim (client.data.attached) or the attachment it typed into, so a detach, or a detach followed by a reattach, did not cancel it.How
Before queueing, keep
origin(the attachment the sender is a member of, if any) andclaim. Inside the queued step, after the asynchronous paste shaping, answerinput_failedand send nothing when the claim changed, or whenoriginwas replaced, closed, or no longer holds the sender. Typing from a connection that never attached keeps its existing path (both values stayundefined). No wire shape changes, soshared/protocol.ts, the demo transport and the remote bundle are untouched.How verified
server/submit.contract.test.ts(typing without terminal attach, at the edges): a server withterminalAttach: falseandsubmitDelayMs: 1000holds the pane's turn with a submit, then the test sendsinputanddetach(second round:detach+attach).one\rtypedtwo\r.input_failed.typedtwo. With only the claim half disabled it stays green, because the attachment check covers both rounds on its own.bun run check run bun test ./server/submit.contract.test.ts: 22 pass, 0 fail.bun run check fast: workflow syntax, generated types, typecheck, build, and unit tests all pass (1699 pass, 4 platform skips, 0 fail).Fixes #546