Skip to content

fix(mention): give each queued mention a deadline, keep a due flush on another client's handshake, and report refused writes - #480

Merged
ThomasK33 merged 53 commits into
mainfrom
fix/mention-flush-handshake
Nov 11, 2026
Merged

ThomasK33 merged 53 commits into
mainfrom
fix/mention-flush-handshake

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

What you get

Each queued @mention now has a deadline: connection_timeout + connection_wait_delay ms after you sent it (10.6 s by default). No handshake and no disconnect moves the deadline. At its deadline, a mention that is still queued leaves with an error, unless a ready Claude is connected. A ready Claude is one that completed its WebSocket handshake connection_wait_delay ms ago (600 ms by default).

While a ready Claude is connected, no deadline clears a mention. The queue drains to it, oldest first, 25 ms apart, also past the deadlines. When that Claude leaves, the next flush clears every mention whose deadline passed meanwhile, with an error, and the rest wait for their own deadlines.

So a Claude that completes its handshake less than connection_timeout ms after a send, and stays connected, gets that mention, and every other mention still queued when it is ready. With 100 mentions sent at once and a handshake 8 s or 9.8 s later, all 100 arrive.

The error says which case you are in:

  • No client connected: "Connection timeout - clearing N queued @ mentions" (the same text as before).
  • A client connected that is not ready yet: "Send timeout - clearing N queued @ mentions that the connected Claude did not take in time".

Before this change, each handshake stopped the connection timeout. Clients that connected and left without overlapping (for example a new client every 1000 ms that stays 300 ms) held a mention for as long as they kept doing it, with no error. They still never become ready, so the mention leaves with an error at its deadline.

Another client's handshakes no longer delay a queued mention. A mention goes out once some connected client is ready. The broadcast then reaches every connected client, including one that connected later and is still in its own wait. Before, while one Claude stayed connected, each handshake of another client restarted the wait of a flush that was already due.

When libuv refuses a write at once, you now see an error too. luv returns nil and the error for such a write and never runs its callback. send_message ignored that return, so broadcast never reported, and the queue dropped the mention without a word.

Behavior changes

  1. A drop no longer gives a queued mention a fresh connection_timeout. The deadline counts from the send.
  2. Mentions sent at different times time out at different times. Before, one timeout from the first queued mention cleared them all.

These fix the Codex round-6 finding on #454 (thread), which the user waived for #454 so that it ships here. They also fix a gap the advisor on #454 found, the Codex round-1 finding on this PR, and the verifier's findings on 0585211, d1a6724 and cefe258.

Change

  • init.lua queue_mention: it stamps each mention with its deadline, and starts M.state.connection_timer when it is not running.
  • init.lua expire_mentions, the connection_timer callback: while no ready client is connected, it clears the queued mentions whose deadline passed, with one error, and starts the timer again for the oldest mention left. When the oldest mention went out since, it clears nothing. While a ready client is connected, it clears nothing, makes sure a flush is due, and stops.
  • init.lua flush_mention_queue: it sends the oldest mention only once some connected client is ready (ms_until_a_client_is_ready). Otherwise it waits for the first such moment and checks again then. A flush that finds no ready client calls expire_mentions, so the mentions whose deadline passed while a ready client took the queue leave at once.
  • init.lua schedule_mention_flush: it no longer starts or stops the timeout while the queue holds a mention. The bound on the wait for a ready client (ready_wait_since) is gone, as the deadline covers it.
  • init.lua M.process_mention_queue(true), the handshake hook: it no longer restarts the flush timer.
  • server/client.lua: a client records connected_at when its handshake completes, and get_client_info returns it.
  • server/client.lua send_message: it passes the error of a write that libuv refuses at once to the callback. broadcast then counts that write as failed and still calls on_written once. Main added a write helper that every socket write goes through (fix(server): drop a client that answers pings but never reads #467). It now returns what tcp_handle:write returned, and it stops a large frame at the first piece that libuv refuses at once (Codex round 4, hardening: libuv refuses all pieces or none, and at_mentioned frames are never split).
  • config.lua and dev-config.lua: the connection_timeout comment says what the setting does now.
  • types.lua, the comments in init.lua and server/client.lua, and lua/claudecode/README.md state the rules above.
  • fixtures/issue-70/README.md names the deadline and expire_mentions in place of the removed start_connection_timeout_if_needed.

Tests

All tests are in tests/unit/mention_queue_spec.lua unless noted. The tests run the real server code on a fake libuv loop, so each time below is exact.

Tests that fail on cefe258, the previous head:

  • F1: 100 mentions sent at once all reach a Claude whose handshake comes 8000 ms later, and the same at 9800 ms.
  • F1: when a ready Claude leaves after the deadline, the rest leave at the next flush: one error, within 25 ms of the drop, for every mention it did not get.
  • F1: when a ready Claude leaves after the deadline while another waits, the rest leave at once: the same, while a second Claude that is not ready yet is connected.
  • D: a Claude that gets ready just at the deadline gets the mention.
  • F1: a ready Claude that leaves before its handshake callback ran does not strand the queue. The verify-mention-queue model found this path once I gave it the drain rule: Neovim runs the handshake's on_open callback late when its loop was busy, so the deadline can find Claude ready before any flush is due. Without the flush that the spared deadline now schedules, a Claude that left before on_open ran left the queue with nothing that sent or cleared it.
  • In tests/unit/server/connection_lifecycle_spec.lua: stops at a piece that libuv refuses at once, and passes its error to the callback once.

On real Neovim 0.12.3, with the plugin's own timers and a loopback WebSocket client: 100 mentions sent at once, and a handshake 8.6 s later. On this head all 100 arrive, in order, with no error. On cefe258, 36 arrive, and at 10.6 s you see "Send timeout - clearing 64 queued @ mentions that the connected Claude did not take in time".

Tests that fail on d1a6724:

  • S: clients that connect every <period> ms and stay <life> ms do not hold a mention past its deadline, for 1000/300, 700/300, 650/500 and 900/100 ms: no two clients connected at once. The mention is sent 1 ms after the first handshake. Each test expects one error at 10601 ms, and no client receives the mention.
  • S: clients that overlap but each leave before they are ready do not hold a mention past its deadline: a new client every 200 ms that stays 300 ms. One error at 10601 ms, with the Send timeout text.
  • A, F4: a Claude whose handshake comes 10.3 s after the send is not ready by the deadline: you see an error
  • H: a handshake and a drop just before the deadline do not move it
  • B: mentions sent 5 s apart with no Claude each time out at their own deadline: errors at 10.6, 15.6 and 20.6 s.
  • B: of two mentions sent 5 s apart, a Claude ready between their deadlines gets the second
  • B, C: :ClaudeCodeStop drops the deadline of the old mention: a mention sent after a restart gets its own
  • C: a stop cancels a deadline whose callback already waits for the main loop
  • F1: when a ready Claude leaves in the middle of the queue, the rest leave at their deadline: one error at 10.6 s for every mention it did not get. It passes on cefe258.

Tests that pass on d1a6724 and pin a rule:

  • A: a Claude whose handshake comes 9.9 s after the send is ready at 10.5 s and gets the mention
  • B, control: the deadline of a mention that went out clears no later mention
  • E: a send while a flush is due does not postpone that flush. It kills the queue_mention mutant that the verifier found surviving (F2-send-restarts-flush below).

Tests from earlier rounds:

  • D: handshakes of other clients do not postpone a flush that is due for a connected Claude. It fails on 3f42e01, the last fix(mention): deliver queued @mentions once, in order, or report an error #454 head.
  • D: when the Claude a flush was kept for leaves, a newly connected Claude still gets connection_wait_delay. It fails on 7e8860c, the first head of this PR.
  • The readiness rule: D: a Claude whose handshake came 700 ms after its TCP connect still gets connection_wait_delay, D: a Claude whose CLOSE frame arrives as a flush is due does not count as ready, D: when the Claude a flush waits for leaves during the wait, the next Claude gets its own wait, and D: a Claude that answered a ping just now has still been connected since its handshake.
  • H: a write that libuv refuses at once shows an error too, and a server_spec.lua case where one client's write is refused and another's succeeds: on_written runs once, with 1.

The fake loop fails a test when its timers keep firing at one moment, so a mutant that loops makes a test fail instead of hanging the run.

Three earlier tests contradicted the final rules, and I replaced them: "H: after a drop in the wait, the mention gets a whole connection_timeout for Claude to come back", "B, control: the connection timeout runs from the first queued mention, not from the latest", and, on cefe258, "D: a Claude that gets ready just at the deadline is too late". I removed "C: a flush that the deadline cancelled does not send a mention sent right after the deadline": its path no longer exists, as no deadline clears a mention while a ready Claude is connected.

Mutants: I ran 47 mutants of the queue, deadline, drain, readiness, broadcast and write-report code. Each mutant changes one guard alone. 46 of them make at least one test fail.

no-identity-check survives. start_timer runs a callback only while its timer is still the one in M.state, and the mutant drops that check, so a stale flush or deadline callback runs once more. Every queue callback re-checks the queue and the clients, and no deadline clears a mention while a ready client is connected. So a stale callback can no longer send a mention early or clear one, and I found no path that tells the two apart. I kept the check: without it, a stale callback stops the live timer of its name.

C2-connected-at-accept now sets connected_at from client.last_pong, which after #488 holds the time of the TCP accept (or of the last pong). Before, it left connected_at nil and duplicated connected-at-unset.

The definitions follow, as the code each mutant replaces (-) and its replacement (+). A mutant with no + lines deletes the code.

The 47 mutants
  1. closing-counts in init.lua

    - takes no frames.
    -       if info.state == "connected" and info.handshake_complete == true then
    + takes no frames.
    +       if (info.state == "connected" or info.handshake_complete == true) and info.handshake_complete == true then
  2. no-identity-check in init.lua

    - if M.state[name] == timer then
    + if true then
  3. flush-ignores-broadcast-result in init.lua

    - if not server.broadcast("at_mentioned", params, report_unwritten("queue", mention.file_path)) then
    + if not (server.broadcast("at_mentioned", params, report_unwritten("queue", mention.file_path)) or true) then
  4. stop-keeps-queue in init.lua

    -   -- Clear the queued @ mentions and stop the queue's timers: nothing queued before the stop reaches Claude
    -   clear_mention_queue()
  5. queued-path-raw in init.lua

    -     queue_mention(mention.file_path, mention.start_line, mention.end_line)
    +     queue_mention(file_path, start_line, end_line)
  6. immediate-overtakes in init.lua

    - if immediate and #mention_queue() == 0 then
    + if immediate then
  7. no-on-open in server/client.lua

    -         if on_open then
    -           on_open(client)
    -         end
  8. broadcast-always-true in server/init.lua

    - return tcp_server.broadcast(M.state.server, json_message, on_written) > 0
    + tcp_server.broadcast(M.state.server, json_message, on_written)
    +   return true
  9. tcp-broadcast-counts-closing in server/tcp.lua

    -     if client.state == "connected" then
    -       sent = sent + 1
    +     if true then
    +       sent = sent + 1
  10. bad-path-queued-anyway in init.lua

-     if not mention then
-       return false, format_error
-     end
-     queue_mention(mention.file_path, mention.start_line, mention.end_line)
+     mention = mention or { file_path = file_path, start_line = start_line, end_line = end_line }
+     queue_mention(mention.file_path, mention.start_line, mention.end_line)
  1. tcp-broadcast-ignores-on-written in server/tcp.lua
-       if on_written then
-         pending = pending + 1
+       if false then
+         pending = pending + 1
  1. tcp-broadcast-reports-before-writes in server/tcp.lua
-     if dispatched and not reported and pending == 0 and sent > 0 then
+     if dispatched and not reported and sent > 0 then
  1. tcp-broadcast-counts-failed-writes in server/tcp.lua
-           if not err then
-             written = written + 1
-           end
+           written = written + 1
  1. unwritten-silent in init.lua
-       logger.error(component, "Claude's connection failed
+       logger.debug(component, "Claude's connection failed
  1. mock-broadcast-no-report in server/mock.lua
-   if written > 0 and on_written then
+   if false then
  1. immediate-on-written-noop in init.lua
- M.state.server.broadcast("at_mentioned", params, report_unwritten("command", formatted_path))
+ M.state.server.broadcast("at_mentioned", params, function() end)
  1. immediate-reports-raw-path in init.lua
- report_unwritten("command", formatted_path)
+ report_unwritten("command", file_path)
  1. tcp-broadcast-no-dispatched-guard in server/tcp.lua
-     if dispatched and not reported and pending == 0 and sent > 0 then
+     if not reported and pending == 0 and sent > 0 then
  1. send-message-ignores-refusal in server/client.lua
-   if not queued and err and callback then
+   if false then
  1. flush-ignores-readiness in init.lua
-   if wait and wait > 0 then
+   if false then
  1. readiness-uses-newest-client in init.lua
-       wait = wait and math.min(wait, left) or left
+       wait = wait and math.max(wait, left) or left
  1. handshake-restarts-flush in init.lua
-     schedule_mention_flush(M.state.config.connection_wait_delay)
-   else
+     stop_timer("mention_timer")
+     schedule_mention_flush(M.state.config.connection_wait_delay)
+   else
  1. connected-at-unset in server/client.lua
-         client.connected_at = vim.loop.now()
  1. C2-connected-at-accept in server/client.lua
-         client.connected_at = vim.loop.now()
+         client.connected_at = client.last_pong
  1. I4-closing-counts-ready in init.lua
-     if info.state == "connected" and info.handshake_complete == true and info.connected_at then
+     if info.handshake_complete == true and info.connected_at then
  1. I9-readiness-from-last-pong in init.lua
-       local left = math.max(0, info.connected_at + M.state.config.connection_wait_delay - vim.loop.now())
+       local left = math.max(0, info.last_pong + M.state.config.connection_wait_delay - vim.loop.now())
  1. I12-no-recheck-after-wait in init.lua
-   local wait = ms_until_a_client_is_ready()
-   if wait and wait > 0 then
+   local wait = not M.state.skip_ready_check and ms_until_a_client_is_ready()
+   M.state.skip_ready_check = nil
+   if wait and wait > 0 then
+     M.state.skip_ready_check = true
  1. no-remove-head in init.lua
-   table.remove(queue, 1)
-   logger.debug("queue", "Sent queued
+   logger.debug("queue", "Sent queued
  1. send-restarts-flush-v2 in init.lua
-   elseif M.is_claude_connected() and not M.state.mention_timer then
+   elseif M.is_claude_connected() then
  1. F2-send-restarts-flush in init.lua
-     start_timer("connection_timer", timeout, expire_mentions)
-   end
-   schedule_mention_flush(debounce_delay)
+     start_timer("connection_timer", timeout, expire_mentions)
+   end
+   stop_timer("mention_timer")
+   schedule_mention_flush(debounce_delay)
  1. clear-keeps-deadline in init.lua
-   M.state.mention_queue = {}
-   stop_timer("mention_timer")
-   stop_timer("connection_timer")
+   M.state.mention_queue = {}
+   stop_timer("mention_timer")
  1. empty-queue-keeps-deadline in init.lua
-   if #mention_queue() == 0 then
-     stop_timer("mention_timer")
-     stop_timer("connection_timer")
+   if #mention_queue() == 0 then
+     stop_timer("mention_timer")
  1. timeout-silent in init.lua
-       logger.error("queue", "Connection timeout - clearing "
+       logger.debug("queue", "Connection timeout - clearing "
  1. send-timeout-silent in init.lua
-       logger.error(
-         "queue",
-         "Send timeout
+       logger.debug(
+         "queue",
+         "Send timeout
  1. F4-connection-text-while-connected in init.lua
-     if M.is_claude_connected() then
-       logger.error(
+     if false then
+       logger.error(
  1. deadline-restarts-on-send in init.lua
-   if not M.state.connection_timer then
-     start_timer("connection_timer", timeout, expire_mentions)
-   end
+   start_timer("connection_timer", timeout, expire_mentions)
  1. deadline-without-wait-delay in init.lua
-   local timeout = M.state.config.connection_timeout + M.state.config.connection_wait_delay
+   local timeout = M.state.config.connection_timeout
  1. handshake-stops-deadline in init.lua
-   if from_new_connection then
-     schedule_mention_flush
+   if from_new_connection then
+     stop_timer("connection_timer")
+     schedule_mention_flush
  1. handshake-moves-deadline in init.lua
-   if from_new_connection then
-     schedule_mention_flush
+   if from_new_connection then
+     local timeout = M.state.config.connection_timeout + M.state.config.connection_wait_delay
+     for _, queued in ipairs(mention_queue()) do
+       queued.deadline = vim.loop.now() + timeout
+     end
+     if #mention_queue() > 0 then
+       start_timer("connection_timer", timeout, expire_mentions)
+     end
+     schedule_mention_flush
  1. expire-clears-whole-queue in init.lua
-   while expired < #queue and queue[expired + 1].deadline <= now do
+   while expired < #queue do
  1. expire-keeps-mentions in init.lua
-     for _ = 1, expired do
-       table.remove(queue, 1)
-     end
  1. expire-no-rearm in init.lua
-   else
-     start_timer("connection_timer", queue[1].deadline - now, expire_mentions)
-   end
+   end
  1. expire-rearms-for-newest in init.lua
- start_timer("connection_timer", queue[1].deadline - now, expire_mentions)
+ start_timer("connection_timer", queue[#queue].deadline - now, expire_mentions)
  1. F1-expire-while-ready in init.lua
-   if ms_until_a_client_is_ready() == 0 then
+   if false then
  1. F1-wait-keeps-overdue in init.lua
-     expire_mentions()
-     if #mention_queue() > 0 then
+     if #mention_queue() > 0 then
  1. F1-no-client-keeps-overdue in init.lua
-     logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path)
-     expire_mentions()
- 
+     logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path)
+ 
  1. F1-spare-without-flush in init.lua
-     schedule_mention_flush(inter_message_delay)
-     return
-   end
-   local queue = mention_queue()
+     return
+   end
+   local queue = mention_queue()

Validation

All on the pushed head, after merging main at 09b78fd. #488 on main removed client.last_pong = vim.loop.now() from the 101 write callback, so the merge sets client.connected_at = vim.loop.now() there, the handshake time as before. Outside covered.lock and that line, the change of this PR is the same as at 3de15f5, apart from index lines and hunk offsets:

  • luacheck on lua/ and tests/: 0 warnings / 0 errors in 173 files
  • busted, full suite, shuffled: 1516 successes / 0 failures / 0 errors / 20 pending, with --coverage (seed 1794411584) and without (seed 12345)
  • Real-Neovim integration tests (Neovim 0.12.3): 313 success, 0 failed
  • treefmt --ci: 0 files changed
  • The 47 mutants: 46 killed, 1 survived (no-identity-check, see above)
  • Real Neovim 0.12.3, 100 mentions at once and a handshake 8.6 s later: 100 received, in order, 0 errors
  • verify-diff-lifecycle: I re-recorded covered.lock after each change to a covered file, and check.sh passes.
  • verify-mention-queue (the model for the skill PR that follows this one, not in this PR): every TLC check passes on this code, with the drain rule and its three new guards. check.sh still exits 3 (STALE) on this head, because the skill's covered.lock needs a re-record. The skill PR does that.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

Adds tests/unit/mention_queue_spec.lua. It drives the real init.lua and
the real server/init.lua, tcp.lua and client.lua on a fake libuv loop
and plays Claude through the real TCP and WebSocket paths. Each test is
named after the verify-mention-queue finding it reproduces (A, B, C, D,
E, F, G, H, S). On origin/main every finding test fails; the two
controls pass.
…rror

Every @ mention that does not go out at once now waits in one queue,
M.state.mention_queue, until a connected Claude took it, its wait timed
out with an error, or the integration stopped. Two timers move the
queue on, and one of them runs while it holds a mention:

- mention_timer is the next flush: 50 ms after a send,
  connection_wait_delay after Claude's WebSocket handshake, or 25 ms
  after the previous mention. A flush sends only the oldest mention.
- connection_timer bounds the wait for Claude to connect. The handshake
  stops it, and a flush that finds no connected Claude starts it again.

A timer callback does nothing once its timer was stopped or replaced,
so :ClaudeCodeStop cancels every pending flush and timeout.

The server reports a handshake through a new on_open callback (the old
flush hook ran on_connect at TCP accept), is_claude_connected counts
only a client in state "connected", and broadcast returns true only
when a connected client took the frame. A queued mention gets the same
cwd-relative path as a connected one.

queue_timeout is still accepted but no longer drops mentions: a mention
queued while Claude starts is delivered once Claude connects, as the
README promises.
…he reconnect window

A flush now keeps the oldest mention whenever no connected client took
it, instead of first asking is_claude_connected: the two checks covered
the same case. Adds tests for the whole connection_timeout after a drop
in the wait, and for a send in the tick after the handshake.
…cord the diff model

lua/claudecode/README.md no longer says the queue drops mentions older
than queue_timeout. It states the delivery order, the handshake trigger,
the connection timeout and what a stop cancels. The verify-claudecode-nvim
send-at-mention map expects the relative path and connection_timeout.

verify-diff-lifecycle: only whole-file hashes changed (init.lua,
config.lua, types.lua, server/init.lua, tcp.lua, client.lua). No covered
block or site line changed, and the queue code calls no diff function,
so the diff model still describes the code. check.sh passes.
…atch the mock broadcast

Codex round 1 on #454. _format_path_for_at_mention checked only a raw
string prefix, so with cwd /project the file /project2/a.lua became
/a.lua, and with cwd / the file /tmp/a.lua became mp/a.lua. The queue
now formats every mention, so this also reached queued sends. A path is
now relative only when it is the cwd or below it.

server/mock.lua broadcast returned true with no client. It now returns
true only when a client took the message, as server/init.lua does.
…ord the diff model

verify-diff-lifecycle: only the file hashes of init.lua and
server/mock.lua changed. The path formatter and the mock are outside
the diff model, and check.sh passes.
…dd the randomized check

Verifier findings on fc19b4b:
- A bad path sent while Claude is not connected returns an error, queues
  nothing, starts no timer and opens no terminal. No test covered it.
- The connection timeout runs from the first queued mention: a send at
  0 s and one at 8 s both time out at 10 s. No test pinned it.
- dev-config.lua still described queue_timeout as the queue's age limit.

Adds the verifier's randomized check to mention_queue_spec.lua: 300
fixed seeds of 30 random steps each (about 1 s), checked against the
promise. It fails on origin/main.
Under busted --coverage, which mise run test and CI use, its 300 seeds
take 546 s (about 1 s without coverage): LuaJIT runs the hooked code in
its interpreter. The verifier keeps it as a separate spec.
…ait_delay example

Verifier finding on d6f3d06: nothing tested that server/mock.lua
broadcast returns true only when a client took the message. Adds
tests/unit/server/mock_spec.lua. dev-config.lua showed
connection_wait_delay = 200, while the default is 600.
…nt took it

Codex round 3 on #454 (P1). broadcast counted a connected client as
having taken the frame when it queued the write. A socket that failed
during the write reported it only to the write callback, which nothing
passed, so the queue dropped the mention without a word.

tcp.lua and server/init.lua broadcast take an optional on_written
callback. When a client took the message, it runs once after the last
write finished, with how many writes succeeded. The queue flush and the
immediate send pass one that shows an error when none did. The mention
is reported, not retried: the frame may have reached the socket in part.
server/mock.lua follows the same contract.
verify-diff-lifecycle: only the file hashes of init.lua, server/init.lua,
server/tcp.lua and server/mock.lua changed. broadcast is outside the
diff model, and check.sh passes.
…ivery

# Conflicts:
#	.agents/skills/verify-claudecode-nvim/features/send-at-mention.md
#	.agents/skills/verify-diff-lifecycle/covered.lock
#	lua/claudecode/init.lua
…ivery

# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
…xed synchronous broadcast

Verifier finding on 7a8a657: with disable_broadcast_debouncing, nothing
tested that a failed write shows an error naming the absolute path, and
nothing tested that broadcast waits for the last of several writes that
finish at once.
…ivery

# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
…ivery

# Conflicts:
#	.agents/skills/verify-claudecode-nvim/features/send-at-mention.md
Codex round 6 on #454 (P2). Each completed handshake stopped a flush
that was already due and started a fresh connection_wait_delay. While
one client stayed connected, a second client that completed a handshake
at least once per connection_wait_delay postponed the queued mentions
for as long as it kept doing so. A handshake now keeps a due flush when
another client is connected.
Only the file hash of init.lua changed. The queue code calls no diff
function, and check.sh passes.
Advisor finding on #454. luv returns nil and the error for a write it
refuses at once, and never runs that write's callback. send_message
ignored the return, so broadcast never called on_written, and the queue
dropped the mention without an error. send_message now passes such an
error to the callback, so broadcast counts the write as failed and still
reports once.
Only the file hash of server/client.lua changed, outside create_client,
the one client.lua block the diff model covers. check.sh passes.
…dshake

# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
#	lua/claudecode/README.md
#	lua/claudecode/init.lua
#	tests/unit/mention_queue_spec.lua
#	tests/unit/server_spec.lua
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T12:30:57.760376Z d514686 Manual request
🔒 Security Review ✅ Completed 2026-11-11T12:31:23.432775Z d514686 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

… a ready Claude

verify-mention-queue found it: when Neovim runs the handshake's on_open callback late, the deadline can find the
Claude ready before any flush is due. The deadline then cleared nothing and stopped, and when that Claude left before
on_open ran, nothing sent or cleared the queue. The spared deadline now schedules a flush.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 3de15f5ca4

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 3de15f5ca4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…dshake

# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: d514686211

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: d514686211

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33

Copy link
Copy Markdown
Member Author

Waiver recorded for this run: a lock-only merge of main (the PR's own diff outside .agents/skills/verify-diff-lifecycle/covered.lock byte-identical, check.sh passing, CI green on the new head) may be pushed past the six-round cap and merged on the last clean Codex verdict. This PR is at round 6 with Codex clean on d514686; its next push is such a merge.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

…dshake

# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
#	lua/claudecode/server/client.lua
@ThomasK33

Copy link
Copy Markdown
Member Author

Waiver recorded for this PR: the maintainer allowed the merge of main that changes one line of this PR's own diff (client.connected_at = vim.loop.now() in server/client.lua, needed after #488 removed the last_pong reset from the handshake callback) to be pushed past the round cap and merged on CI green plus the last clean Codex verdict (d514686), without a new Codex round.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 5a8cd4f Nov 11, 2026
7 checks passed
@ThomasK33
ThomasK33 deleted the fix/mention-flush-handshake branch November 11, 2026 16:43
ThomasK33 added a commit that referenced this pull request Nov 11, 2026
… before the fixes

sync.lua now also accepts the empty queue that the handshake left before #454, so the five replays that use it run
through on that code. churn.lua sends while no client is connected, the path that hung before #480.
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 12, 2026
…r#510)

## What changes

The test suite now passes when `TMPDIR` holds a `%` or a relative path,
for the unit suite and for the integration runner. Before this change,
each case broke part of the suite on `main`. Only test files change. No
script and no plugin code change.

Unit suite, both on the same base (`d8e767c`):

| `TMPDIR` | `main` | This branch |
|---|---|---|
| relative (`reltmp`) | 1450 passed, 13 failed
(`integration_runner_spec`) | 1465 passed, 0 failed |
| contains `%` | 1454 passed, 9 failed (`lockfile_test`) | 1465 passed,
0 failed |
| unset | not run | 1465 passed, 0 failed |

The branch adds two cases, so its suite has 1465 tests where `main` has
1463.

Integration runner (`scripts/run_integration_tests_individually.sh`, all
22 files), on `main` `f932d7b` plus this branch's first two fixes, with
plenary at CI's pinned commit and a stub `claude`:

| Neovim | `TMPDIR` | Before the third fix | This branch |
|---|---|---|---|
| 0.12.3 | contains `%` | 28 failed (`diff_hand_over_spec`,
`diff_new_tab_mode_spec`) | 291 passed, 0 failed |
| v0.8.0 | contains `%` | 28 failed (same two files) | 204 passed, 0
failed |
| 0.12.3 | relative | 291 passed, 0 failed | not rerun (no change to
that path) |
| v0.8.0 | relative | 204 passed, 0 failed | not rerun (no change to
that path) |
| 0.12.3, v0.8.0 | unset | not run | 291 and 204 passed, 0 failed |

## Why each case failed

1. **`%` in `TMPDIR`, unit suite.** `tests/lockfile_test.lua` expanded
`~` with `path:gsub("~", home)`. `gsub` reads `%` in a replacement
string as a capture reference, so `home` lost the character. The mock
now gives `gsub` a function, which returns `home` unchanged.
2. **Relative `TMPDIR`.** `tests/unit/integration_runner_spec.lua`
changes into a fake repository, then runs the integration runner. The
runner calls `mktemp`, and a relative `TMPDIR` no longer named a folder
after that `cd`. The spec now passes the folder that holds its temp dir
as `TMPDIR` (`dir:match("^(.*)/[^/]+$")`), which is an absolute path.
The script needs no change, because it never changes directory.
3. **`%` in `TMPDIR`, integration specs.** `diff_hand_over_spec.lua` (6
calls of `vim.cmd.edit(path)`) and `diff_new_tab_mode_spec.lua` (1 call
of `vim.cmd("edit " .. dir .. "/a.txt")`) gave a temp path to `:edit` as
it was. `:edit` reads a bare `%` in its argument as the current file
name, so each call failed with `E499: Empty file name for '%'` (28 tests
on both Neovim versions). Both specs now pass the path through
`vim.fn.fnameescape`. I found this when I ran the integration runner
under a `%` `TMPDIR` after coder#502 merged. CI does not run the integration
runner under a `%` `TMPDIR`, so nothing guards these 7 calls.

`tests/unit/tmpdir_suite_spec.lua` (new, 2 cases) runs one spec in a
child `busted` under each odd `TMPDIR`, so a later change cannot bring
the first two failures back silently. The relative-`TMPDIR` case is
pending on a host with no `timeout` executable (stock macOS), because
`integration_runner_spec.lua` registers only a pending case there. Codex
found this in round 1 (P2). I reproduced it with `timeout` hidden from
`PATH`, then fixed it in `b13bffa`.

The lockfile case requires exit status 0 and at least one success, not
an exact count, so a test added to `tests/lockfile_test.lua` (open PR
coder#247 adds 7) cannot break it.

## Evidence

- Gate on `19213e8`, with `main` `f932d7b` (coder#502) merged in: `mise run
clean && mise run all` exit 0, seed 1794413672, 1517 passed, 0 failed,
20 pending.
- Before the third fix (head `b7a2e93`, the merge only): the same gate
gave 1517 passed, 0 failed, 20 pending (seed 1794412856). The full
busted suite also passed under a `%` and a relative `TMPDIR` (1517
passed each, seed 777).
- `tests/unit/integration_runner_spec.lua` 38 of 38,
`tests/unit/tmpdir_suite_spec.lua` 2 of 2, `tests/lockfile_test.lua` 16
of 16 on the merge.
- Mutant checks on the new spec (revert one fix at a time, run
`tests/unit/tmpdir_suite_spec.lua`):

| Mutant | Result |
|---|---|
| none | 2 passed, 0 failed |
| `tests/lockfile_test.lua` back to `main` | lockfile case fails (child:
7 passed, 9 failed) |
| `tests/unit/integration_runner_spec.lua` back to `main` | runner case
fails (child: 0 passed, 1 failed) |

## Overlap with coder#502

coder#502 merged at 15:49:58Z. It changed
`tests/unit/integration_runner_spec.lua`, which this branch also
touches, and `scripts/run_integration_tests_individually.sh`, which this
branch does not touch. Merging `main` into this branch had no conflict,
and the runner spec passes 38 of 38 on the merge. coder#515 (merged later)
also changed `tests/unit/integration_runner_spec.lua`; `git merge-tree`
of this head with main after coder#515 and coder#480 is clean, and the merge queue
reruns the tests on that merge.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
… it contradicts (coder#533)

## Why

The goal is that every claim in the README contracts of this repository
is true for the code at `main`, and that each correction names its
evidence. A contract is the README of one folder. It states what the
code does and cites the function that does it. The branch `docs-gtz7`
corrected many claims at an old commit of `main`. `main` has moved since
(peer PRs coder#445 to coder#522 rewrote several passages and changed the code).
This PR merges `main` into that work, re-checks every line that it
changes against the current code, and fixes the lines that the peers
made stale or that the merge joined into false text.

## What changed

Docs only, in seven README files. The branch has four parts.

1. The commits of `docs-gtz7` (three content commits and a merge of
`main`): a refresh of the contracts against the code, a pass that fixed
the drift that the refresh left, and a pass that corrected claims which
runtime probes disproved.
2. Five merge commits of `main` (the second for coder#522, the third for coder#524
and coder#530, the fourth for coder#506, the fifth for coder#531 and coder#526). Where a
peer PR and `docs-gtz7` both changed a passage, `main`'s text won. A
`docs-gtz7` correction stayed only where it is still true.
3. Nine correction commits. Six verifier agents, one per file or half
file, checked every line that this diff changes against the code. They
ran headless Neovim 0.12.3 probes (one on 0.10.4) where reading did not
decide the claim. I applied their corrections in commits that group the
files: server, root and plugin, tools, lua, terminal, scripts, the
main-text fixes, and one for the exit sentences and the last seven
findings. A second verifier then found one false sentence (the exit
sentence below) and seven wording findings, which that last commit
fixes.

4. One commit for two P2 findings of the Codex code review on
`42ef1bd1`. First, `stop` and the token mismatch in `start` try to
remove the lock file and only log a warning when that fails
(`lua/claudecode/init.lua:642-647`, `:605-608`), so three sentences in
`README.md` now say "tries to". Second, the headless auto-setup check in
`plugin/README.md` used `--cmd "set rtp^=$PWD"`, which Ex splits at a
space in the checkout path. I ran it in `/…/with space/repo` on Neovim
0.12.3: it fails with `E518` and prints `0`. The `lua
vim.opt.rtp:prepend(vim.fn.getcwd())` form prints `2`, and `0` without
the auto-setup line, as the README says.

Per file, with the number of claims checked and the result:

| File | Claims checked | True | False | Incomplete | Unverified | Edits
|
|---|---|---|---|---|---|---|
| `README.md` (Repository Blueprint) | 41 | 39 | 0 | 2 | 0 | 2 |
| `plugin/README.md` | 40 | 39 | 0 | 1 | 0 | 1 |
| `lua/claudecode/README.md` | 198 | 173 | 11 | 14 | 0 | 23 |
| `lua/claudecode/server/README.md` | 88 | 75 | 3 | 9 | 1 | 13 |
| `lua/claudecode/terminal/README.md` | 106 | 100 | 2 | 4 | 0 | 5 |
| `lua/claudecode/tools/README.md` | 68 | 54 | 4 | 10 | 0 | 14 |
| `scripts/README.md` | 7 | 0 | 2 | 4 | 0 | 13 |

A false claim was true at the old base or never true. An incomplete
claim leaves out a condition that the code has. The row for
`scripts/README.md` lists the seven items of the coder#462 verifiers (one
more was misleading). The 541 claims of the first six rows split into
480 true, 20 false, 40 incomplete and 1 unverified.

### Re-sync items after the peer PRs

- coder#530 (invalid `provider`). `lua/claudecode/README.md` said that an
invalid `provider` keeps the previous value "although the warning says
Defaulting to 'native'". `main` now warns `Keeping '<provider>'`
(`lua/claudecode/terminal.lua:483-488`). The sentence says what the
warning names now. The defect is gone from the list of code defects
below.
- coder#531 (release-please runner). The `run-release-please.sh` bullet of
`scripts/README.md` keeps the verified text and adds `main`'s clause
that `commonmark` must be on the module path too. The runner requires it
at load (`scripts/release-please-runner.js:27`). The Communique,
Prettier and output code that the runner bullet cites did not change.
- coder#526 (snacks tests). It changes comments and dead window option
assignments in `lua/claudecode/terminal/snacks.lua`. No line of this PR
depends on them.
- coder#524 (visual selection and plugin edits). The two conflicts were
passages about the buffer listener and the flush of a visual selection.
`main`'s text won in both, and the root `Editor state` bullet keeps the
verified autocmd group list with `main`'s listener wording
(`lua/claudecode/selection.lua:watch_selection_buffer`,
`note_visual_edit`). The selection claims that this PR changes
(`selection_changed` positions, `selection.lua` internals) do not depend
on the code that coder#524 changed.
- coder#459 (lockfile). `README.md` no longer says that `start` leaves the
lock file on disk after a token mismatch: `start` removes it and stops
the server (`lua/claudecode/init.lua:603-619`). The System Verification
bullet about `CLAUDE_CONFIG_DIR` and `tests/lockfile_test.lua` is gone:
the test hides the caller's value and stays in a private home
(`tests/lockfile_test.lua:340-355`, `:439-442`). `lockfile.update` is
removed from `lua/claudecode/README.md`: the module has no such
function. The `start` and `stop` bullets use the text of coder#459, and
`stop` is described as an attempt to remove the lock file
(`init.lua:635-670`).
- coder#461 (openFile reload). The reload-error passage of
`lua/claudecode/tools/README.md` is the text of `main`. The claim that
the recovery read does not end Visual mode stays. A probe confirmed it
in both forms: plain Visual mode, and Visual mode that CTRL-O started.
The read runs through `nvim_buf_call`, so the mode stays on
(`lua/claudecode/tools/open_file.lua`).
- coder#462 (WebSocket shell clients removed). No README cites
`scripts/lib_claude.sh` or another deleted file (a grep over the seven
files finds none).

### Corrections per file

<details>
<summary>README.md</summary>

- The list of what rejects a pending `openDiff` now includes Claude's
`close_tab` and `closeAllDiffTabs` calls, which reject it through
`close_diff_by_tab_name` (`lua/claudecode/diff.lua:2040-2044`,
`:2105-2107`, `lua/claudecode/tools/close_tab.lua:53`,
`lua/claudecode/tools/close_all_diff_tabs.lua:30`).
- At exit, Neovim fires `BufUnload` for each loaded buffer before
`VimLeavePre`, and the proposal buffer's `BufUnload` autocmd rejects its
pending diff (`lua/claudecode/diff.lua:1279-1285`). So with one pending
diff the rejection runs before the `VimLeavePre` cleanup. With two or
more pending native diffs there is one exception: the reject of a
new-file diff in the current tab with `diff_opts.on_new_file_reject =
"keep_empty"` (the default) deletes its proposal buffer
(`diff.lua:1171-1175`, `:1523-1524`), Neovim's exit loop then stops
firing `BufUnload`, and the cleanup removes the other diffs with no
answer. Probes on Neovim 0.8.0, 0.9.5, 0.10.4, 0.11.5 and 0.12.3 show
both cases, and the `unified` layout answers every diff. The sentence
states both. A fix PR (F13) follows, see "Defects in the code".
- `start` removes the lock file on a token mismatch (see above).
- The Processes bullet adds `:checkhealth claudecode` (`<cli>
--version`, `lua/claudecode/health.lua:43-66`) and the `git rev-parse`
of `terminal.git_repo_cwd` (`lua/claudecode/cwd.lua:28-42`).
- `CLAUDE_CONFIG_DIR` set but empty uses `~/.claude/ide`
(`lua/claudecode/lockfile.lua:11-18`). Three lines say so now.
- The Side Effects bullet lists the autocmd groups of `lua/` and
`plugin/` (`ClaudeCodeShutdown`, `ClaudeCodeSelection`,
`ClaudeCodeMCPDiff`, `ClaudeCodeDiffCleanup`, `ClaudeCodeNativeAdopted`,
`ClaudeCodeOpenFileNewFiles`, and one `snacks_win_<id>` per Snacks
terminal whose split the plugin re-creates,
`lua/claudecode/terminal/snacks.lua:259`), the `nvim_buf_attach`
listeners (`lua/claudecode/selection.lua:175-191`) and the `"unified"`
highlight groups (`lua/claudecode/diff_inline.lua:33-36`).

</details>

<details>
<summary>plugin/README.md</summary>

- Commands from auto-setup exist after a startup script that waits
(`:sleep` or `vim.wait()`) lets the deferred `setup` run. They do not
exist when the `-c` commands and `VimEnter` run. Headless probes on
Neovim 0.12.3 (`plugin/claudecode.lua:13-17`).
- The other 39 changed claims held: the load guard order, the second
`source` doing nothing, the version error on Neovim older than 0.8, the
failed module load shown once, the auto-setup global read twice, and the
error from the deferred `setup`.

</details>

<details>
<summary>lua/claudecode/README.md: 23 corrections with
evidence</summary>

- Only :ClaudeCodeSend uses the local add_paths_to_claude.
:ClaudeCodeTreeAdd and :ClaudeCodeAdd call M.send_at_mention in their
own code. Evidence: lua/claudecode/init.lua:703, init.lua:830,
init.lua:904, init.lua:914, init.lua:974-975, init.lua:1021-1022,
init.lua:1126.
- expand_tilde expands only `~` and `~/...`. A path such as `~user/x`
stays as it is. Evidence: lua/claudecode/init.lua:1113-1117,
lua/claudecode/utils.lua:167-181.
- The fallback split runs create_split, whose leave_preview_window moves
from the preview window to a tiled Claude terminal window in the same
tab before it splits. Evidence: lua/claudecode/diff.lua:803-810,
diff.lua:263-276, diff.lua:279-281.
- Since coder#483 and coder#469, diff_inline.lua no longer calls
_resolve_diff_as_saved (it calls _write_proposal) and it also calls
_register_reject_when_hidden and _close_proposal_window. Evidence:
lua/claudecode/diff_inline.lua:471, diff_inline.lua:485,
diff_inline.lua:616, lua/claudecode/diff.lua:1228-1234, commits 6eef218
(coder#483) and 36f4a0e (coder#469).
- lockfile.update no longer exists: PR coder#459 (0e63cc7) removed it.
Evidence: lua/claudecode/lockfile.lua:74, lockfile.lua:84,
lockfile.lua:208, lockfile.lua:239, lockfile.lua:292 (the only M
functions), commit 0e63cc7 (coder#459).
- terminal.setup also calls get_provider, whose fallback warnings go
through logger.warn, as the bullet above says. Evidence:
lua/claudecode/terminal.lua:584, terminal.lua:124-130, terminal.lua:145,
terminal.lua:163-166, terminal.lua:178.
- A connected send that the debounce queues still fires
ClaudeCodeSendComplete. Only the disconnected branch fires none.
Evidence: lua/claudecode/init.lua:383-398,
lua/claudecode/init.lua:1395-1400, lua/claudecode/init.lua:401-416.
- The terminal call and the event happen only when _broadcast_at_mention
succeeds. Evidence: lua/claudecode/init.lua:383-400.
- No handshake polling exists since coder#454/coder#480. The 50 ms, 25 ms and
connection_wait_delay facts repeat main's queue bullet. A send with
debouncing off still queues behind a non-empty queue. Evidence:
lua/claudecode/init.lua:283-289, lua/claudecode/init.lua:110-111,
lua/claudecode/init.lua:1381-1396.
- PR coder#452 added TextChangedI and TextChangedP. Evidence:
lua/claudecode/selection.lua:387.
- Since coder#452 reconcile_held_selection reports the cursor at CursorMoved,
not after visual_demotion_delay_ms. A text change and another buffer
also end the hold. Evidence: lua/claudecode/selection.lua:237-265,
lua/claudecode/selection.lua:118-159,
lua/claudecode/selection.lua:576-584, tests/selection_test.lua:2341.
- find_main_editor_window skips only a fixed filetype list, not every
explorer or outline sidebar. Evidence: lua/claudecode/diff.lua:157-190.
- find_main_editor_window can return a window of another tab (current
tab first), as the bullet above says. Evidence:
lua/claudecode/diff.lua:125-142, lua/claudecode/diff_inline.lua:382-409.
- Probe: proposal "a\n\n" is accepted as "a\n" (split_lines drops one
trailing empty line, and the newline fix adds none back). Evidence:
lua/claudecode/diff_inline.lua:44-52,
lua/claudecode/diff_inline.lua:526-540,
lua/claudecode/diff_inline.lua:303, probe_unified_accept.out.
- An invalid diff percentage falls back to split_width_percentage, not
straight to 0.30. Evidence: lua/claudecode/diff.lua:50-69.
- PR coder#469 set the unified proposal buffer to bufhidden=hide (main's
bullet above says so), WinClosed/BufHidden reject it. Evidence:
lua/claudecode/diff_inline.lua:299,
lua/claudecode/diff_inline.lua:475-485,
lua/claudecode/diff.lua:1189-1222.
- The close_tab tool also passes a client_id. Evidence:
lua/claudecode/tools/close_tab.lua:53,
lua/claudecode/diff.lua:2105-2148.
- At qa! the BufUnload autocmd (diff.lua:1283) calls
_resolve_diff_as_rejected before the VimLeavePre cleanup runs, for one
pending diff. With two or more native diffs, a keep_empty reject of a
new-file diff deletes its buffer and stops the exit loop, so the later
diffs stay pending until the cleanup (the Augroups bullet states this).
Evidence: lua/claudecode/diff.lua:1171-1175,
lua/claudecode/diff.lua:1279-1285, lua/claudecode/diff.lua:1523-1524,
probes on 0.8.0 to 0.12.3.
- PR coder#478 added the WinClosed autocmd that deletes the handler.
Evidence: lua/claudecode/diff.lua:455-463,
tests/unit/diff_new_tab_terminal_handler_spec.lua:127.
- require('claudecode').setup validates log_level (assert) before
logger.setup runs. Evidence: lua/claudecode/init.lua:462-465,
lua/claudecode/config.lua:15, lua/claudecode/config.lua:98-106,
lua/claudecode/logger.lua:26-40.
- Neovim 0.10 has no 'messagesopt' and always stops. The parenthetical
read as if only 0.11+ stops. Evidence: probe_echospace.out (0.10.4: NO
SUCH OPTION, 0.11.5: hit-enter,history:500).
- With laststatus=1 and two windows v:echospace is 68, not 51. Evidence:
probe_echospace.out.
- PR coder#445 removed the skip. The loop runs every file. Evidence:
scripts/run_integration_tests_individually.sh:86-88.

</details>

<details>
<summary>lua/claudecode/server/README.md: 13 corrections with
evidence</summary>

- The pending check/idle pair of a tick runs judge_silence and
judge_stall, not only a stall confirmation Evidence:
lua/claudecode/server/tcp.lua:601-611, tcp.lua:424-435, tcp.lua:490.
- The state stays `closing` after on_close. On_close only removes the
client (README line on client states says it never becomes `closed`)
Evidence: lua/claudecode/server/client.lua:363-371, tcp.lua:227-228,
tcp.lua:275-283.
- start_close_timer also sets `closed` when a keepalive window passes
with no write progress Evidence:
lua/claudecode/server/client.lua:459-466, client.lua:140-144.
- Completes the state list: _disconnect_client does not touch
client.state (probe: on_disconnect saw state `connected` after EOF)
Evidence: lua/claudecode/server/tcp.lua:209-217, tcp.lua:255-294,
probe-server/events.log client_8, client_9.
- PR coder#480 added connected_at to get_client_info Evidence:
lua/claudecode/server/client.lua:522-531, client.lua:292.
- PR coder#454 moved process_mention_queue from on_connect to on_open
Evidence: lua/claudecode/server/init.lua:57-73, client.lua:294-296.
- After the merged sentences, `It` reads as process_data or the Close
frame. Process_data does check the whole message Evidence:
lua/claudecode/server/frame.lua:156-168, client.lua:184-188.
- parse_frame rejects a 1-byte Close payload with 1002 (same bullet
group says so), so only an empty payload reaches the 1000 default
Evidence: lua/claudecode/server/frame.lua:157-160, client.lua:352-361,
probe S5, S6.
- Since PR coder#455/coder#488 last_pong plays no part in the keepalive Evidence:
lua/claudecode/server/client.lua:381-383, client.lua:529,
tcp.lua:220-222, tcp.lua:474-475.
- PR coder#497/coder#500: tools/call answers -32602 for a non-string name before
handle_invoke. Contradicts the JSON-RPC errors bullet Evidence:
lua/claudecode/server/init.lua:353-368.
- Two callbacks share the name: process_data's on_error carries client
errors (other bullets say so), server.on_error only server failures
Evidence: lua/claudecode/server/tcp.lua:121,190,196,524,
tcp.lua:229-231, client.lua:202,275.
- The 101/error write callback calls on_error when err or not success
Evidence: lua/claudecode/server/client.lua:271-279, probe events
client_1..3.
- The message carries the tool name Evidence:
lua/claudecode/tools/init.lua:90-96.

</details>

<details>
<summary>lua/claudecode/terminal/README.md: 5 corrections with
evidence</summary>

- FALSE since coder#464: native close and the first get_active_bufnr of a
module copy adopt a marked live terminal. The sentence also contradicts
the same bullet's second sentence. Evidence:
lua/claudecode/terminal/native.lua:292,
lua/claudecode/terminal/native.lua:574-577, probe
probe-terminal/probe_native_reload.out.
- INCOMPLETE: on Neovim 0.12.3 termopen also returns 0 under :silent!
for a modified buffer, which is not an invalid argument. Evidence: probe
probe-terminal/probe_termopen.out (cases D, E, F, G, H, I),
lua/claudecode/terminal/native.lua:237-242.
- INCOMPLETE: through the dispatcher a missing Snacks never reaches the
ERROR notification, because get_provider falls back to native. Evidence:
lua/claudecode/terminal.lua:133-146,
lua/claudecode/terminal.lua:192-196,
lua/claudecode/terminal/snacks.lua:691-693.
- INCOMPLETE: an unset snacks_win_opts.position falls back to split_side
(left or right), so cc_show re-creates a split itself. Evidence:
lua/claudecode/terminal/snacks.lua:374-377.
- INCOMPLETE: for an adopted terminal each entry with a config replaces
term._cc.config, so it is not the config captured at open. Evidence:
lua/claudecode/terminal/snacks.lua:463-474,
lua/claudecode/terminal/snacks.lua:646-647,
lua/claudecode/terminal/snacks.lua:747.

</details>

<details>
<summary>lua/claudecode/tools/README.md: 14 corrections with
evidence</summary>

- FALSE after PR coder#500: handle_invoke uses `params.arguments or {}`, so
no Lua error and no -32000 (probe p1: openFile/saveDocument -32602,
getDiagnostics []). Also contradicted line 17. Evidence:
lua/claudecode/tools/init.lua:88, lua/claudecode/server/init.lua:357,
probes/p1_static.out.
- FALSE: the script loops over every tests/integration/*_spec.lua with
no skip. PR coder#445 (a4d3238) made it run command_args_spec again.
Evidence: scripts/run_integration_tests_individually.sh:86.
- Misleading: bufnr() finds 'file.txt', 'b/file',
'./dir/../dir/file.txt' and '' (current buffer). Probes p1 and p5.
Evidence: lua/claudecode/tools/save_document.lua:31,
lua/claudecode/tools/check_document_dirty.lua:27, probes/p1_static.out,
probes/p5_bufnr.out.
- INCOMPLETE after PR coder#457: with no stored selection it reads the editor
(the bullet above says so), so it does not always return the stored
selection. Evidence:
lua/claudecode/tools/get_current_selection.lua:37-39, :83-95.
- INCOMPLETE: the -32603 table is returned, not raised, so
build_response sends it as `{ result = ... }`. Also joins the
hard-wrapped bullet like the rest of the file. Evidence:
lua/claudecode/tools/close_tab.lua:26-33,
lua/claudecode/tools/init.lua:180-182,
lua/claudecode/server/init.lua:387.
- Format: the merge wrapped a quoted error message across two lines
inside a code span, so a search for the message misses it. Every other
bullet is one line. Evidence: lua/claudecode/tools/open_diff.lua:56.
- Coherence: the inserted sentence split 'finds it' from 'Without one',
so 'one' no longer names the loaded buffer. The moved claim is TRUE
(probe p1). Evidence: lua/claudecode/tools/open_file.lua:133-140,
probes/p1_static.out.
- INCOMPLETE: expand_tilde leaves `~user/...` unchanged. Evidence:
lua/claudecode/utils.lua:167-181,
lua/claudecode/tools/open_file.lua:547.
- INCOMPLETE: with makeFrontmost=false the answer is the JSON object,
and in the middle of a command openFile appends a suffix. Evidence:
lua/claudecode/tools/open_file.lua:735, :741, :746-760,
probes/p4_openfile_save.out.
- INCOMPLETE: source and code are nil (absent from the JSON) when the
diagnostic has none. Evidence:
lua/claudecode/tools/get_diagnostics.lua:82-83.
- INCOMPLETE: nvim_get_current_tabpage() returns a handle. Probe p1:
tabpagenr() 2, handle 3, groupIndex/viewColumn 2/3. Evidence:
lua/claudecode/tools/get_open_editors.lua:19, :65-66,
probes/p1_static.out.
- INCOMPLETE: reload_file_buffers skips a modified buffer, and a diff
kept for another client does not close. Evidence:
lua/claudecode/diff.lua:2015-2018, :2025-2027, :1104.
- INCOMPLETE: no API tells which loaded buffers were opened before their
file existed. The loop marks every loaded, named, normal buffer whose
file is not readable. Evidence:
lua/claudecode/tools/open_file.lua:170-179.
- INCOMPLETE (minor): with diff_opts.layout = "unified" diff.lua hands
the result to diff_inline, which builds it. Evidence:
lua/claudecode/diff.lua:1050-1053,
lua/claudecode/diff_inline.lua:526-537.

</details>

<details>
<summary>scripts/README.md: 7 items of the coder#462 verifiers, each checked
against the scripts</summary>

- The CI paragraph named no job for "the same job". It now names the
three jobs of `.github/workflows/test.yml` (`unit-tests`,
`integration-tests`, `covered-lock`), says that `integration-tests` runs
the runner twice (the second time with a `TMPDIR` that holds a space and
a `%`), and says that `Required` needs only `unit-tests` and
`integration-tests` (`test.yml` lines 52-80, 98-167, 215-234).
- `tla/check-lib.sh` exit precedence: 2 wins over 1, and 1 wins over 3.
A stale lock plus a broken rule exits 1. `--hash-only` exits 0 or 3, and
`--record` exits 0. A script that sources the lib and ends without
`tla_main` turns an exit 0 into exit 2. Probed with a fake `curl` and a
fake TLC: default with a broken rule and a stale lock gave rc=1, default
with a pass and a stale lock rc=3, a broken `TypeOK` rc=2, `--record` on
a stale lock rc=0 (`scripts/tla/check-lib.sh:81-84`, `:226-246`,
`:313-333`, `:406-419`).
- `tla_init` comes right after `SKILL` and `MODEL`, before
`covered_block` (`.agents/skills/verify-diff-lifecycle/check.sh:15-29`,
`scripts/tla/check-lib.sh:461`).
- `run-release-please.sh` prints nothing itself, and exits 1 with no
message when `release-please` is missing. The runner's stdout and file
outputs are listed: release-please's own log lines go to stdout (info
and debug) and stderr (warnings and errors), the `key=value` lines go to
`GITHUB_OUTPUT` or to stdout when it is unset, and `--dry-run` prints
JSON after the log lines and still runs Communique. Probed without a
token and without network: wrapper rc=1 with no output, and `Error:
GITHUB_TOKEN must be set` (`scripts/release-please-runner.js:487-492`,
`:555`, `:590`, `:614`).
- `--record` and `--hash-only` exit before the TLC jar is fetched, so
they never download it and need no `java`. A plain run and `--mutants`
do. Probe with an empty cache and a fake `curl`: `--hash-only` and
`--record` made 0 `curl` calls, a plain run and `--mutants` made 1 and
exited 2 (`scripts/tla/check-lib.sh:452-453`, `:147-151`).
- The Lua repros: under `nvim -l` an uncaught error prints `E5113` and
exits 1, `cquit N` and `os.exit(N)` exit N, and a bare file name run
from `scripts/` fails with `module 'claudecode.diff' not found`. In the
`:luafile` form of CI an uncaught error falls through to `cquit 2`
(probes on Neovim 0.12.3, and `-l` exit 1 on 0.9.5, 0.10.4 and 0.11.5).
- `repro_issue_70.sh` and `repro_issue_277.sh` side effects that the
file did not list: work dir names, the `att-home` folder, the empty
`/tmp/agent-tty/<8 hex>` socket folder and the `homes.json` entry that
`agent-tty` leaves, the inherited environment, the three background
probes of 70 and their `PORT.lock` files (a SIGKILL leaves the lock),
the unpinned diffview.nvim clone of 277, the state and cache files of
`NVIM_APPNAME=issue-277`, the `perl` timeouts, and the nested copy under
an existing `issue277-artifacts-latest`. A full run of 277 with private
dirs took 32 s. A SIGKILL run left the lock file and 3 processes until
`agent-tty destroy`.
- `manual_test_helper.lua` calls the handler itself, so no response
sender is stored for its coroutine. Any resolution after the handler
yields (accept, reject, or `qa!`) logs `No global response sender found
for coroutine`. It is expected and harmless (all four CI-form runs
exited 0).

</details>

## Scope

- No code changes. `covered.lock` does not change.
- Lines of `scripts/README.md` about `repro_issue_218.sh` stay as they
are on `main`. PR coder#458 owns them, and its merge will change them.
- I checked every line that this diff changes. Lines that the diff does
not touch were not re-verified, except where a verifier found one false
while reading its neighbors. Those fixes are in the last correction
commit: `server/mock.lua` is required by
`tests/unit/server/mock_spec.lua`, the augroup and `utils.lua` lists,
the empty `CLAUDE_CONFIG_DIR`, and the Processes bullet.
- Probes ran on Linux with Neovim 0.12.3. One probe ran on 0.10.4. The
old Neovim claims that earlier lanes proved on 0.8 were not run again.
- One claim stays unverified: the sentence in
`lua/claudecode/server/README.md` that a client whose EOF the server
read in the same read as its request is handled as the code says. Six
probe tries did not produce that case. The sentence is a softening, so I
kept it.
- The terminal README says that the wait before a `close` does not
prevent a hang at exit. A probe saw the hang with and without a `close`,
so the probe does not prove that the buffer delete causes it. I did not
open neovim/neovim#42508 (the host has no network).

### Defects in the code that the verifiers found

These need a code fix and a test, so they are not in this docs PR.

1. Accepting a `unified` diff drops one trailing empty line of the
proposal: `"a\n\n"` is accepted as `"a\n"`
(`lua/claudecode/diff_inline.lua:44-52` and `:531`). The native layout
is not affected. A probe shows it. The README says the accepted text is
the proposal lines that setup computed.
2. `lua/claudecode/server/tcp.lua:19` says the PRNG is seeded once per
process. It is seeded once per load of the module.
3. `lua/claudecode/server/init.lua:59-60` logs "Authenticated WebSocket
client connected" from `on_connect`, before the authentication check
runs.
4. `lua/claudecode/cwd.lua:36-42` runs the same `git rev-parse` twice.
5. At exit, a pending native diff can stay unanswered. Two native diffs
are pending, and the first one (the lower proposal buffer number) is a
new file in the current tab with `diff_opts.on_new_file_reject =
"keep_empty"` (the default). On `:qa!` its reject deletes its proposal
buffer (`lua/claudecode/diff.lua:1171-1175`, `:1523-1524`), Neovim's
exit loop stops firing `BufUnload` (`src/nvim/main.c`, the
`bufref_valid` check), and the `VimLeavePre` cleanup removes the second
diff with no answer. Reproduced on Neovim 0.8.0, 0.9.5, 0.10.4, 0.11.5
and 0.12.3, with and without the server. A separate PR (F13) will make
exit answer every pending diff. It will change the exit sentences of
this PR: the openDiff bullet in `README.md` (Global Invariants, the
sentence that starts "When Neovim exits") and the Augroups bullet in
`lua/claudecode/README.md` (the sentences about `BufUnload` and the
exception).

## Blast Radius

Seven README files. Nothing loads them at runtime. A wrong correction
here misleads a reader but breaks no behavior.

## Verification

- Six Opus verifiers, one per file (two for the 198 claims of
`lua/claudecode/README.md`). Each checked every claim that the diff adds
or changes against the code, and every `[file:symbol]` citation, with
file and line evidence. Their findings, edits and probe scripts are in
the evidence folder of the run.
- A second, independent verifier checked the head `bcacd14c` (134
sentences and all seven scripts items, with probes on Neovim 0.8.0 to
0.12.3). It found one false sentence (the exit sentence, now fixed with
its exception) and seven wording findings (fixed). It found no other
false claim.
- Citation check on the final head: 760 citations, links and test paths
over the seven files, 1 flagged. The flagged one is
`[plugin/claudecode.lua:pcall(require, "claudecode")]`, an expression
that `main` already cites in that README.
- `mise run all`: exit 0 on the final head `e3e48af1` (1682 successes, 0
failures, 0 errors, 24 pending, `luacheck` 0 warnings and 0 errors in
178 files). `mise run format-check`: exit 0 on the final head (355
files, 0 changed). 1676 successes, 0 failures, 0 errors, 24 pending.
`luacheck` reports 0 warnings and 0 errors in 178 files. The format step
changes 0 files.
- `git merge-tree --write-tree` of the head and `origin/main`
(`3a0bb769`) is clean.
- The merges of coder#524 and coder#530 changed one sentence of
`lua/claudecode/README.md` and merged two conflict hunks. I checked that
sentence and the merged bullets against `lua/claudecode/terminal.lua`
and `lua/claudecode/selection.lua` at `a41254a4`. I did not run a
verifier agent over the merge delta.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…queue (coder#523)

## What this adds

`.agents/skills/verify-mention-queue/`: a TLA+ model of the @mention
queue and the WebSocket clients it sends to, a `check.sh` on the shared
harness `scripts/tla/check-lib.sh`, a `/statechart` view, and 12
headless Neovim replays. CI runs it in the `covered-lock` job with the
other `verify-*/check.sh`.

The model describes the code on main after coder#454 and coder#480: one queue, a
deadline per mention (`connection_timeout + connection_wait_delay` after
the send), a flush that waits until some connected client has been
connected for `connection_wait_delay` since its handshake
(`connected_at`), a broadcast to every connected client, the drain to a
ready client past the deadlines, and the write reports, the refused
write included. It has two client slots, so a broadcast can reach two
clients and the readiness rule can count both.

This PR also fixes the doc findings from the last verifier pass on coder#480,
and adds the test the coder#480 advisor found missing:

- `.agents/skills/verify-claudecode-nvim/features/send-at-mention.md`: a
connected client that is not ready yet gets "Send timeout", not
"Connection timeout", and the error comes at the deadline, 10.6 s after
the send.
- `lua/claudecode/config.lua`, `dev-config.lua`: the `queue_timeout`
comments no longer say that mentions wait up to `connection_timeout`.
- `fixtures/issue-70/init.lua`: the mention waits until its deadline,
10.6 s by default, not 10 s.
- `lua/claudecode/types.lua`: `connection_timer` runs while the queue
holds a mention, also while a ready client drains it. It is nil only
after a deadline passed while a ready client was connected, until
`queue_mention` or a flush with no ready client starts it again.
- `tests/unit/mention_queue_spec.lua`: `D: a send 400 ms after the
handshake waits until connection_wait_delay has passed since the
handshake`, and the same at 540 ms. Each expects nothing 590 ms after
the handshake and the mention by 610 ms. A flush that sends while under
20, 100 or 200 ms of the wait are left fails one of them. `D: the first
ready client sends the queue, though a second client connected later is
not ready yet` fails when readiness waits for the newest client
(`math.max` in place of `math.min`).

These change comments and tests only. No runtime code changes.

## What the model checks

- `NoSilentDrop`: a mention you sent reaches Claude, or you see an
error, unless you stopped the integration first.
- `SentInOrder`: each Claude gets the mentions in the order you sent
them.
- `AtMostOnce`: each Claude gets each mention at most once.
- `SentWhenReady`: a mention goes out only while some connected client
has been connected for `connection_wait_delay` since its handshake.
Default config only: with `disable_broadcast_debouncing` on, a send
while the queue is empty skips this wait. That is a known bug, which a
follow-up PR fixes (repro below).
- `NothingAfterStop`: after `:ClaudeCodeStop`, no mention queued before
the stop reaches Claude, and the queue raises no Lua error.
- `NoStrandedMention` (liveness): every queued mention leaves the queue.
- `ReadyDelivers` (liveness): while a ready Claude stays connected,
every queued mention goes out to it.
- `FlushDelivers` (liveness, with no deadline that passes): the flushes
alone send a ready Claude every queued mention, so none waits for a
deadline first.

`check.sh` reads 19 guards from `init.lua`, `server/init.lua`,
`server/tcp.lua` and `server/client.lua`, and runs the model with each
one as the code has it. `FlushExpiresOverdue` alone breaks
`NoStrandedMention`. `QueueKeepsDeadline` alone breaks no rule in this
untimed model: the next flush with no ready client clears the overdue
mentions, so only the time of the error changes. So `--mutants` checks
it only as a pair with `FlushExpiresOverdue`. The unit tests `A, F4: a
Claude whose handshake comes 10.3 s after the send …` and the `S:` churn
tests at 700/300 and 650/500 ms catch it alone.

A code change that keeps every fact TRUE reads only STALE, for example a
change of the readiness wait or a deadline that never comes. `SKILL.md`
names such changes, and the unit tests catch them.

## Bugs it found, and the fixes

The first model, on the code before coder#454, broke five of its six rules.
Each finding was replayed on real Neovim. coder#454 fixed all nine:

1. A: a mention that waited for a slow Claude start was dropped at the
flush as older than `queue_timeout`, with no error.
2. H: Claude dropped during the 600 ms wait, and the plugin sent the
mention to no client and logged success.
3. E: a send during the wait overtook the mention queued before it.
4. C: `:ClaudeCodeStop` during the wait raised a Lua error, and a stop
and a start sent the old mention to the new Claude.
5. B: the connection timer stayed set after it fired, so later mentions
never timed out.
6. S: a send while connected started no timer, so a drop within the 50
ms debounce stranded the mention.
7. F: a client that sent its CLOSE frame still counted as connected.
8. D: the flush hook ran at the TCP accept, before the handshake, so a
send in between waited for the timeout while Claude was connected.
9. G: a queued mention kept its raw path, while a connected send was
made absolute.

Four more came up in review of coder#454's fixes and while coder#480 was built,
and this model's second version found one. coder#480 fixed all five. Items 13
and 14 were in coder#480's own intermediate commits and never reached main:

10. A second client's handshakes postponed a due flush (Codex, round 6
on coder#454).
11. A write that libuv refused at once dropped the mention with no
error.
12. Clients that connected and left without overlap kept a mention
waiting with no error.
13. On a head of coder#480 before its drain rule (cefe258), with many
mentions queued while Claude started, the first deadline cleared the
rest while a ready Claude was taking them, 25 ms apart. With 100
mentions and a handshake 8.6 s after the send, fewer than half arrived,
and the count changed with the host's load. `drain.lua` passes only when
all 100 arrive, in order, with no error.
14. Found by this model: when Neovim ran the handshake's `on_open`
callback late, the deadline found Claude ready before any flush was due,
cleared nothing and stopped. When that Claude left before `on_open` ran,
nothing sent or cleared the queue. The spared deadline now schedules a
flush.

## Proof

On the head of this PR, after merging main at 9bfc39c. The statechart
runs ran on 195c1d8. The model and `statechart-view.ts` are unchanged
since then, and the main merges after it change no mention-queue
behavior:

- `check.sh`: PASS, exit 0. `main` 270,120 distinct states,
`live-NoStrandedMention` 57,814, `live-ReadyDelivers` 57,814,
`live-FlushDelivers` 24,692. All 19 facts read TRUE.
- `check.sh --mutants`: exit 0 on 86e2359 (the round-3 verifier's run,
2494 s). Every guard is caught by the rule `EXPECT` names, and every
rule breaks at least once. Since then the model changed only in
comments, `check.sh` and `scripts/tla/` are byte-identical, and the
merged code still reads all 19 facts TRUE, so each mutant run gets the
same TLC input.
- A scratch break of one guard in `lua/` (`expire_mentions` spares a
ready client's queue without scheduling a flush) makes `check.sh` exit
1: `SparedQueueFlushes` reads FALSE and `live-NoStrandedMention` is
BROKEN. After the restore, `git diff --shortstat` is empty.
- `scripts/tla/test-check-lib.sh`: 44 cases, 0 failed.
- `replays/run.sh`, the same replays on four trees, old trees on Neovim
0.12.3:
- this head: 12 of 12 kept their promise on Neovim 0.8.0 and 12 of 12 on
0.12.3.
- b92e7ac (the code before coder#454): 0 of 12. Each replay prints its bug,
for example `order` gets `second.txt, first.txt`, `stop-in-wait` gets
`init.lua:234: attempt to index field 'server' (a nil value)`, and
`churn` loses the mention with no error.
- 08dbcae (the code before coder#480) and d1a6724 (a coder#480 head before the
deadline per mention): 11 of 12. `churn` keeps the mention queued with
no error.
- `churn`, 10 runs on this head: 10 kept, the mention cleared with one
error 10605 to 10655 ms after the send. The replay now refreshes the
loop clock before the send and reads the send and the clearing on that
clock, the one that stamps the deadline. Before, it read real time
against a loop clock that lagged 17 to 26 ms after `setup()`, and
printed BROKEN on correct code in about half the runs.
- `order` with a scratch loss of the second mention (a second
`table.remove(queue, 1)` in the flush): BROKEN, exit 1. On this head:
KEPT. Before, its verdict counted a lost second mention as kept. The
other replays pass their verdict expression straight to `L.verdict`, so
a `false` or `nil` reads BROKEN there.
- `replays/run.sh` portability (Codex round 2): from a checkout path
with a space, with `OUT=./rel-logs` and `NVIM=./bin/nvim`, 2 of 2
replays kept, and every `XDG_*_HOME` sits under the absolute `OUT`. On
the same path, eb16b3a's `run.sh` printed no verdict (`E518: Unknown
option`). Each run is now bounded by `perl -e 'alarm 120; exec @argv'`,
not GNU `timeout`: the same wrapper with `alarm 2` ended a hung headless
Neovim after 2 s.
- `replays/run.sh` exit codes (Codex round 5): a replay counts as `KEPT`
only when it printed `KEPT` and its Neovim exited 0. I added two scratch
replays to a copy of this head. One prints `KEPT` and then exits 1: it
reads `BROKEN … Neovim exited 1 after the verdict "KEPT …"`. The other
prints `KEPT` and quits as `L.verdict` does: it reads `KEPT`. The 12
real replays read `KEPT` on Neovim 0.8.0 and on 0.12.3.
- `replays/run.sh` on Neovim 0.8 (Codex round 3): each replay now runs
through `:luafile`, as `.github/workflows/test.yml` does, since `nvim
-l` needs Neovim 0.9. On 0.8.0 and 0.12.3, from a checkout path with a
space and with a relative `OUT`, a replay that raises before its verdict
reads `BROKEN … printed no verdict` within 3 s, with its error in the
log.
- `statechart-view.ts`: `/statechart` refuses a view whose regions
change between states, so every state now has the regions "Sent early",
"Lua error" and "Sent after a stop". Each one reads "no" until its harm
happens. The generator charted three scratch breaks with 1 mention and 1
connect, and each exited 1 with a chart:
- `StopClearsQueue` off, against `NothingAfterStop`: the flush after the
stop raises a Lua error.
  - `FlushWaitsForReady` off, against `SentWhenReady`.
- `StopClearsQueue` off, against `~leaked` alone: the last state shows
"Sent after a stop: a mention queued before :ClaudeCodeStop reached a
Claude after the stop".
Pele 1.9.0 rendered each chart to SVG. On the same leak input, 93fb4cb's
view exited 2 ("Return the same regions for every state") and charted
nothing.
- `verify-diff-lifecycle/check.sh`: PASS. Main's coder#539 added a log line
to `on_open` and changed a comment in `tcp.lua`. The flush is still
scheduled the same way, so I re-recorded the mention model's
`covered.lock` after `check.sh` read all 19 facts TRUE.
- busted, full suite, shuffled (seed 12345): 1709 successes, 0 failures,
0 errors, 25 pending. luacheck: 0 warnings in 181 files. `treefmt --ci`:
0 files changed. shellcheck (`--exclude=SC1091,SC2016`) on `check.sh`
and `replays/run.sh`: clean.

<details>
<summary>Repro of the known bug:
<code>disable_broadcast_debouncing</code> skips the readiness
wait</summary>

It runs the real plugin in a headless Neovim with a loopback Claude,
sends right after the handshake, and measures on the loop clock that
stamps `connected_at`. Save it as `repro-immediate.lua` in the repo
root, then run from there (`IMMEDIATE=0` is the default config):

```bash
repo=$PWD dir=$(mktemp -d)
cp repro-immediate.lua "$dir" && cd "$dir"
for imm in 1 0; do
  CLAUDE_CONFIG_DIR="$dir/claude" REPLAY_LIB="$repo/.agents/skills/verify-mention-queue/replays/lib.lua" IMMEDIATE=$imm \
    nvim --headless -u NONE -i NONE --cmd "set rtp^=$repo" -c 'luafile repro-immediate.lua' -c 'cquit 1'
done
```

```lua
-- Does a send right after Claude's handshake reach Claude before connection_wait_delay has passed?
-- IMMEDIATE=1 sets disable_broadcast_debouncing = true; otherwise the default config (control).
-- Times are on the loop clock that the plugin stamps connected_at with (vim.loop.now()).
local uv = vim.uv or vim.loop
local L = dofile(vim.env.REPLAY_LIB)
local immediate = vim.env.IMMEDIATE == "1"
local cc = require("claudecode")
cc.setup({ terminal = { provider = "none" }, track_selection = false, disable_broadcast_debouncing = immediate })
assert(cc.state.server, "the server did not start")
local c = L.connect()
assert(L.upgrade(c), "no handshake")
local connected_at
for _, info in ipairs(require("claudecode.server.init").get_status().clients or {}) do
  if info.state == "connected" then
    connected_at = info.connected_at
  end
end
assert(connected_at, "no connected_at")
uv.update_time()
local sent_age = uv.now() - connected_at
L.send(L.file("early.txt"))
local got_age
vim.wait(2000, function()
  if #L.received(c) > 0 then
    uv.update_time()
    got_age = uv.now() - connected_at
    return true
  end
  return false
end, 1)
local wait = cc.state.config.connection_wait_delay
io.stdout:write(
  string.format(
    "RESULT disable_broadcast_debouncing=%s connection_wait_delay=%d sent %d ms after the handshake; Claude got it %s; queue=%d; %s\n",
    tostring(immediate),
    wait,
    sent_age,
    got_age and (got_age .. " ms after the handshake") or "never (2 s)",
    #(cc.state.mention_queue or {}),
    (got_age and got_age < wait) and "BEFORE READY" or "after ready"
  )
)
vim.cmd("qall!")
```

Output:

```text
NVIM v0.12.3: RESULT disable_broadcast_debouncing=true connection_wait_delay=600 sent 4 ms after the handshake; Claude got it 7 ms after the handshake; queue=0; BEFORE READY
NVIM v0.12.3: RESULT disable_broadcast_debouncing=false connection_wait_delay=600 sent 4 ms after the handshake; Claude got it 600 ms after the handshake; queue=0; after ready
NVIM v0.8.0-1210-gd367ed9b2: RESULT disable_broadcast_debouncing=true connection_wait_delay=600 sent 3 ms after the handshake; Claude got it 3 ms after the handshake; queue=0; BEFORE READY
NVIM v0.8.0-1210-gd367ed9b2: RESULT disable_broadcast_debouncing=false connection_wait_delay=600 sent 3 ms after the handshake; Claude got it 600 ms after the handshake; queue=0; after ready
```

</details>

## How to run

```bash
.agents/skills/verify-mention-queue/check.sh            # about 60 s
.agents/skills/verify-mention-queue/check.sh --mutants  # 25 to 50 min
NVIM=nvim .agents/skills/verify-mention-queue/replays/run.sh  # about 2 min
```

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-opus-5-5` • Thinking: `xhigh`_
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