From 95b7160d5a880c5365fcfd36adca89ddcf0e96e9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:01:37 +0000 Subject: [PATCH 01/35] test(mention): reproduce the mention-queue findings A to H 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. --- tests/unit/mention_queue_spec.lua | 525 ++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 tests/unit/mention_queue_spec.lua diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua new file mode 100644 index 000000000..5ebdcc174 --- /dev/null +++ b/tests/unit/mention_queue_spec.lua @@ -0,0 +1,525 @@ +--- The @ mention queue keeps one promise: a mention you send reaches Claude once and in the order you sent it, or +--- you see an error, and after a stop nothing is sent and no Lua error happens. Each test drives the real plugin +--- (init.lua and the real server/init.lua, tcp.lua and client.lua) on a fake clock, and plays Claude through the real +--- TCP and WebSocket code paths. A test is named after the finding of the verify-mention-queue model it covers. +require("tests.busted_setup") + +describe("The @ mention queue", function() + local token = "test-token-0123456789" + local upgrade = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + .. "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + .. "x-claude-code-ide-authorization: " + .. token + .. "\r\n\r\n" + + local loaded = { + "claudecode", + "claudecode.config", + "claudecode.logger", + "claudecode.terminal", + "claudecode.lockfile", + "claudecode.diff", + "claudecode.server.init", + "claudecode.server.tcp", + "claudecode.server.client", + "claudecode.server.handshake", + } + local saved = {} + local loop, errors, claudecode, frame + + -- A fake libuv loop. Like libuv, it fires every timer that is due in one phase, in the order they were started, + -- and then the main loop runs every scheduled callback before time moves on. A callback that raises is recorded, + -- as Neovim reports it. + local function new_timer() + local timer = { active = false, closing = false } + function timer:start(timeout, repeat_ms, callback) + assert(type(timeout) == "number" and timeout >= 0, "timer started with a bad timeout") + self.due, self.repeat_ms, self.callback, self.active = loop.now + timeout, repeat_ms, callback, true + table.insert(loop.timers, self) + return 0 + end + function timer:stop() + self.active = false + return 0 + end + function timer:close() + assert(not self.closing, "timer closed twice") + self.active, self.closing = false, true + end + function timer:is_active() + return self.active + end + function timer:is_closing() + return self.closing + end + return timer + end + + local function protect(fn) + local ok, err = pcall(fn) + if not ok then + table.insert(loop.lua_errors, tostring(err)) + end + end + + ---Let `ms` milliseconds pass. + local function run(ms) + local stop_at = loop.now + ms + while true do + while #loop.scheduled > 0 do + protect(table.remove(loop.scheduled, 1)) + end + local due + for _, timer in ipairs(loop.timers) do + if timer.active and timer.due <= stop_at and (not due or timer.due < due) then + due = timer.due + end + end + if not due then + break + end + loop.now = due + local firing = {} + for _, timer in ipairs(loop.timers) do + if timer.active and timer.due == due then + table.insert(firing, timer) + end + end + for _, timer in ipairs(firing) do + -- A timer that an earlier callback of this phase stopped does not fire. + if timer.active then + if timer.repeat_ms and timer.repeat_ms > 0 then + timer.due = timer.due + timer.repeat_ms + else + timer.active = false + end + protect(timer.callback) + end + end + end + loop.now = stop_at + end + + ---Run fn from a uv timer callback `ms` from now, in the same phase as the plugin's timers that are due then. + local function at(ms, fn) + new_timer():start(ms, 0, fn) + end + + before_each(function() + loop = { now = 0, timers = {}, scheduled = {}, lua_errors = {} } + errors = {} + saved = { + new_timer = vim.loop.new_timer, + now = vim.loop.now, + schedule = vim.schedule, + schedule_wrap = vim.schedule_wrap, + defer_fn = vim.defer_fn, + getcwd = vim.fn.getcwd, + isdirectory = vim.fn.isdirectory, + } + vim.loop.new_timer = new_timer + vim.loop.now = function() + return loop.now + end + vim.schedule = function(fn) + table.insert(loop.scheduled, fn) + end + vim.schedule_wrap = function(fn) + return function(...) + local args = { ... } + vim.schedule(function() + fn(unpack(args)) + end) + end + end + vim.defer_fn = function(fn, timeout) + local timer = new_timer() + timer:start(timeout, 0, function() + vim.schedule(fn) + end) + return timer + end + vim.fn.getcwd = function() + return "/project" + end + vim.fn.isdirectory = function() + return 0 + end + + for _, name in ipairs(loaded) do + package.loaded[name] = nil + end + local function record_error(_, ...) + local parts = {} + for i = 1, select("#", ...) do + parts[i] = tostring(select(i, ...)) + end + table.insert(errors, table.concat(parts, " ")) + end + package.loaded["claudecode.logger"] = { + setup = function() end, + error = record_error, + warn = function() end, + info = function() end, + debug = function() end, + trace = function() end, + one_line = function(level, component, message) + if level == "error" then + record_error(component, message) + end + end, + is_level_enabled = function() + return false + end, + } + package.loaded["claudecode.terminal"] = { + setup = function() end, + open = function() end, + ensure_visible = function() end, + } + package.loaded["claudecode.lockfile"] = { + generate_auth_token = function() + return token + end, + create = function(port, auth_token) + return true, "/lock/" .. port .. ".lock", auth_token + end, + remove = function() + return true + end, + } + package.loaded["claudecode.diff"] = { + setup = function() end, + close_pending_diffs = function() end, + close_diffs_for_client = function() end, + } + frame = require("claudecode.server.frame") + claudecode = require("claudecode") + claudecode.setup({ auto_start = false, track_selection = false, enable_broadcast_debouncing_in_tests = true }) + assert(claudecode.start(false)) + run(0) + end) + + after_each(function() + if claudecode.state.server then + claudecode.stop() + end + vim.loop.new_timer = saved.new_timer + vim.loop.now = saved.now + vim.schedule = saved.schedule + vim.schedule_wrap = saved.schedule_wrap + vim.defer_fn = saved.defer_fn + vim.fn.getcwd = saved.getcwd + vim.fn.isdirectory = saved.isdirectory + for _, name in ipairs(loaded) do + package.loaded[name] = nil + end + end) + + ---Claude opens a TCP connection. Its socket keeps every frame the plugin writes, and the 101 reply's write + ---completes only when the test calls `handshake_written`, as a libuv write completes in a later loop turn. + local function claude_connects() + local tcp = require("claudecode.server.tcp") + local server = claudecode.state.server.state.server + tcp._handle_new_connection(server) + local client = assert(server.clients["client_" .. _G.claudecode_clients_created]) + local claude = { client = client, written = {}, closed = false } + local handle = client.tcp_handle + function handle.write(_, data, callback) + assert(not claude.closed, "write after close") + if data:sub(1, 12) == "HTTP/1.1 101" then + claude.reply_written = callback + else + table.insert(claude.written, data) + if callback then + callback() + end + end + end + function handle.close() + claude.closed = true + end + function handle.is_closing() + return claude.closed + end + + function claude.upgrade() + handle._read_cb(nil, upgrade) + assert(claude.reply_written, "the plugin did not answer the upgrade request") + end + function claude.handshake_written() + claude.reply_written() + assert.equals("connected", client.state) + end + function claude.handshake() + claude.upgrade() + claude.handshake_written() + end + -- Claude's socket ends (EOF): the server forgets the client at once. + function claude.drop() + handle._read_cb(nil, nil) + end + -- Claude sends a CLOSE frame: the client is "closing" until the scheduled on_close removes it. + function claude.close() + handle._read_cb(nil, frame.create_frame(frame.OPCODE.CLOSE, "\3\232", true, true)) + assert.equals("closing", client.state) + end + ---@return string[] paths The filePath of each at_mentioned notification Claude received, in order. + function claude.received() + local paths = {} + for _, data in ipairs(claude.written) do + local parsed = frame.parse_frame(data) + -- vim.json.decode is a stub under busted, and Neovim's encoder escapes "/" as "\/". + if parsed and parsed.opcode == frame.OPCODE.TEXT and parsed.payload:find('"at_mentioned"', 1, true) then + local path = assert(parsed.payload:match('"filePath":"(.-)"'), parsed.payload) + table.insert(paths, (path:gsub("\\/", "/"))) + end + end + return paths + end + return claude + end + + local function send(path) + local ok, err = claudecode.send_at_mention(path, nil, nil, "test") + assert.is_true(ok, err) + end + + local function names(paths) + local out = {} + for i, path in ipairs(paths) do + out[i] = path:match("[^/]+$") + end + return out + end + + local function assert_no_lua_error() + assert.same({}, loop.lua_errors) + end + + it("control: mentions queued while Claude starts reach Claude once it connects, in order, each once", function() + send("/project/a.lua") + send("/project/b.lua") + send("/project/c.lua") + run(1000) + local claude = claude_connects() + claude.handshake() + run(1500) + + assert.same({ "a.lua", "b.lua", "c.lua" }, names(claude.received())) + assert.same({}, errors) + assert_no_lua_error() + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.mention_timer) + assert.is_nil(claudecode.state.connection_timer) + end) + + it("A: a mention queued while Claude starts slowly is delivered, not expired", function() + send("/project/a.lua") + run(4600) + local claude = claude_connects() + claude.handshake() + run(1500) + + assert.same({ "a.lua" }, names(claude.received())) + assert.same({}, errors) + end) + + it("A: Claude's handshake just before connection_timeout stops the timeout", function() + send("/project/a.lua") + run(9900) + local claude = claude_connects() + claude.handshake() + run(1500) + + assert.same({ "a.lua" }, names(claude.received())) + assert.same({}, errors) + end) + + it("H: Claude drops during the wait after its handshake: the next Claude gets the mention", function() + send("/project/a.lua") + local first = claude_connects() + first.handshake() + run(100) + first.drop() + run(1000) + assert.same({}, first.received()) + assert.same({}, errors) + + local second = claude_connects() + second.handshake() + run(1500) + + assert.same({ "a.lua" }, names(second.received())) + assert.same({}, errors) + end) + + it("H: Claude drops during the wait after its handshake and never returns: you see an error", function() + send("/project/a.lua") + local claude = claude_connects() + claude.handshake() + run(100) + claude.drop() + run(11000) + + assert.same({}, claude.received()) + assert.equals(1, #errors) + assert.matches("Connection timeout", errors[1]) + assert.same({}, claudecode.state.mention_queue) + end) + + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() + send("/project/first.lua") + local claude = claude_connects() + claude.handshake() + run(100) + send("/project/second.lua") + run(1500) + + assert.same({ "first.lua", "second.lua" }, names(claude.received())) + assert.same({}, errors) + end) + + it("E: with disable_broadcast_debouncing, a connected send does not overtake queued mentions", function() + claudecode.state.config.disable_broadcast_debouncing = true + send("/project/first.lua") + local claude = claude_connects() + claude.handshake() + run(100) + send("/project/second.lua") + run(1500) + + assert.same({ "first.lua", "second.lua" }, names(claude.received())) + assert.same({}, errors) + end) + + it("C: :ClaudeCodeStop during the wait after a connect raises no Lua error and sends nothing", function() + send("/project/a.lua") + local claude = claude_connects() + claude.handshake() + run(100) + claudecode.stop() + run(1500) + + assert_no_lua_error() + assert.same({}, claude.received()) + assert.same({}, errors) + end) + + it("C: a stop and a start during the wait do not send the old mention to the new Claude", function() + send("/project/a.lua") + local first = claude_connects() + first.handshake() + run(100) + claudecode.stop() + assert(claudecode.start(false)) + local second = claude_connects() + second.handshake() + run(1500) + + assert_no_lua_error() + assert.same({}, second.received()) + end) + + it("B: after a connection timeout, a later mention times out with an error too", function() + send("/project/first.lua") + run(10100) + assert.equals(1, #errors) + + send("/project/second.lua") + run(10100) + + assert.equals(2, #errors) + assert.matches("Connection timeout", errors[2]) + assert.same({}, claudecode.state.mention_queue) + end) + + it("S: Claude drops within the debounce after a connected send: the mention times out with an error", function() + local claude = claude_connects() + claude.handshake() + run(1000) + send("/project/a.lua") + run(10) + claude.drop() + run(11000) + + assert.same({}, claude.received()) + assert.equals(1, #errors) + assert.matches("Connection timeout", errors[1]) + assert.same({}, claudecode.state.mention_queue) + end) + + it("S, control: Claude drops within the debounce after a connected send: the next Claude gets the mention", function() + local first = claude_connects() + first.handshake() + run(1000) + send("/project/a.lua") + run(10) + first.drop() + run(1000) + + local second = claude_connects() + second.handshake() + run(1500) + + assert.same({ "a.lua" }, names(second.received())) + assert.same({}, errors) + end) + + it("F: a client that sent its CLOSE frame does not count as connected", function() + local first = claude_connects() + first.handshake() + run(1000) + first.close() + + assert.is_false(claudecode.is_claude_connected()) + send("/project/a.lua") + run(1000) + + local second = claude_connects() + second.handshake() + run(1500) + + assert.same({}, first.received()) + assert.same({ "a.lua" }, names(second.received())) + assert.same({}, errors) + end) + + it("D: a mention sent between Claude's TCP connect and its handshake is sent once the handshake completes", function() + local claude = claude_connects() + run(50) + send("/project/a.lua") + claude.upgrade() + run(10) + claude.handshake_written() + run(2000) + + assert.same({ "a.lua" }, names(claude.received())) + run(10000) + assert.same({}, errors) + end) + + it("D: a connection timeout that fires as Claude completes its handshake leaves the queue to that Claude", function() + send("/project/a.lua") + run(9000) + local claude = claude_connects() + claude.upgrade() + -- The 101 write completes in the timer phase in which connection_timeout fires, before the main loop runs. + at(1000, claude.handshake_written) + run(2000) + + assert.same({ "a.lua" }, names(claude.received())) + assert.same({}, errors) + end) + + it("G: a queued mention reaches Claude with the same cwd-relative path as a connected send", function() + send("/project/src/queued.lua") + local claude = claude_connects() + claude.handshake() + run(1500) + send("/project/src/connected.lua") + run(1000) + + assert.same({ "src/queued.lua", "src/connected.lua" }, claude.received()) + end) +end) From e209cac05c3def84d2a189c0c0fcf9260b4a4b31 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:02:09 +0000 Subject: [PATCH 02/35] fix(mention): deliver queued @mentions once, in order, or report an error 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. --- lua/claudecode/config.lua | 4 +- lua/claudecode/init.lua | 324 ++++++++++++++----------------- lua/claudecode/server/client.lua | 8 +- lua/claudecode/server/init.lua | 10 +- lua/claudecode/server/tcp.lua | 14 +- lua/claudecode/types.lua | 9 +- tests/unit/server_spec.lua | 36 +++- 7 files changed, 208 insertions(+), 197 deletions(-) diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 42fe7131f..6c8771f7d 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -18,8 +18,8 @@ M.defaults = { focus_after_send = false, visual_demotion_delay_ms = 50, -- Milliseconds to wait before demoting a visual selection connection_wait_delay = 600, -- Milliseconds to wait after connection before sending queued @ mentions - connection_timeout = 10000, -- Maximum time to wait for Claude Code to connect (milliseconds) - queue_timeout = 5000, -- Maximum time to keep @ mentions in queue (milliseconds) + connection_timeout = 10000, -- Maximum time queued @ mentions wait for Claude Code to connect (milliseconds) + queue_timeout = 5000, -- Unused, kept so that configs that set it stay valid (connection_timeout bounds the wait) diff_opts = { layout = "vertical", open_in_new_tab = false, -- Open diff in a new tab (false = use current tab) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 7534f421b..0827b9365 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -69,7 +69,8 @@ function M.is_claude_connected() -- Prefer handshake-aware check when client info is available; otherwise fall back to client_count if status.clients and #status.clients > 0 then for _, info in ipairs(status.clients) do - if (info.state == "connected" or info.handshake_complete == true) and info.handshake_complete == true then + -- A client that sent or answered a CLOSE frame is "closing": it keeps handshake_complete but takes no frames. + if info.state == "connected" and info.handshake_complete == true then return true end end @@ -79,189 +80,164 @@ function M.is_claude_connected() end end ----Clear the mention queue and stop any pending timer -local function clear_mention_queue() - -- Initialize mention_queue if it doesn't exist (for test compatibility) - if not M.state.mention_queue then - M.state.mention_queue = {} - else - if #M.state.mention_queue > 0 then - logger.debug("queue", "Clearing " .. #M.state.mention_queue .. " queued @ mentions") - end - M.state.mention_queue = {} - end - - if M.state.mention_timer then - M.state.mention_timer:stop() - M.state.mention_timer:close() - M.state.mention_timer = nil - end +-- The @ mention queue. A send that does not go out at once waits in M.state.mention_queue, oldest first. A mention +-- leaves the queue only when a connected Claude took it, when its wait timed out with an error, or when the +-- integration stops. While the queue holds a mention, one of two timers runs: +-- - M.state.mention_timer is the next flush: 50 ms after a send, connection_wait_delay ms after Claude's handshake, +-- or 25 ms after the previous mention went out. A flush sends only the oldest mention, so the order holds. +-- - M.state.connection_timer runs while the mentions wait for Claude to connect. After connection_timeout ms it clears +-- them with an error. Claude's handshake stops it, and a flush that finds no connected Claude starts it. +-- A timer's callback runs on the main loop and does nothing once its timer was stopped or replaced, so a stop cancels +-- every pending flush and timeout. +local debounce_delay = 50 -- ms from a send to its flush, so a burst of sends goes out as one batch +local inter_message_delay = 25 -- ms between two mentions of a batch, so Claude is not overwhelmed + +---@return ClaudeCodeMention[] queue The mention queue, created when a test replaced M.state without one +local function mention_queue() + M.state.mention_queue = M.state.mention_queue or {} + return M.state.mention_queue end ----Process mentions when Claude is connected (debounced mode) -local function process_connected_mentions() - -- Reset the debounce timer - if M.state.mention_timer then - M.state.mention_timer:stop() - M.state.mention_timer:close() +---Stop and forget the queue timer M.state[name], if it is set. +---@param name "mention_timer"|"connection_timer" +local function stop_timer(name) + local timer = M.state[name] + if timer then + M.state[name] = nil + timer:stop() + timer:close() end - - -- Set a new timer to process the queue after 50ms of inactivity - M.state.mention_timer = vim.loop.new_timer() - local debounce_delay = math.max(10, 50) -- Minimum 10ms debounce, 50ms for batching - - -- Use vim.schedule_wrap if available, otherwise fallback to vim.schedule + function call - local wrapped_function = vim.schedule_wrap and vim.schedule_wrap(M.process_mention_queue) - or function() - vim.schedule(M.process_mention_queue) - end - - M.state.mention_timer:start(debounce_delay, 0, wrapped_function) end ----Start connection timeout timer if not already started -local function start_connection_timeout_if_needed() - if not M.state.connection_timer then - M.state.connection_timer = vim.loop.new_timer() - M.state.connection_timer:start(M.state.config.connection_timeout, 0, function() - vim.schedule(function() - if #M.state.mention_queue > 0 then - logger.error("queue", "Connection timeout - clearing " .. #M.state.mention_queue .. " queued @ mentions") - clear_mention_queue() - end - end) +---Run fn on the main loop after delay ms, as the queue timer M.state[name], unless that timer is stopped or replaced +---first. A timer of that name that still runs is stopped. +---@param name "mention_timer"|"connection_timer" +---@param delay number Milliseconds +---@param fn function +local function start_timer(name, delay, fn) + stop_timer(name) + local timer = assert(vim.loop.new_timer(), "cannot create the @ mention " .. name) + M.state[name] = timer + timer:start(delay, 0, function() + vim.schedule(function() + if M.state[name] == timer then + stop_timer(name) + fn() + end end) - end + end) end ----Add @ mention to queue ----@param file_path string The file path to mention ----@param start_line number|nil Optional start line ----@param end_line number|nil Optional end line -local function queue_mention(file_path, start_line, end_line) - -- Initialize mention_queue if it doesn't exist (for test compatibility) - if not M.state.mention_queue then - M.state.mention_queue = {} +---Clear the mention queue and stop its timers +local function clear_mention_queue() + local queue = mention_queue() + if #queue > 0 then + logger.debug("queue", "Clearing " .. #queue .. " queued @ mentions") end + M.state.mention_queue = {} + stop_timer("mention_timer") + stop_timer("connection_timer") +end - local mention_data = { - file_path = file_path, - start_line = start_line, - end_line = end_line, - timestamp = vim.loop.now(), - } - - table.insert(M.state.mention_queue, mention_data) - logger.debug("queue", "Queued @ mention: " .. file_path .. " (queue size: " .. #M.state.mention_queue .. ")") +local schedule_mention_flush - -- Process based on connection state - if M.is_claude_connected() then - -- Connected: Use debounced processing (old broadcast_queue behavior) - process_connected_mentions() - else - -- Disconnected: Start connection timeout timer (old queued_mentions behavior) - start_connection_timeout_if_needed() +---Start the timeout of the mentions that wait for Claude to connect, unless it runs already. +local function start_connection_timeout_if_needed() + if M.state.connection_timer then + return end + start_timer("connection_timer", M.state.config.connection_timeout, function() + local queue = mention_queue() + if #queue > 0 and not M.is_claude_connected() then + logger.error("queue", "Connection timeout - clearing " .. #queue .. " queued @ mentions") + clear_mention_queue() + else + -- Claude completed its handshake, and the flush that the handshake schedules has not run yet. + schedule_mention_flush(M.state.config.connection_wait_delay) + end + end) end ----Process the mention queue (handles both connected and disconnected modes) ----@param from_new_connection boolean|nil Whether this is triggered by a new connection (adds delay) -function M.process_mention_queue(from_new_connection) - -- Initialize mention_queue if it doesn't exist (for test compatibility) - if not M.state.mention_queue then - M.state.mention_queue = {} +---Send the oldest queued mention to Claude, then schedule the next one. +local function flush_mention_queue() + local queue = mention_queue() + if #queue == 0 or not M.is_claude_connected() then + -- Claude went away since this flush was scheduled: the mentions wait for its next handshake. + schedule_mention_flush(debounce_delay) return end - if #M.state.mention_queue == 0 then + local mention = queue[1] + local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line } + if not M.state.server.broadcast("at_mentioned", params) then + -- No connected client took the frame: the mention waits for the next handshake, or times out with an error. + logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path) + start_connection_timeout_if_needed() return end + table.remove(queue, 1) + logger.debug("queue", "Sent queued @ mention: " .. mention.file_path) + schedule_mention_flush(inter_message_delay) +end - if not M.is_claude_connected() then - -- Still disconnected or handshake not complete yet, wait for readiness - logger.debug("queue", "Claude not ready (no handshake). Keeping ", #M.state.mention_queue, " mentions queued") - - -- If triggered by a new connection, poll until handshake completes (bounded by connection_timeout timer) - if from_new_connection then - local retry_delay = math.max(50, math.floor((M.state.config.connection_wait_delay or 200) / 4)) - vim.defer_fn(function() - M.process_mention_queue(true) - end, retry_delay) +---Keep the queue moving. While Claude is connected, a flush is due: in delay ms, unless one is due already. Otherwise +---the connection timeout runs. An empty queue stops both timers. +---@param delay number Milliseconds to the flush when Claude is connected and no flush is due yet +schedule_mention_flush = function(delay) + if #mention_queue() == 0 then + stop_timer("mention_timer") + stop_timer("connection_timer") + elseif M.is_claude_connected() then + stop_timer("connection_timer") + if not M.state.mention_timer then + start_timer("mention_timer", delay, flush_mention_queue) end - return + else + start_connection_timeout_if_needed() end +end - local mentions_to_send = vim.deepcopy(M.state.mention_queue) - M.state.mention_queue = {} -- Clear queue - - -- Stop any existing timer - if M.state.mention_timer then - M.state.mention_timer:stop() - M.state.mention_timer:close() - M.state.mention_timer = nil - end +---Add @ mention to queue +---@param file_path string The file path to mention, formatted as Claude receives it +---@param start_line number|nil Optional start line +---@param end_line number|nil Optional end line +local function queue_mention(file_path, start_line, end_line) + local queue = mention_queue() + table.insert(queue, { file_path = file_path, start_line = start_line, end_line = end_line }) + logger.debug("queue", "Queued @ mention: " .. file_path .. " (queue size: " .. #queue .. ")") + schedule_mention_flush(debounce_delay) +end - -- Stop connection timer since we're now connected - if M.state.connection_timer then - M.state.connection_timer:stop() - M.state.connection_timer:close() - M.state.connection_timer = nil +---Process the mention queue. Claude completed its WebSocket handshake when from_new_connection is true (the server's +---on_open callback): the queue then flushes connection_wait_delay ms later, so that Claude is ready for the mentions. +---@param from_new_connection boolean|nil true after a handshake; otherwise the oldest mention goes out now +function M.process_mention_queue(from_new_connection) + if from_new_connection then + stop_timer("mention_timer") + schedule_mention_flush(M.state.config.connection_wait_delay) + else + flush_mention_queue() end +end - logger.debug("queue", "Processing " .. #mentions_to_send .. " queued @ mentions") - - -- Send mentions with a small delay between each to prevent WebSocket/extension overwhelm - local function send_mention_sequential(index) - if index > #mentions_to_send then - logger.debug("queue", "All queued mentions sent successfully") - return - end - - local mention = mentions_to_send[index] - - -- Check if mention has expired (same timeout logic as old system) - local current_time = vim.loop.now() - if (current_time - mention.timestamp) > M.state.config.queue_timeout then - logger.debug("queue", "Skipped expired @ mention: " .. mention.file_path) - else - -- Directly broadcast without going through the queue system to avoid infinite recursion - local params = { - filePath = mention.file_path, - lineStart = mention.start_line, - lineEnd = mention.end_line, - } - - local broadcast_success = M.state.server.broadcast("at_mentioned", params) - if broadcast_success then - logger.debug("queue", "Sent queued @ mention: " .. mention.file_path) - else - logger.error("queue", "Failed to send queued @ mention: " .. mention.file_path) - end - end - - -- Process next mention with delay - if index < #mentions_to_send then - local inter_message_delay = 25 -- ms - vim.defer_fn(function() - send_mention_sequential(index + 1) - end, inter_message_delay) - end +---Format a mention the way Claude receives it: a cwd-relative path, and no line numbers for a directory. +---@param file_path string +---@param start_line number|nil +---@param end_line number|nil +---@return table|nil mention `file_path`, `start_line`, `end_line` and `is_directory`, or nil for a bad path +---@return string|nil error Why the path is bad +local function format_mention(file_path, start_line, end_line) + local format_success, formatted_path, is_directory = pcall(M._format_path_for_at_mention, file_path) + if not format_success then + return nil, formatted_path -- the error message end - -- Apply delay for new connections, send immediately for debounced processing - if #mentions_to_send > 0 then - if from_new_connection then - -- Wait for connection_wait_delay when processing queue after new connection - local initial_delay = (M.state.config and M.state.config.connection_wait_delay) or 200 - logger.debug("queue", "Waiting ", initial_delay, "ms after connect before flushing queue") - vim.defer_fn(function() - send_mention_sequential(1) - end, initial_delay) - else - -- Send immediately for debounced processing (Claude already connected) - send_mention_sequential(1) - end + if is_directory and (start_line or end_line) then + logger.debug("command", "Line numbers ignored for directory: " .. formatted_path) + start_line = nil + end_line = nil end + return { file_path = formatted_path, start_line = start_line, end_line = end_line, is_directory = is_directory } end ---Show terminal if Claude is connected and it's not already visible @@ -355,8 +331,12 @@ function M.send_at_mention(file_path, start_line, end_line, context) end return success, error_msg else - -- Claude not connected, queue the mention and launch terminal - queue_mention(file_path, start_line, end_line) + -- Claude not connected: queue the mention, with the path a connected send gives Claude, and launch the terminal + local mention, format_error = format_mention(file_path, start_line, end_line) + if not mention then + return false, format_error + end + queue_mention(mention.file_path, mention.start_line, mention.end_line) -- Launch terminal with Claude Code local terminal = require("claudecode.terminal") @@ -608,7 +588,7 @@ function M.stop() M.state.port = nil M.state.auth_token = nil - -- Clear any queued @ mentions when server stops + -- Clear the queued @ mentions and stop the queue's timers: nothing queued before the stop reaches Claude clear_mention_queue() logger.info("init", "Claude Code integration stopped") @@ -1311,18 +1291,12 @@ function M._broadcast_at_mention(file_path, start_line, end_line) end -- Safely format the path and handle validation errors - local formatted_path, is_directory - local format_success, format_result, is_dir_result = pcall(M._format_path_for_at_mention, file_path) - if not format_success then - return false, format_result -- format_result contains the error message - end - formatted_path, is_directory = format_result, is_dir_result - - if is_directory and (start_line or end_line) then - logger.debug("command", "Line numbers ignored for directory: " .. formatted_path) - start_line = nil - end_line = nil + local mention, format_error = format_mention(file_path, start_line, end_line) + if not mention then + return false, format_error end + local formatted_path, is_directory = mention.file_path, mention.is_directory + start_line, end_line = mention.start_line, mention.end_line local params = { filePath = formatted_path, @@ -1339,11 +1313,11 @@ function M._broadcast_at_mention(file_path, start_line, end_line) end_line = end_line, } - -- For tests or when explicitly configured, broadcast immediately without queuing - if - (M.state.config and M.state.config.disable_broadcast_debouncing) + -- For tests or when explicitly configured, broadcast immediately without queuing, unless older mentions still wait + -- in the queue: this one must not overtake them. + local immediate = (M.state.config and M.state.config.disable_broadcast_debouncing) or (package.loaded["busted"] and not (M.state.config and M.state.config.enable_broadcast_debouncing_in_tests)) - then + if immediate and #mention_queue() == 0 then local broadcast_success = M.state.server.broadcast("at_mentioned", params) if broadcast_success then return true, nil, sent @@ -1357,8 +1331,8 @@ function M._broadcast_at_mention(file_path, start_line, end_line) -- Use mention queue system for debounced broadcasting queue_mention(formatted_path, start_line, end_line) - -- Always return success since we're queuing the message - -- The actual broadcast result will be logged in the queue processing + -- Always return success since we're queuing the message. The queue sends it to a connected Claude, or shows an + -- error when Claude does not connect within connection_timeout. return true, nil, sent end diff --git a/lua/claudecode/server/client.lua b/lua/claudecode/server/client.lua index a20beb995..0fbd725f1 100644 --- a/lua/claudecode/server/client.lua +++ b/lua/claudecode/server/client.lua @@ -100,7 +100,8 @@ end ---@param on_close function Callback for client close: function(client, code, reason) ---@param on_error function Callback for errors: function(client, error_msg) ---@param auth_token string|nil Expected authentication token for validation -function M.process_data(client, data, on_message, on_close, on_error, auth_token) +---@param on_open function|nil Callback once the WebSocket handshake completed: function(client) +function M.process_data(client, data, on_message, on_close, on_error, auth_token, on_open) -- Nothing more is parsed from a client that is closing, so none of its bytes are kept either. if client.state == "closing" or client.state == "closed" then client.buffer, client.pending = "", nil @@ -178,9 +179,12 @@ function M.process_data(client, data, on_message, on_close, on_error, auth_token client.handshake_complete = true client.state = "connected" logger.debug("client", "WebSocket connection established for client:", client.id) + if on_open then + on_open(client) + end if #client.buffer > 0 then - M.process_data(client, "", on_message, on_close, on_error, auth_token) + M.process_data(client, "", on_message, on_close, on_error, auth_token, on_open) end end) end diff --git a/lua/claudecode/server/init.lua b/lua/claudecode/server/init.lua index d4f3b39ce..4e64a3d21 100644 --- a/lua/claudecode/server/init.lua +++ b/lua/claudecode/server/init.lua @@ -57,8 +57,9 @@ function M.start(config, auth_token) else logger.debug("server", "WebSocket client connected (no auth):", client.id) end - - -- Notify main module about new connection for queue processing + end, + on_open = function(client) + -- The client completed its WebSocket handshake, so it can take the queued @ mentions now. local main_module = require("claudecode") if main_module.process_mention_queue then vim.schedule(function() @@ -429,7 +430,7 @@ end ---Broadcast a message to all connected clients ---@param method string The method name ---@param params table|nil The parameters to send ----@return boolean success Whether broadcast was successful +---@return boolean success Whether a connected client took the message function M.broadcast(method, params) if not M.state.server then return false @@ -442,8 +443,7 @@ function M.broadcast(method, params) } local json_message = vim.json.encode(message) - tcp_server.broadcast(M.state.server, json_message) - return true + return tcp_server.broadcast(M.state.server, json_message) > 0 end ---Get server status information diff --git a/lua/claudecode/server/tcp.lua b/lua/claudecode/server/tcp.lua index 5ebbe5a31..903b168fb 100644 --- a/lua/claudecode/server/tcp.lua +++ b/lua/claudecode/server/tcp.lua @@ -10,6 +10,7 @@ local M = {} ---@field clients table Table of connected clients ---@field on_message function Callback for WebSocket messages ---@field on_connect function Callback for new connections +---@field on_open function Callback for a client whose WebSocket handshake completed ---@field on_disconnect function Callback for client disconnections ---@field on_error fun(err_msg: string) Callback for the server's own errors, not a client's @@ -148,6 +149,7 @@ function M.create_server(config, callbacks, auth_token) clients = {}, on_message = callbacks.on_message or function() end, on_connect = callbacks.on_connect or function() end, + on_open = callbacks.on_open or function() end, on_disconnect = callbacks.on_disconnect or function() end, on_error = callbacks.on_error or function() end, } @@ -220,10 +222,10 @@ function M._handle_new_connection(server) M._disconnect_client(server, cl, code, reason) end, function(cl, error_msg) M._disconnect_client(server, cl, 1006, "Client error: " .. error_msg) - end, server.auth_token) + end, server.auth_token, server.on_open) end) - -- Notify about new connection + -- Notify about the TCP connection. on_open follows once its WebSocket handshake completes. server.on_connect(client) end @@ -295,10 +297,16 @@ end ---Broadcast a message to all connected clients ---@param server TCPServer The server object ---@param message string The message to broadcast +---@return integer sent How many clients took the message: those whose state is "connected" function M.broadcast(server, message) + local sent = 0 for _, client in pairs(server.clients) do - client_manager.send_message(client, message) + if client.state == "connected" then + client_manager.send_message(client, message) + sent = sent + 1 + end end + return sent end ---Get the number of connected clients diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 26e0a6a09..954131dde 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -56,10 +56,9 @@ -- @ mention queued for Claude Code ---@class ClaudeCodeMention ----@field file_path string The absolute file path to mention +---@field file_path string The path Claude receives: cwd-relative when the file is under the cwd ---@field start_line number? Optional start line (0-indexed for Claude compatibility) ---@field end_line number? Optional end line (0-indexed for Claude compatibility) ----@field timestamp number Creation timestamp from vim.loop.now() for expiry tracking -- Payload delivered as `data` to a `User ClaudeCodeSendComplete` autocmd. Fired -- once per file, synchronously, when a send is accepted while Claude is connected. @@ -127,7 +126,7 @@ ---@field visual_demotion_delay_ms number ---@field connection_wait_delay number ---@field connection_timeout number ----@field queue_timeout number +---@field queue_timeout number Unused: accepted so that configs that set it stay valid ---@field diff_opts ClaudeCodeDiffOptions ---@field models ClaudeCodeModelOption[] ---@field disable_broadcast_debouncing? boolean @@ -151,8 +150,8 @@ ---@field auth_token string|nil ---@field initialized boolean ---@field mention_queue ClaudeCodeMention[] ----@field mention_timer uv.uv_timer_t? -- (compatible with vim.loop timer) ----@field connection_timer uv.uv_timer_t? -- (compatible with vim.loop timer) +---@field mention_timer uv.uv_timer_t? -- the next flush of the mention queue (compatible with vim.loop timer) +---@field connection_timer uv.uv_timer_t? -- the timeout of the mentions that wait for Claude to connect -- This module only defines types, no runtime functionality return {} diff --git a/tests/unit/server_spec.lua b/tests/unit/server_spec.lua index 038d0b7fe..f8997ccd5 100644 --- a/tests/unit/server_spec.lua +++ b/tests/unit/server_spec.lua @@ -171,7 +171,7 @@ describe("WebSocket Server", function() server.stop() end) - it("should broadcast to all clients", function() + it("should broadcast to all clients, and report whether a connected client took it", function() -- Start server first local config = { port_range = { min = 10000, max = 65535 } } server.start(config) @@ -179,12 +179,38 @@ describe("WebSocket Server", function() local method = "test_method" local params = { foo = "bar" } - local success = server.broadcast(method, params) - - expect(success).to_be_true() + -- The @ mention queue keeps a mention that no client took, so a broadcast to nobody must not report success. + local ok, success_without_client = pcall(server.broadcast, method, params) + local written = {} + local function add_client(id, state) + server.state.server.clients[id] = { + id = id, + state = state, + tcp_handle = { + write = function(_, data) + table.insert(written, id) + end, + is_closing = function() + return false + end, + close = function() end, + }, + } + end + add_client("closing_client", "closing") + local ok_closing, success_with_closing_client = pcall(server.broadcast, method, params) + add_client("connected_client", "connected") + local ok_connected, success_with_connected_client = pcall(server.broadcast, method, params) - -- Clean up + -- Clean up first, so that a failed assertion does not leave the server running for the next test. + server.state.server.clients = {} server.stop() + + assert.is_true(ok and ok_closing and ok_connected) + expect(success_without_client).to_be_false() + expect(success_with_closing_client).to_be_false() + expect(success_with_connected_client).to_be_true() + assert.same({ "connected_client" }, written) end) describe("invalid JSON-RPC messages", function() From 986f31b793bf2968a162a17f386458188d661e25 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:02:56 +0000 Subject: [PATCH 03/35] test(mention): cover a stop that races a pending timeout, the stop reset, and the connect wait --- tests/unit/mention_queue_spec.lua | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 5ebdcc174..1270cad17 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -374,7 +374,9 @@ describe("The @ mention queue", function() claude.handshake() run(100) send("/project/second.lua") - run(1500) + run(400) + assert.same({}, claude.received(), "nothing goes out before connection_wait_delay ends") + run(1100) assert.same({ "first.lua", "second.lua" }, names(claude.received())) assert.same({}, errors) @@ -421,6 +423,37 @@ describe("The @ mention queue", function() assert.same({}, second.received()) end) + it("C: a stop cancels a connection timeout whose callback already waits for the main loop", function() + send("/project/old.lua") + -- In the timer phase in which the timeout fires, before its callback runs: stop, start, and send again. + at(10000, function() + claudecode.stop() + assert(claudecode.start(false)) + send("/project/new.lua") + end) + run(10000) + assert.same({}, errors, "the old timeout must not clear the mention sent after the stop") + + run(10100) + assert.equals(1, #errors) + assert.matches("clearing 1 queued", errors[1]) + assert_no_lua_error() + end) + + it("B: :ClaudeCodeStop resets the connection timeout", function() + send("/project/old.lua") + run(5000) + claudecode.stop() + assert(claudecode.start(false)) + send("/project/new.lua") + run(9000) + assert.same({}, errors, "a mention sent after a restart gets the whole connection_timeout") + + run(1100) + assert.equals(1, #errors) + assert.matches("Connection timeout", errors[1]) + end) + it("B: after a connection timeout, a later mention times out with an error too", function() send("/project/first.lua") run(10100) From fe786981991412c8469fb6b12b96d27f5f1a02ba Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:04:45 +0000 Subject: [PATCH 04/35] fix(mention): trust only the broadcast result in a flush, and cover the 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. --- lua/claudecode/init.lua | 10 ++++++---- tests/unit/mention_queue_spec.lua | 32 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 0827b9365..c78ca1788 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -161,16 +161,18 @@ end ---Send the oldest queued mention to Claude, then schedule the next one. local function flush_mention_queue() local queue = mention_queue() - if #queue == 0 or not M.is_claude_connected() then - -- Claude went away since this flush was scheduled: the mentions wait for its next handshake. + if #queue == 0 then schedule_mention_flush(debounce_delay) return end + -- M.stop clears the queue when it drops the server, and the stop cancels this flush too. + local server = assert(M.state.server, "the @ mention queue holds mentions while the integration is stopped") local mention = queue[1] local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line } - if not M.state.server.broadcast("at_mentioned", params) then - -- No connected client took the frame: the mention waits for the next handshake, or times out with an error. + if not server.broadcast("at_mentioned", params) then + -- No connected client took the frame, as Claude went away since this flush was scheduled. The mention waits for + -- Claude's next handshake, or times out with an error. logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path) start_connection_timeout_if_needed() return diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 1270cad17..9ce628222 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -368,6 +368,38 @@ describe("The @ mention queue", function() assert.same({}, claudecode.state.mention_queue) end) + it("H: after a drop in the wait, the mention gets a whole connection_timeout for Claude to come back", function() + send("/project/a.lua") + run(9900) + local first = claude_connects() + first.handshake() + run(50) + first.drop() + run(2000) + assert.same({}, errors, "the timeout that ran before the handshake must not fire after it") + + local second = claude_connects() + second.handshake() + run(1500) + + assert.same({ "a.lua" }, names(second.received())) + assert.same({}, errors) + end) + + it("control: a send in the tick after the handshake waits connection_wait_delay with the queued mentions", function() + send("/project/first.lua") + local claude = claude_connects() + claude.handshake() + -- The handshake's on_open callback still waits for the main loop: Claude counts as connected already. + send("/project/second.lua") + run(500) + assert.same({}, claude.received(), "nothing goes out before connection_wait_delay ends") + run(1000) + + assert.same({ "first.lua", "second.lua" }, names(claude.received())) + assert.same({}, errors) + end) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() From fc19b4bcc7f02c789d3199d166a313fccc8d4d6b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:07:29 +0000 Subject: [PATCH 05/35] docs(mention): describe the queue that delivers or reports, and re-record 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. --- .../features/send-at-mention.md | 8 ++++---- .agents/skills/verify-diff-lifecycle/covered.lock | 12 ++++++------ lua/claudecode/README.md | 8 +++++--- lua/claudecode/server/README.md | 4 ++-- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.agents/skills/verify-claudecode-nvim/features/send-at-mention.md b/.agents/skills/verify-claudecode-nvim/features/send-at-mention.md index a972e3a2d..ee4b78dc9 100644 --- a/.agents/skills/verify-claudecode-nvim/features/send-at-mention.md +++ b/.agents/skills/verify-claudecode-nvim/features/send-at-mention.md @@ -11,7 +11,7 @@ The user hands Claude a file, a directory, or a line range as context. `:ClaudeC - `send-add-errors` sends nothing and shows an error for a missing path or a start line after the end line. - `send-mapped` sends from a Visual or Select mode mapping to `ClaudeCodeSend`, as in the README, and leaves the user in Normal mode. - `send-tree` sends the file under the cursor in netrw on `:ClaudeCodeTreeAdd` or `:ClaudeCodeSend`. -- `send-queue` holds a mention made while no client is connected and sends it to the next client that connects within `queue_timeout` (default 5000 ms). +- `send-queue` holds a mention made while no client is connected and sends it to the next client that completes its WebSocket handshake within `connection_timeout` (default 10000 ms), `connection_wait_delay` (default 600 ms) after that handshake. Without such a client it shows "Connection timeout - clearing 1 queued @ mentions". ## How to get to it (user POV) @@ -41,12 +41,12 @@ Steps: - `missing.txt` and `other.txt 3 2` send nothing. `:messages` ends with `ClaudeCodeAdd: File or directory does not exist: missing.txt` and `ClaudeCodeAdd: Start line (3) must be <= end line (2)`. - **Send from a mapping.** Map `S` as the README does: `$V keys ':vnoremap S ClaudeCodeSend' Enter`. Run `$V keys G V`, `$V until 'mode()' V`, and `$V keys S`: the params are `{"filePath":"hello.txt","lineStart":2,"lineEnd":2}` and `$V expr 'mode()'` prints `n`. Select mode works the same: `$V keys gg gH`, `$V until 'mode()' S`, `$V keys S` sends line 0. - **Send from netrw.** Run `$V keys ':Explore' Enter`, `$V until '&filetype' netrw`, and `$V keys '/^other\.txt' Enter`. `$V keys ':ClaudeCodeTreeAdd' Enter` and then `$V keys ':ClaudeCodeSend' Enter` each send `{"filePath":"other.txt"}`. Return with `$V keys ':b hello.txt' Enter`. -- **Queue while disconnected.** Run `$V disconnect`, then `$V until 'luaeval("require(\"claudecode\").is_claude_connected() and 1 or 0")' 0`. Send `gg V j` with `:ClaudeCodeSend`. Run `VCN_CLIENT=b $V connect` within five seconds. Within three seconds `$OUT/ws-in-b.jsonl` holds one `at_mentioned` with `{"filePath":"/hello.txt","lineStart":0,"lineEnd":1}`. A queued mention keeps the absolute path. +- **Queue while disconnected.** Run `$V disconnect`, then `$V until 'luaeval("require(\"claudecode\").is_claude_connected() and 1 or 0")' 0`. Send `gg V j` with `:ClaudeCodeSend`. Run `VCN_CLIENT=b $V connect` within ten seconds. Within three seconds `$OUT/ws-in-b.jsonl` holds one `at_mentioned` with `{"filePath":"hello.txt","lineStart":0,"lineEnd":1}`. A queued mention gets the same cwd-relative path as a connected send. - **Send with a real terminal.** Launch with `VCN_SETUP='{"terminal_cmd":"sh","terminal":{"provider":"native"}}' $V launch -c 'xnoremap S ClaudeCodeSend' hello.txt`. For snacks, check out the commit the repo's fixtures pin, and check it before you load it: `P=$(jq -r '."snacks.nvim".commit' fixtures/snacks-picker/lazy-lock.json) && git clone -q --no-checkout https://github.com/folke/snacks.nvim && git -C checkout -q --detach "$P" && [ "$(git -C rev-parse HEAD)" = "$P" ]`. Never load an unpinned branch: setup runs its Lua as you. Then set `"provider":"snacks"` and add `--cmd 'set rtp^='` to the launch arguments. The repo has no snacks checkout, so without network this path is unreachable. Run `$V keys ':ClaudeCode' Enter` and `$V until '&buftype' terminal`, then hide it with `$V keys 'Ctrl+\' 'Ctrl+N'`, `$V until 'mode()' n`, and `$V keys ':ClaudeCode' Enter`. Send with `$V keys gg V j`, `$V until 'mode()' V`, and `$V keys S`. `$V expr "mode() . '|' . bufname()"` must print `n|hello.txt`, and a typed `$V keys x` must delete a character. Show the terminal again with `$V keys ':ClaudeCodeFocus' Enter` and `$V until 'mode()' t`, hide it as before, and repeat with `:ClaudeCodeSend` typed instead of `S`. A send to a terminal that is already visible does not test showing it. On main, snacks with the `S` mapping prints `i|hello.txt`, and `x` types into the buffer (#423). Native, and snacks with the typed command, print `n|hello.txt`. - **Proof.** Type the command with `$V keys ':ClaudeCodeSend'`, then run `$V capture send-visual` and `$V screenshot send-visual` with the selection and the typed command visible, before `$V keys Enter`. Keep the `at_mentioned` lines from `$OUT/ws-in.jsonl`, and end with `$V record send-at-mention`. ## Gotchas -- The `none` provider opens no terminal. Without a connected client the plugin queues the mention and tries to open its Claude terminal. The mention reaches only a client that connects within `queue_timeout`, so check the handshake line of `$V doctor` first. With a client, a send shows the hidden Claude terminal without focus (with focus when `focus_after_send = true`), and `none` skips that too, so a mode or focus change from showing it (#423) appears only in **Send with a real terminal**. -- Paths are relative to Neovim's cwd (`$WORK`). A file outside it arrives as an absolute path (`:ClaudeCodeAdd /etc/hostname` gave `{"filePath":"/etc/hostname"}`). A path typed as `./other.txt` arrives unchanged, and a queued visual send arrives absolute. +- The `none` provider opens no terminal. Without a connected client the plugin queues the mention and tries to open its Claude terminal. The mention reaches only a client that connects within `connection_timeout`, so check the handshake line of `$V doctor` first. With a client, a send shows the hidden Claude terminal without focus (with focus when `focus_after_send = true`), and `none` skips that too, so a mode or focus change from showing it (#423) appears only in **Send with a real terminal**. +- Paths are relative to Neovim's cwd (`$WORK`). A file outside it arrives as an absolute path (`:ClaudeCodeAdd /etc/hostname` gave `{"filePath":"/etc/hostname"}`). A path typed as `./other.txt` arrives unchanged. A queued send arrives with the same path as a connected one. - JSON key order varies between runs. Compare with `jq`, not with string equality. diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 1e0065e83..cb4c669d0 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -1,5 +1,5 @@ == lua/claudecode/config.lua -file lua/claudecode/config.lua dfb93bd69a6198ec +file lua/claudecode/config.lua 35f1bc960cb293e4 == lua/claudecode/cwd.lua file lua/claudecode/cwd.lua 55d26ea362173afa == lua/claudecode/diff.lua @@ -121,7 +121,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua bd2f75aefb5dd447 +file lua/claudecode/init.lua 1c213d633993b817 block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -135,14 +135,14 @@ file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua file lua/claudecode/selection.lua f9cd61cbc71b82bb == lua/claudecode/server/client.lua -file lua/claudecode/server/client.lua 9411e7b95e949d74 +file lua/claudecode/server/client.lua f2ac78c7e6804788 block create_client 647b7523dea26af6 == lua/claudecode/server/frame.lua file lua/claudecode/server/frame.lua 3c50766875878d56 == lua/claudecode/server/handshake.lua file lua/claudecode/server/handshake.lua 3eca35d572094a55 == lua/claudecode/server/init.lua -file lua/claudecode/server/init.lua f1dca3428031fc70 +file lua/claudecode/server/init.lua dcac9fddf1ad3be3 block server_stop 4dae0bf675cf2cc1 block on_disconnect 9110ffdfd72d4ea6 block handle_request b74ff7dc2e55a2b8 @@ -157,7 +157,7 @@ site lua/claudecode/server/init.lua: if not _G.claude_deferred_responses then @ == lua/claudecode/server/mock.lua file lua/claudecode/server/mock.lua 47986b9e1060acd8 == lua/claudecode/server/tcp.lua -file lua/claudecode/server/tcp.lua 460039a8ac40d1cb +file lua/claudecode/server/tcp.lua 2ec384dd58cfbbd7 == lua/claudecode/server/utils.lua file lua/claudecode/server/utils.lua 2915ae87b48f04ca == lua/claudecode/terminal.lua @@ -205,7 +205,7 @@ file lua/claudecode/tools/open_file.lua 46f73ca7efb9f393 == lua/claudecode/tools/save_document.lua file lua/claudecode/tools/save_document.lua 5fd303da9fac9d23 == lua/claudecode/types.lua -file lua/claudecode/types.lua 08aa28a9c192d17a +file lua/claudecode/types.lua b4e8e9d00d7fe4b9 == lua/claudecode/utils.lua file lua/claudecode/utils.lua fec39728f0301703 == lua/claudecode/visual_commands.lua diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index dffa26f68..e1826eb5c 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -43,9 +43,9 @@ Module map (direct files): - `M.setup(opts) -> M`: applies config, sets up the logger, terminal, and diff modules, and creates commands and the shutdown autocmd. It starts the server when `auto_start` is set [init.lua:M.setup] - `M.start(show_startup_notification?) -> ok, port|err` and `M.stop() -> ok, err?` [init.lua:M.start] - `M.state`: `config`, `server`, `port`, `auth_token`, `initialized`, `mention_queue`, `mention_timer`, and `connection_timer` [init.lua:M.state] -- `M.is_claude_connected()`: false when the server is not running. When `get_status().clients` is non-empty, true only when a client has `handshake_complete`. When it is empty, true when `client_count > 0`, with no handshake check [init.lua:M.is_claude_connected] +- `M.is_claude_connected()`: false when the server is not running. When `get_status().clients` is non-empty, true only when a client is in state `connected` with `handshake_complete`, so a `closing` client does not count. When it is empty, true when `client_count > 0`, with no handshake check [init.lua:M.is_claude_connected] - `M.send_at_mention(file_path, start_line?, end_line?, context?) -> ok, err?`: line numbers are 0-indexed [init.lua:M.send_at_mention] -- `M.process_mention_queue(from_new_connection?)`: the server's `on_connect` callback calls it [init.lua:M.process_mention_queue] +- `M.process_mention_queue(from_new_connection?)`: the server's `on_open` callback calls it with `true` once a client completed its WebSocket handshake. Without the argument it sends the oldest queued mention now [init.lua:M.process_mention_queue] - `M.open_with_model(args)`, `M.get_version()`, and `M.version`, read from the repository's `VERSION` file at load [init.lua:M.version] - Internal API: `M._create_commands`, `M._broadcast_at_mention`, `M._add_paths_to_claude` (default `max_files` 100, `batch_size` 10), `M._format_path_for_at_mention`, `M._ensure_terminal_visible_if_connected`, and `M._maybe_warn_unfocusable_provider` [init.lua:M._add_paths_to_claude] @@ -110,7 +110,9 @@ Module map (direct files): ### @-mentions and selection - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] -- A mention sent while disconnected goes to the queue, and the terminal opens. The queue drops mentions older than `queue_timeout` and is cleared after `connection_timeout` [init.lua:M.process_mention_queue] +- A mention sent while disconnected goes to the queue with the same cwd-relative path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] +- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] +- `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] - Select mode (`gh`, or `v`, `V` and CTRL-V when `'selectmode'` contains `cmd`) is tracked like Visual mode [selection.lua:is_visual_mode], and a command such as a `ClaudeCodeSend` mapping takes the visual path there [visual_commands.lua:M.create_visual_command_wrapper]. The visual path ends Select mode. For a mapping made with `mode = "v"` (`:vmap`), Neovim queues a key that restores Select mode after the mapping, and the visual path cancels it with `gV` [visual_commands.lua:M.exit_visual_and_schedule] diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 9a13083b6..55a7be6d2 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -26,7 +26,7 @@ Layers, top to bottom: - `tools/call` delegates to `tools.handle_invoke` and returns a `_deferred` table for blocking tools [server/init.lua:M.register_handlers] - `M.send(client, method, params) -> boolean`: sends a JSON-RPC notification to one client [server/init.lua:M.send] - `M.send_response(client, id, result, error_data) -> boolean`: a `nil` id is sent as JSON `null` (`vim.NIL`) [server/init.lua:M.send_response] -- `M.broadcast(method, params) -> boolean`: sends a notification to all clients [server/init.lua:M.broadcast] +- `M.broadcast(method, params) -> boolean`: sends a notification to every `connected` client, and returns true only when at least one took it [server/init.lua:M.broadcast] - `M.get_status() -> { running, port, client_count, clients }`. `clients` is present only while the server runs [server/init.lua:M.get_status] - Internal dispatch: `M._handle_message`, `M._handle_request`, `M._handle_notification`, and `M._setup_deferred_response` [server/init.lua:M._handle_message] @@ -90,7 +90,7 @@ Layers, top to bottom: ## Side Effects - `M.start` changes the global tool registry (`tools.setup`), opens a listening TCP handle, and starts a `vim.loop` ping timer [server/init.lua:M.start] -- On connect, the server schedules `claudecode.process_mention_queue(true)` [server/init.lua:M.start] +- When a client completed its WebSocket handshake (`on_open`, called from the 101 write callback in `client.lua`), the server schedules `claudecode.process_mention_queue(true)`. `on_connect` runs at the TCP accept, before the handshake, and only logs [server/init.lua:M.start] [server/client.lua:M.process_data] - On disconnect, the server schedules `diff.close_diffs_for_client(client.id, "client disconnected")`, only when `package.loaded["claudecode.diff"]` is set [server/init.lua:M.start] - `M.stop` calls `diff.close_pending_diffs("server stopping")` when `package.loaded["claudecode.diff"]` is set, resets `_G.claude_deferred_responses`, and clears `M.state.auth_token` [server/init.lua:M.stop] - `M.start` logs only the auth token length, not the token [server/init.lua:M.start] From 16dce7bcadc2f962662e07032e6acbf06889d4d9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:25:40 +0000 Subject: [PATCH 06/35] fix(mention): relativize a path only when it is inside the cwd, and match 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. --- lua/claudecode/init.lua | 43 +++++++++++++++++++------------ lua/claudecode/server/mock.lua | 9 ++++--- tests/unit/at_mention_spec.lua | 15 +++++++++++ tests/unit/mention_queue_spec.lua | 9 +++++++ 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index c78ca1788..ea8b8c175 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1239,6 +1239,26 @@ function M.get_version() } end +---The part of path below dir, or nil when path is not dir or inside it. A sibling such as /project2 is not inside +---/project, and every absolute path is inside /. +---@param path string +---@param dir string +---@return string|nil relative "" for dir itself +local function relative_to(path, dir) + if dir == "" or path:sub(1, #dir) ~= dir then + return nil + end + local rest = path:sub(#dir + 1) + if rest == "" or dir:match("[/\\]$") then + return rest + end + local separator = rest:sub(1, 1) + if separator == "/" or separator == "\\" then + return rest:sub(2) + end + return nil +end + ---Format file path for at mention (exposed for testing) ---@param file_path string The file path to format ---@return string formatted_path The formatted path @@ -1259,28 +1279,19 @@ function M._format_path_for_at_mention(file_path) local is_directory = vim.fn.isdirectory(file_path) == 1 local formatted_path = file_path + local relative_path = relative_to(file_path, vim.fn.getcwd()) if is_directory then - local cwd = vim.fn.getcwd() - if string.find(file_path, cwd, 1, true) == 1 then - local relative_path = string.sub(file_path, #cwd + 2) - if relative_path ~= "" then - formatted_path = relative_path - else - formatted_path = "./" - end + if relative_path == "" then + formatted_path = "./" + elseif relative_path then + formatted_path = relative_path end if not string.match(formatted_path, "/$") then formatted_path = formatted_path .. "/" end - else - local cwd = vim.fn.getcwd() - if string.find(file_path, cwd, 1, true) == 1 then - local relative_path = string.sub(file_path, #cwd + 2) - if relative_path ~= "" then - formatted_path = relative_path - end - end + elseif relative_path and relative_path ~= "" then + formatted_path = relative_path end return formatted_path, is_directory diff --git a/lua/claudecode/server/mock.lua b/lua/claudecode/server/mock.lua index e3cdfe02d..9b0881ef8 100644 --- a/lua/claudecode/server/mock.lua +++ b/lua/claudecode/server/mock.lua @@ -217,17 +217,18 @@ end ---Broadcast a message to all connected clients ---@param method string The method name ---@param params table The parameters to send ----@return boolean success Whether broadcasting was successful +---@return boolean success Whether a client took the message, as server/init.lua broadcast reports it function M.broadcast(method, params) if not M.state.server or type(M.state.server.clients) ~= "table" then return false end - local success = true + local success = false for client_id, _ in pairs(M.state.server.clients) do - local send_success = M.send(client_id, method, params) - success = success and send_success + if M.send(client_id, method, params) then + success = true + end end return success diff --git a/tests/unit/at_mention_spec.lua b/tests/unit/at_mention_spec.lua index 18a5fefab..994ea7fdf 100644 --- a/tests/unit/at_mention_spec.lua +++ b/tests/unit/at_mention_spec.lua @@ -144,6 +144,21 @@ describe("At Mention Functionality", function() expect(formatted_path).to_be("./") end) + + it("keeps the absolute path of a file in a sibling directory that shares the cwd's prefix", function() + local formatted_path = init_module._format_path_for_at_mention("/Users/test/project2/a.lua") + + expect(formatted_path).to_be("/Users/test/project2/a.lua") + end) + + it("makes a path relative to a cwd of /", function() + mock_vim.fn.getcwd = function() + return "/" + end + local formatted_path = init_module._format_path_for_at_mention("/tmp/a.lua") + + expect(formatted_path).to_be("tmp/a.lua") + end) end) describe("file at mention from nvim-tree", function() diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 9ce628222..691bcd705 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -577,6 +577,15 @@ describe("The @ mention queue", function() assert.same({}, errors) end) + it("G, control: a queued file outside the cwd keeps its absolute path, though it shares the cwd's prefix", function() + send("/project2/a.lua") + local claude = claude_connects() + claude.handshake() + run(1500) + + assert.same({ "/project2/a.lua" }, claude.received()) + end) + it("G: a queued mention reaches Claude with the same cwd-relative path as a connected send", function() send("/project/src/queued.lua") local claude = claude_connects() From 68233f8f5c1a94fe8e3d3e52b5d445f44d2c20d2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:27:09 +0000 Subject: [PATCH 07/35] docs(mention): state when a mention path becomes relative, and re-record 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. --- .agents/skills/verify-diff-lifecycle/covered.lock | 4 ++-- lua/claudecode/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index cb4c669d0..13243a494 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -121,7 +121,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua 1c213d633993b817 +file lua/claudecode/init.lua cd570b67f5c23a17 block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -155,7 +155,7 @@ site lua/claudecode/server/init.lua: diff.close_pending_diffs("server stopping") site lua/claudecode/server/init.lua: if _G.claude_deferred_responses then @ function M.stop() site lua/claudecode/server/init.lua: if not _G.claude_deferred_responses then @ function M._setup_deferred_response(deferred_info) == lua/claudecode/server/mock.lua -file lua/claudecode/server/mock.lua 47986b9e1060acd8 +file lua/claudecode/server/mock.lua b2726250730d062f == lua/claudecode/server/tcp.lua file lua/claudecode/server/tcp.lua 2ec384dd58cfbbd7 == lua/claudecode/server/utils.lua diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index e1826eb5c..3e1bf9c8b 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -110,7 +110,7 @@ Module map (direct files): ### @-mentions and selection - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] -- A mention sent while disconnected goes to the queue with the same cwd-relative path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] +- A mention sent while disconnected goes to the queue with the same cwd-relative path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention]. A path becomes relative only when it is the cwd or below it, so a file in a sibling such as `/project2` keeps its absolute path when the cwd is `/project` [init.lua:relative_to] - The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms. Claude terminal buffers are skipped [selection.lua:M.update_selection] From f7b139fb5a14c8e4a90547ea645ed44307a95d14 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 12:00:17 +0000 Subject: [PATCH 08/35] test(mention): pin the bad-path refusal and the timeout anchor, and add 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. --- dev-config.lua | 4 +- tests/unit/mention_queue_spec.lua | 256 +++++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 4 deletions(-) diff --git a/dev-config.lua b/dev-config.lua index da953f875..bafc30e7d 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -76,8 +76,8 @@ return { -- Connection Management -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) - -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) - -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) + -- connection_timeout = 10000, -- Max time queued @ mentions wait for Claude Code to connect (ms) + -- queue_timeout = 5000, -- Unused, still accepted: queued @ mentions wait up to connection_timeout -- Diff Integration -- diff_opts = { diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 691bcd705..85e339b9b 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -25,7 +25,7 @@ describe("The @ mention queue", function() "claudecode.server.handshake", } local saved = {} - local loop, errors, claudecode, frame + local loop, errors, claudecode, frame, terminal_opens -- A fake libuv loop. Like libuv, it fires every timer that is due in one phase, in the order they were started, -- and then the main loop runs every scheduled callback before time moves on. A callback that raises is recorded, @@ -172,9 +172,12 @@ describe("The @ mention queue", function() return false end, } + terminal_opens = 0 package.loaded["claudecode.terminal"] = { setup = function() end, - open = function() end, + open = function() + terminal_opens = terminal_opens + 1 + end, ensure_visible = function() end, } package.loaded["claudecode.lockfile"] = { @@ -486,6 +489,19 @@ describe("The @ mention queue", function() assert.matches("Connection timeout", errors[1]) end) + it("B, control: the connection timeout runs from the first queued mention, not from the latest", function() + send("/project/first.lua") + run(8000) + send("/project/second.lua") + run(1900) + assert.same({}, errors) + + run(200) + assert.equals(1, #errors) + assert.matches("clearing 2 queued", errors[1]) + assert.same({}, claudecode.state.mention_queue) + end) + it("B: after a connection timeout, a later mention times out with an error too", function() send("/project/first.lua") run(10100) @@ -586,6 +602,17 @@ describe("The @ mention queue", function() assert.same({ "/project2/a.lua" }, claude.received()) end) + it("G: a bad path sent while Claude is not connected returns an error and queues nothing", function() + local ok, err = claudecode.send_at_mention("", nil, nil, "test") + + assert.is_false(ok) + assert.matches("non%-empty string", err) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.mention_timer) + assert.is_nil(claudecode.state.connection_timer) + assert.equals(0, terminal_opens) + end) + it("G: a queued mention reaches Claude with the same cwd-relative path as a connected send", function() send("/project/src/queued.lua") local claude = claude_connects() @@ -596,4 +623,229 @@ describe("The @ mention queue", function() assert.same({ "src/queued.lua", "src/connected.lua" }, claude.received()) end) + + -- Random sequences of sends, connects, upgrades, handshakes, drops, CLOSE frames, stops, starts and waits, each + -- checked against the promise: no client gets a mention twice or out of order or from before a stop, no Lua error, + -- nothing left queued or a timer left running after a long wait, and every mention of a run without a stop either + -- reached a client or was counted in a timeout error. The seeds are fixed, so a failure names the seed and the steps + -- that reproduce it. FUZZ_SEEDS, FUZZ_STEPS and FUZZ_BASE run other sequences. + local SEEDS = tonumber(os.getenv("FUZZ_SEEDS") or "300") + local STEPS = tonumber(os.getenv("FUZZ_STEPS") or "30") + local BASE_SEED = tonumber(os.getenv("FUZZ_BASE") or "1") + + local function id_of(path) + return tonumber(path:match("f(%d+)%.lua$")) + end + + local function play(seed) + local rng = { state = seed * 2654435761 % 4294967296 } + local function rand(n) + rng.state = (rng.state * 1103515245 + 12345) % 2147483648 + return (math.floor(rng.state / 65536) % n) + 1 + end + local trace = {} + local function note(fmt, ...) + table.insert(trace, string.format(fmt, ...)) + end + + local next_id = 0 + local accepted = {} -- id -> epoch + local epoch = 0 + local clients = {} -- list of { claude=, phase="tcp"|"upgrading"|"open"|"dropped", epoch= } + local violations = {} + local function violate(msg) + table.insert(violations, msg) + end + local function open_clients() + local out = {} + for _, c in ipairs(clients) do + if c.phase ~= "dropped" and c.epoch == epoch then + table.insert(out, c) + end + end + return out + end + + local function check_received() + for ci, c in ipairs(clients) do + local ids = {} + local seen = {} + for _, path in ipairs(c.claude.received()) do + local id = id_of(path) + if seen[id] then + violate(("client %d received f%d twice"):format(ci, id)) + end + seen[id] = true + if #ids > 0 and id < ids[#ids] then + violate(("client %d received f%d after f%d: out of order"):format(ci, id, ids[#ids])) + end + table.insert(ids, id) + if accepted[id] ~= nil and accepted[id] < c.epoch then + violate(("client %d (epoch %d) received f%d sent in epoch %d"):format(ci, c.epoch, id, accepted[id])) + end + end + end + end + + local ok_all, err_all = pcall(function() + for _ = 1, STEPS do + local r = rand(100) + if r <= 28 then + if claudecode.state.server then + next_id = next_id + 1 + local path = ("/project/f%d.lua"):format(next_id) + local ok, e = claudecode.send_at_mention(path, nil, nil, "fuzz") + if ok then + accepted[next_id] = epoch + else + violate("send refused while running: " .. tostring(e)) + end + note("send f%d", next_id) + else + note("send skipped (stopped)") + end + elseif r <= 38 then + if claudecode.state.server then + local c = { claude = claude_connects(), phase = "tcp", epoch = epoch } + table.insert(clients, c) + note("connect #%d", #clients) + end + elseif r <= 48 then + for _, c in ipairs(open_clients()) do + if c.phase == "tcp" then + c.claude.upgrade() + c.phase = "upgrading" + note("upgrade") + break + end + end + elseif r <= 60 then + for _, c in ipairs(open_clients()) do + if c.phase == "upgrading" then + c.claude.handshake_written() + c.phase = "open" + note("handshake_written") + break + end + end + elseif r <= 68 then + local oc = open_clients() + if #oc > 0 then + local c = oc[rand(#oc)] + c.claude.drop() + c.phase = "dropped" + note("drop") + end + elseif r <= 72 then + for _, c in ipairs(open_clients()) do + if c.phase == "open" and c.claude.client.state == "connected" then + c.claude.close() + c.phase = "dropped" -- CLOSE sent; the server removes it on the scheduled on_close + note("close-frame") + break + end + end + elseif r <= 75 then + if claudecode.state.server then + claudecode.stop() + epoch = epoch + 1 + note("STOP") + end + elseif r <= 80 then + if not claudecode.state.server then + assert(claudecode.start(false)) + note("START") + end + else + local choices = { 0, 10, 30, 60, 100, 300, 700, 2000, 5000, 10100 } + local ms = choices[rand(#choices)] + run(ms) + note("run %d", ms) + end + check_received() + end + + -- Settle: restart if stopped, let everything finish with no more actions. + if not claudecode.state.server then + assert(claudecode.start(false)) + note("START (settle)") + end + run(30000) + note("run 30000 (settle)") + check_received() + + if #loop.lua_errors > 0 then + violate("lua errors: " .. table.concat(loop.lua_errors, " || ")) + end + -- Liveness: nothing stuck. + if #claudecode.state.mention_queue > 0 then + violate("stuck queue: " .. #claudecode.state.mention_queue) + end + if claudecode.state.mention_timer ~= nil or claudecode.state.connection_timer ~= nil then + violate( + "timers left: mention=" + .. tostring(claudecode.state.mention_timer) + .. " conn=" + .. tostring(claudecode.state.connection_timer) + ) + end + -- Accounting: every mention accepted after the last stop that no client got must be counted in a timeout error. + local delivered = {} + for _, c in ipairs(clients) do + for _, path in ipairs(c.claude.received()) do + delivered[id_of(path)] = true + end + end + local undelivered = 0 + for id, ep in pairs(accepted) do + if ep == epoch and not delivered[id] then + undelivered = undelivered + 1 + end + end + local cleared = 0 + for _, e in ipairs(errors) do + local n = e:match("clearing (%d+) queued") + if n then + cleared = cleared + tonumber(n) + end + end + -- The errors count over the whole run, and a stop clears mentions without one: only a run with no stop compares. + if epoch == 0 and cleared ~= undelivered then + violate(("accounting: %d undelivered but %d reported as cleared"):format(undelivered, cleared)) + end + end) + if not ok_all then + violate("driver error: " .. tostring(err_all)) + end + return violations, trace + end + + it("fuzz: random sends, connects, drops, closes, stops and starts keep the promise", function() + local bad = {} + for seed = BASE_SEED, BASE_SEED + SEEDS - 1 do + -- A fresh plugin and loop for each seed. + if claudecode.state.server then + claudecode.stop() + end + loop.timers, loop.scheduled, loop.lua_errors, loop.now = {}, {}, {}, 0 + for k in pairs(errors) do + errors[k] = nil + end + claudecode.state.mention_queue = {} + if not claudecode.state.server then + assert(claudecode.start(false)) + end + run(0) + local violations, trace = play(seed) + if #violations > 0 then + table.insert(bad, ("seed %d: %s\n trace: %s"):format(seed, violations[1], table.concat(trace, "; "))) + if #bad >= 5 then + break + end + end + end + if #bad > 0 then + error("\n" .. table.concat(bad, "\n"), 0) + end + end) end) From dd981a5ebe7ac87e668449ef3bf20c0385fd3282 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 12:22:33 +0000 Subject: [PATCH 09/35] test(mention): drop the randomized check from the suite 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. --- tests/unit/mention_queue_spec.lua | 225 ------------------------------ 1 file changed, 225 deletions(-) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 85e339b9b..b482d8ddb 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -623,229 +623,4 @@ describe("The @ mention queue", function() assert.same({ "src/queued.lua", "src/connected.lua" }, claude.received()) end) - - -- Random sequences of sends, connects, upgrades, handshakes, drops, CLOSE frames, stops, starts and waits, each - -- checked against the promise: no client gets a mention twice or out of order or from before a stop, no Lua error, - -- nothing left queued or a timer left running after a long wait, and every mention of a run without a stop either - -- reached a client or was counted in a timeout error. The seeds are fixed, so a failure names the seed and the steps - -- that reproduce it. FUZZ_SEEDS, FUZZ_STEPS and FUZZ_BASE run other sequences. - local SEEDS = tonumber(os.getenv("FUZZ_SEEDS") or "300") - local STEPS = tonumber(os.getenv("FUZZ_STEPS") or "30") - local BASE_SEED = tonumber(os.getenv("FUZZ_BASE") or "1") - - local function id_of(path) - return tonumber(path:match("f(%d+)%.lua$")) - end - - local function play(seed) - local rng = { state = seed * 2654435761 % 4294967296 } - local function rand(n) - rng.state = (rng.state * 1103515245 + 12345) % 2147483648 - return (math.floor(rng.state / 65536) % n) + 1 - end - local trace = {} - local function note(fmt, ...) - table.insert(trace, string.format(fmt, ...)) - end - - local next_id = 0 - local accepted = {} -- id -> epoch - local epoch = 0 - local clients = {} -- list of { claude=, phase="tcp"|"upgrading"|"open"|"dropped", epoch= } - local violations = {} - local function violate(msg) - table.insert(violations, msg) - end - local function open_clients() - local out = {} - for _, c in ipairs(clients) do - if c.phase ~= "dropped" and c.epoch == epoch then - table.insert(out, c) - end - end - return out - end - - local function check_received() - for ci, c in ipairs(clients) do - local ids = {} - local seen = {} - for _, path in ipairs(c.claude.received()) do - local id = id_of(path) - if seen[id] then - violate(("client %d received f%d twice"):format(ci, id)) - end - seen[id] = true - if #ids > 0 and id < ids[#ids] then - violate(("client %d received f%d after f%d: out of order"):format(ci, id, ids[#ids])) - end - table.insert(ids, id) - if accepted[id] ~= nil and accepted[id] < c.epoch then - violate(("client %d (epoch %d) received f%d sent in epoch %d"):format(ci, c.epoch, id, accepted[id])) - end - end - end - end - - local ok_all, err_all = pcall(function() - for _ = 1, STEPS do - local r = rand(100) - if r <= 28 then - if claudecode.state.server then - next_id = next_id + 1 - local path = ("/project/f%d.lua"):format(next_id) - local ok, e = claudecode.send_at_mention(path, nil, nil, "fuzz") - if ok then - accepted[next_id] = epoch - else - violate("send refused while running: " .. tostring(e)) - end - note("send f%d", next_id) - else - note("send skipped (stopped)") - end - elseif r <= 38 then - if claudecode.state.server then - local c = { claude = claude_connects(), phase = "tcp", epoch = epoch } - table.insert(clients, c) - note("connect #%d", #clients) - end - elseif r <= 48 then - for _, c in ipairs(open_clients()) do - if c.phase == "tcp" then - c.claude.upgrade() - c.phase = "upgrading" - note("upgrade") - break - end - end - elseif r <= 60 then - for _, c in ipairs(open_clients()) do - if c.phase == "upgrading" then - c.claude.handshake_written() - c.phase = "open" - note("handshake_written") - break - end - end - elseif r <= 68 then - local oc = open_clients() - if #oc > 0 then - local c = oc[rand(#oc)] - c.claude.drop() - c.phase = "dropped" - note("drop") - end - elseif r <= 72 then - for _, c in ipairs(open_clients()) do - if c.phase == "open" and c.claude.client.state == "connected" then - c.claude.close() - c.phase = "dropped" -- CLOSE sent; the server removes it on the scheduled on_close - note("close-frame") - break - end - end - elseif r <= 75 then - if claudecode.state.server then - claudecode.stop() - epoch = epoch + 1 - note("STOP") - end - elseif r <= 80 then - if not claudecode.state.server then - assert(claudecode.start(false)) - note("START") - end - else - local choices = { 0, 10, 30, 60, 100, 300, 700, 2000, 5000, 10100 } - local ms = choices[rand(#choices)] - run(ms) - note("run %d", ms) - end - check_received() - end - - -- Settle: restart if stopped, let everything finish with no more actions. - if not claudecode.state.server then - assert(claudecode.start(false)) - note("START (settle)") - end - run(30000) - note("run 30000 (settle)") - check_received() - - if #loop.lua_errors > 0 then - violate("lua errors: " .. table.concat(loop.lua_errors, " || ")) - end - -- Liveness: nothing stuck. - if #claudecode.state.mention_queue > 0 then - violate("stuck queue: " .. #claudecode.state.mention_queue) - end - if claudecode.state.mention_timer ~= nil or claudecode.state.connection_timer ~= nil then - violate( - "timers left: mention=" - .. tostring(claudecode.state.mention_timer) - .. " conn=" - .. tostring(claudecode.state.connection_timer) - ) - end - -- Accounting: every mention accepted after the last stop that no client got must be counted in a timeout error. - local delivered = {} - for _, c in ipairs(clients) do - for _, path in ipairs(c.claude.received()) do - delivered[id_of(path)] = true - end - end - local undelivered = 0 - for id, ep in pairs(accepted) do - if ep == epoch and not delivered[id] then - undelivered = undelivered + 1 - end - end - local cleared = 0 - for _, e in ipairs(errors) do - local n = e:match("clearing (%d+) queued") - if n then - cleared = cleared + tonumber(n) - end - end - -- The errors count over the whole run, and a stop clears mentions without one: only a run with no stop compares. - if epoch == 0 and cleared ~= undelivered then - violate(("accounting: %d undelivered but %d reported as cleared"):format(undelivered, cleared)) - end - end) - if not ok_all then - violate("driver error: " .. tostring(err_all)) - end - return violations, trace - end - - it("fuzz: random sends, connects, drops, closes, stops and starts keep the promise", function() - local bad = {} - for seed = BASE_SEED, BASE_SEED + SEEDS - 1 do - -- A fresh plugin and loop for each seed. - if claudecode.state.server then - claudecode.stop() - end - loop.timers, loop.scheduled, loop.lua_errors, loop.now = {}, {}, {}, 0 - for k in pairs(errors) do - errors[k] = nil - end - claudecode.state.mention_queue = {} - if not claudecode.state.server then - assert(claudecode.start(false)) - end - run(0) - local violations, trace = play(seed) - if #violations > 0 then - table.insert(bad, ("seed %d: %s\n trace: %s"):format(seed, violations[1], table.concat(trace, "; "))) - if #bad >= 5 then - break - end - end - end - if #bad > 0 then - error("\n" .. table.concat(bad, "\n"), 0) - end - end) end) From ae8385bcffafd5e70be94857d5bb71695e82ae44 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 13:19:46 +0000 Subject: [PATCH 10/35] test(server): pin the mock broadcast result, and fix the connection_wait_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. --- dev-config.lua | 2 +- tests/unit/server/mock_spec.lua | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/unit/server/mock_spec.lua diff --git a/dev-config.lua b/dev-config.lua index bafc30e7d..18b1ad4f7 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -75,7 +75,7 @@ return { -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) -- Connection Management - -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) + -- connection_wait_delay = 600, -- Wait time after Claude's handshake before sending queued @ mentions (ms) -- connection_timeout = 10000, -- Max time queued @ mentions wait for Claude Code to connect (ms) -- queue_timeout = 5000, -- Unused, still accepted: queued @ mentions wait up to connection_timeout diff --git a/tests/unit/server/mock_spec.lua b/tests/unit/server/mock_spec.lua new file mode 100644 index 000000000..3f85b17dd --- /dev/null +++ b/tests/unit/server/mock_spec.lua @@ -0,0 +1,45 @@ +--- server/mock.lua stands in for server/init.lua, so its broadcast must report delivery the same way: true only when a +--- client took the message. The @ mention queue keeps a mention that no client took. +require("tests.busted_setup") + +describe("The mock server's broadcast", function() + local mock + + before_each(function() + package.loaded["claudecode.server.mock"] = nil + mock = require("claudecode.server.mock") + assert(mock.start({ port_range = { min = 10000, max = 10001 } })) + end) + + after_each(function() + if mock.state.server then + mock.stop() + end + end) + + it("returns false when no client takes the message", function() + assert.is_false(mock.broadcast("at_mentioned", { filePath = "a.lua" })) + end) + + it("returns true when a client takes the message, and the client has it", function() + local client = mock.add_client("client_a") + + assert.is_true(mock.broadcast("at_mentioned", { filePath = "a.lua" })) + assert.equals(1, #client.messages) + assert.equals("at_mentioned", client.messages[1].method) + end) + + it("returns false again once the last client is gone", function() + mock.add_client("client_a") + mock.remove_client("client_a") + + assert.is_false(mock.broadcast("at_mentioned", { filePath = "a.lua" })) + end) + + it("returns false when the server is stopped", function() + mock.add_client("client_a") + mock.stop() + + assert.is_false(mock.broadcast("at_mentioned", { filePath = "a.lua" })) + end) +end) From dcfecdc7c4267930d593599e9f3a03c31a55eff4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 13:29:28 +0000 Subject: [PATCH 11/35] fix(mention): show an error when a mention's write fails after a client 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. --- lua/claudecode/init.lua | 18 ++++++++-- lua/claudecode/server/init.lua | 6 ++-- lua/claudecode/server/mock.lua | 13 ++++--- lua/claudecode/server/tcp.lua | 29 +++++++++++++-- tests/unit/mention_queue_spec.lua | 20 +++++++++++ tests/unit/server/mock_spec.lua | 14 ++++++++ tests/unit/server_spec.lua | 60 +++++++++++++++++++++++++++++++ 7 files changed, 149 insertions(+), 11 deletions(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index ea8b8c175..825ee990b 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -158,6 +158,19 @@ local function start_connection_timeout_if_needed() end) end +---A server.broadcast callback that shows an error when no write of the mention succeeded: the connection failed after +---the client took the frame, so Claude did not get it. It runs in a libuv callback, and logger schedules the message. +---@param component string The logger component +---@param file_path string +---@return fun(written: integer) +local function report_unwritten(component, file_path) + return function(written) + if written == 0 then + logger.error(component, "Claude's connection failed while the @ mention " .. file_path .. " was sent") + end + end +end + ---Send the oldest queued mention to Claude, then schedule the next one. local function flush_mention_queue() local queue = mention_queue() @@ -170,7 +183,7 @@ local function flush_mention_queue() local mention = queue[1] local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line } - if not server.broadcast("at_mentioned", params) then + if not server.broadcast("at_mentioned", params, report_unwritten("queue", mention.file_path)) then -- No connected client took the frame, as Claude went away since this flush was scheduled. The mention waits for -- Claude's next handshake, or times out with an error. logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path) @@ -1331,7 +1344,8 @@ function M._broadcast_at_mention(file_path, start_line, end_line) local immediate = (M.state.config and M.state.config.disable_broadcast_debouncing) or (package.loaded["busted"] and not (M.state.config and M.state.config.enable_broadcast_debouncing_in_tests)) if immediate and #mention_queue() == 0 then - local broadcast_success = M.state.server.broadcast("at_mentioned", params) + local broadcast_success = + M.state.server.broadcast("at_mentioned", params, report_unwritten("command", formatted_path)) if broadcast_success then return true, nil, sent else diff --git a/lua/claudecode/server/init.lua b/lua/claudecode/server/init.lua index 4e64a3d21..7e50829d1 100644 --- a/lua/claudecode/server/init.lua +++ b/lua/claudecode/server/init.lua @@ -430,8 +430,10 @@ end ---Broadcast a message to all connected clients ---@param method string The method name ---@param params table|nil The parameters to send +---@param on_written fun(written: integer)|nil When a connected client took the message: called once after the writes +--- finished, with how many succeeded (see tcp.lua broadcast) ---@return boolean success Whether a connected client took the message -function M.broadcast(method, params) +function M.broadcast(method, params, on_written) if not M.state.server then return false end @@ -443,7 +445,7 @@ function M.broadcast(method, params) } local json_message = vim.json.encode(message) - return tcp_server.broadcast(M.state.server, json_message) > 0 + return tcp_server.broadcast(M.state.server, json_message, on_written) > 0 end ---Get server status information diff --git a/lua/claudecode/server/mock.lua b/lua/claudecode/server/mock.lua index 9b0881ef8..d1db91780 100644 --- a/lua/claudecode/server/mock.lua +++ b/lua/claudecode/server/mock.lua @@ -217,21 +217,26 @@ end ---Broadcast a message to all connected clients ---@param method string The method name ---@param params table The parameters to send +---@param on_written fun(written: integer)|nil When a client took the message: called once with how many did, as +--- server/init.lua broadcast reports it after its writes finished ---@return boolean success Whether a client took the message, as server/init.lua broadcast reports it -function M.broadcast(method, params) +function M.broadcast(method, params, on_written) if not M.state.server or type(M.state.server.clients) ~= "table" then return false end - local success = false + local written = 0 for client_id, _ in pairs(M.state.server.clients) do if M.send(client_id, method, params) then - success = true + written = written + 1 end end - return success + if written > 0 and on_written then + on_written(written) + end + return written > 0 end ---Simulate receiving a message from a client diff --git a/lua/claudecode/server/tcp.lua b/lua/claudecode/server/tcp.lua index 903b168fb..8b46bb5e9 100644 --- a/lua/claudecode/server/tcp.lua +++ b/lua/claudecode/server/tcp.lua @@ -297,15 +297,38 @@ end ---Broadcast a message to all connected clients ---@param server TCPServer The server object ---@param message string The message to broadcast +---@param on_written fun(written: integer)|nil When a client took the message: called once, after the last write +--- finished, with how many writes succeeded. A socket that fails during the write reports it only here. ---@return integer sent How many clients took the message: those whose state is "connected" -function M.broadcast(server, message) - local sent = 0 +function M.broadcast(server, message, on_written) + local sent, pending, written, dispatched, reported = 0, 0, 0, false, false + local function finish() + if dispatched and not reported and pending == 0 and sent > 0 then + reported = true + on_written(written) + end + end for _, client in pairs(server.clients) do if client.state == "connected" then - client_manager.send_message(client, message) sent = sent + 1 + if on_written then + pending = pending + 1 + client_manager.send_message(client, message, function(err) + pending = pending - 1 + if not err then + written = written + 1 + end + finish() + end) + else + client_manager.send_message(client, message) + end end end + if on_written then + dispatched = true + finish() + end return sent end diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index b482d8ddb..62c4c812d 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -232,6 +232,13 @@ describe("The @ mention queue", function() assert(not claude.closed, "write after close") if data:sub(1, 12) == "HTTP/1.1 101" then claude.reply_written = callback + elseif claude.write_error then + -- The socket fails during the write: libuv reports it only to the write callback, in a later loop turn. + if callback then + at(0, function() + callback(claude.write_error) + end) + end else table.insert(claude.written, data) if callback then @@ -403,6 +410,19 @@ describe("The @ mention queue", function() assert.same({}, errors) end) + it("H: a connected client takes the mention but its socket fails during the write: you see an error", function() + local claude = claude_connects() + claude.handshake() + run(1000) + claude.write_error = "ECONNRESET" + send("/project/a.lua") + run(1000) + + assert.same({}, claude.received()) + assert.equals(1, #errors) + assert.matches("connection failed while the @ mention a.lua was sent", errors[1]) + end) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() diff --git a/tests/unit/server/mock_spec.lua b/tests/unit/server/mock_spec.lua index 3f85b17dd..1a06c5729 100644 --- a/tests/unit/server/mock_spec.lua +++ b/tests/unit/server/mock_spec.lua @@ -29,6 +29,20 @@ describe("The mock server's broadcast", function() assert.equals("at_mentioned", client.messages[1].method) end) + it("reports how many clients took the message only when one did", function() + local reports = {} + local function on_written(count) + table.insert(reports, count) + end + + mock.broadcast("at_mentioned", { filePath = "a.lua" }, on_written) + mock.add_client("client_a") + mock.add_client("client_b") + mock.broadcast("at_mentioned", { filePath = "a.lua" }, on_written) + + assert.same({ 2 }, reports) + end) + it("returns false again once the last client is gone", function() mock.add_client("client_a") mock.remove_client("client_a") diff --git a/tests/unit/server_spec.lua b/tests/unit/server_spec.lua index f8997ccd5..daa451d2c 100644 --- a/tests/unit/server_spec.lua +++ b/tests/unit/server_spec.lua @@ -213,6 +213,66 @@ describe("WebSocket Server", function() assert.same({ "connected_client" }, written) end) + it("reports once, after the last write finished, how many writes of a broadcast succeeded", function() + server.start({ port_range = { min = 10000, max = 65535 } }) + local pending_writes = {} + local function add_client(id, sync_error) + server.state.server.clients[id] = { + id = id, + state = "connected", + tcp_handle = { + write = function(_, _, callback) + if sync_error ~= nil then + callback(sync_error or nil) + else + table.insert(pending_writes, callback) + end + end, + is_closing = function() + return false + end, + close = function() end, + }, + } + end + local reports = {} + local function on_written(count) + table.insert(reports, count) + end + + local results = {} + -- No client: nothing to report. + results.none = server.broadcast("m", {}, on_written) + local reports_without_client = #reports + -- Two clients whose writes finish later, one with an error. + add_client("a") + add_client("b") + results.async = server.broadcast("m", {}, on_written) + local reports_before_writes = #reports + pending_writes[1]("ECONNRESET") + local reports_after_first_write = #reports + pending_writes[2]() + local async_reports = vim.deepcopy(reports) + -- Writes that finish at once, both failed. + server.state.server.clients = {} + reports = {} + add_client("c", "EPIPE") + add_client("d", "EPIPE") + results.sync = server.broadcast("m", {}, on_written) + + server.state.server.clients = {} + server.stop() + + assert.is_false(results.none) + assert.equals(0, reports_without_client) + assert.is_true(results.async) + assert.equals(0, reports_before_writes) + assert.equals(0, reports_after_first_write) + assert.same({ 1 }, async_reports) + assert.is_true(results.sync) + assert.same({ 0 }, reports) + end) + describe("invalid JSON-RPC messages", function() local tcp = require("claudecode.server.tcp") local original = {} From e50cbffc93b18090bd2e4197cac9579a484cb2be Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 13:34:29 +0000 Subject: [PATCH 12/35] docs(mention): describe the write report, and re-record the diff model 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. --- .agents/skills/verify-diff-lifecycle/covered.lock | 8 ++++---- lua/claudecode/README.md | 2 +- lua/claudecode/server/README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 0e6f4b5a6..d204ca854 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -121,7 +121,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua cd570b67f5c23a17 +file lua/claudecode/init.lua 7e6ff751741f47cc block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -142,7 +142,7 @@ file lua/claudecode/server/frame.lua 3c50766875878d56 == lua/claudecode/server/handshake.lua file lua/claudecode/server/handshake.lua 3eca35d572094a55 == lua/claudecode/server/init.lua -file lua/claudecode/server/init.lua dcac9fddf1ad3be3 +file lua/claudecode/server/init.lua d6e881211c59f191 block server_stop 4dae0bf675cf2cc1 block on_disconnect 9110ffdfd72d4ea6 block handle_request b74ff7dc2e55a2b8 @@ -155,9 +155,9 @@ site lua/claudecode/server/init.lua: diff.close_pending_diffs("server stopping") site lua/claudecode/server/init.lua: if _G.claude_deferred_responses then @ function M.stop() site lua/claudecode/server/init.lua: if not _G.claude_deferred_responses then @ function M._setup_deferred_response(deferred_info) == lua/claudecode/server/mock.lua -file lua/claudecode/server/mock.lua b2726250730d062f +file lua/claudecode/server/mock.lua def1248d2793d559 == lua/claudecode/server/tcp.lua -file lua/claudecode/server/tcp.lua 2ec384dd58cfbbd7 +file lua/claudecode/server/tcp.lua 2f78d4f315ed2a1c == lua/claudecode/server/utils.lua file lua/claudecode/server/utils.lua 2915ae87b48f04ca == lua/claudecode/terminal.lua diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 3e1bf9c8b..951b7ce89 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -111,7 +111,7 @@ Module map (direct files): - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] - A mention sent while disconnected goes to the queue with the same cwd-relative path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention]. A path becomes relative only when it is the cwd or below it, so a file in a sibling such as `/project2` keeps its absolute path when the cwd is `/project` [init.lua:relative_to] -- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] +- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 55a7be6d2..75feb2445 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -26,7 +26,7 @@ Layers, top to bottom: - `tools/call` delegates to `tools.handle_invoke` and returns a `_deferred` table for blocking tools [server/init.lua:M.register_handlers] - `M.send(client, method, params) -> boolean`: sends a JSON-RPC notification to one client [server/init.lua:M.send] - `M.send_response(client, id, result, error_data) -> boolean`: a `nil` id is sent as JSON `null` (`vim.NIL`) [server/init.lua:M.send_response] -- `M.broadcast(method, params) -> boolean`: sends a notification to every `connected` client, and returns true only when at least one took it [server/init.lua:M.broadcast] +- `M.broadcast(method, params, on_written?) -> boolean`: sends a notification to every `connected` client, and returns true only when at least one took it. When one did, `on_written(written)` runs once after the last write finished, with how many writes succeeded: a socket that fails during the write reports it only there [server/init.lua:M.broadcast] [server/tcp.lua:M.broadcast] - `M.get_status() -> { running, port, client_count, clients }`. `clients` is present only while the server runs [server/init.lua:M.get_status] - Internal dispatch: `M._handle_message`, `M._handle_request`, `M._handle_notification`, and `M._setup_deferred_response` [server/init.lua:M._handle_message] From ee2a87bd8cd9990d1269ffd0d7ab2824f149f2fa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 14:20:17 +0000 Subject: [PATCH 13/35] test(mention): cover the write report of the immediate send, and a mixed 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. --- tests/unit/mention_queue_spec.lua | 17 +++++++++++++++++ tests/unit/server_spec.lua | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 5f7f0c574..3a560f6e9 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -423,6 +423,23 @@ describe("The @ mention queue", function() assert.matches("connection failed while the @ mention /project/a%.lua was sent", errors[1]) end) + it("H: with disable_broadcast_debouncing, a send whose write fails shows an error with the absolute path", function() + claudecode.state.config.disable_broadcast_debouncing = true + local claude = claude_connects() + claude.handshake() + run(1000) + claude.write_error = "ECONNRESET" + send("src/a.lua") + -- The send went out at once: nothing waits in the queue, and no flush is due. + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.mention_timer) + run(100) + + assert.same({}, claude.received()) + assert.equals(1, #errors) + assert.matches("connection failed while the @ mention /project/src/a%.lua was sent", errors[1]) + end) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() diff --git a/tests/unit/server_spec.lua b/tests/unit/server_spec.lua index daa451d2c..aecd3acbf 100644 --- a/tests/unit/server_spec.lua +++ b/tests/unit/server_spec.lua @@ -259,6 +259,15 @@ describe("WebSocket Server", function() add_client("c", "EPIPE") add_client("d", "EPIPE") results.sync = server.broadcast("m", {}, on_written) + local sync_failed_reports = vim.deepcopy(reports) + -- Writes that finish at once, one failed and two succeeded: the report must wait for the last of them, whatever + -- order pairs() visits the clients in. + server.state.server.clients = {} + reports = {} + add_client("e", "EPIPE") + add_client("f", false) + add_client("g", false) + results.sync_mixed = server.broadcast("m", {}, on_written) server.state.server.clients = {} server.stop() @@ -270,7 +279,9 @@ describe("WebSocket Server", function() assert.equals(0, reports_after_first_write) assert.same({ 1 }, async_reports) assert.is_true(results.sync) - assert.same({ 0 }, reports) + assert.same({ 0 }, sync_failed_reports) + assert.is_true(results.sync_mixed) + assert.same({ 2 }, reports) end) describe("invalid JSON-RPC messages", function() From 2cf9bc4bf35fe1261eea16495eb0aeba69d50d14 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 14:44:31 +0000 Subject: [PATCH 14/35] fix(mention): do not postpone a due flush on another client's handshake 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. --- lua/claudecode/init.lua | 17 ++++++++++++++++- tests/unit/mention_queue_spec.lua | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 42bf0885a..860624fc0 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -80,6 +80,17 @@ function M.is_claude_connected() end end +---@return integer count The clients that count as connected for M.is_claude_connected +local function connected_client_count() + local count = 0 + for _, info in ipairs(require("claudecode.server.init").get_status().clients or {}) do + if info.state == "connected" and info.handshake_complete == true then + count = count + 1 + end + end + return count +end + -- The @ mention queue. A send that does not go out at once waits in M.state.mention_queue, oldest first. A mention -- leaves the queue only when a connected Claude took it, when its wait timed out with an error, or when the -- integration stops. While the queue holds a mention, one of two timers runs: @@ -228,7 +239,11 @@ end ---@param from_new_connection boolean|nil true after a handshake; otherwise the oldest mention goes out now function M.process_mention_queue(from_new_connection) if from_new_connection then - stop_timer("mention_timer") + -- A flush that is already due while another client was connected stays: repeated handshakes of a second client + -- must not postpone it again and again. + if not (M.state.mention_timer and connected_client_count() > 1) then + stop_timer("mention_timer") + end schedule_mention_flush(M.state.config.connection_wait_delay) else flush_mention_queue() diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 3a560f6e9..f6bf3b83b 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -440,6 +440,20 @@ describe("The @ mention queue", function() assert.matches("connection failed while the @ mention /project/src/a%.lua was sent", errors[1]) end) + it("D: handshakes of other clients do not postpone a flush that is due for a connected Claude", function() + local claude = claude_connects() + claude.handshake() + run(1000) + send("/project/a.lua") + for _ = 1, 10 do + claude_connects().handshake() + run(40) + end + + assert.same({ "a.lua" }, names(claude.received())) + assert.same({}, errors) + end) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() From aefe6dddd5f0bd128131dfb74133a1e21bf5beb1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 14:47:11 +0000 Subject: [PATCH 15/35] chore(skills): re-record the diff model for the handshake flush change Only the file hash of init.lua changed. The queue code calls no diff function, and check.sh passes. --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 272046ebc..182cca471 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -121,7 +121,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua f7c7fff32489cedd +file lua/claudecode/init.lua b4780d1b0d2a0bdb block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() From 7ee279ff97ed380f8d654cd4154100170a36679f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 15:11:56 +0000 Subject: [PATCH 16/35] fix(server): report a write that libuv refuses at once to its callback 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. --- lua/claudecode/server/client.lua | 9 ++++++-- tests/unit/mention_queue_spec.lua | 17 +++++++++++++++ tests/unit/server_spec.lua | 35 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lua/claudecode/server/client.lua b/lua/claudecode/server/client.lua index d1781ac2b..8ad355464 100644 --- a/lua/claudecode/server/client.lua +++ b/lua/claudecode/server/client.lua @@ -297,7 +297,7 @@ end ---Send a text message to a client ---@param client WebSocketClient The client object ---@param message string The message to send ----@param callback function? Optional callback: function(err) +---@param callback function? Optional callback: function(err), run once when the write finished or failed function M.send_message(client, message, callback) if client.state ~= "connected" then if callback then @@ -309,7 +309,12 @@ function M.send_message(client, message, callback) -- Every outbound message passes here. A buffer name or path in it can hold any bytes, and the client drops the -- connection on a text frame that is not valid UTF-8. local text_frame = frame.create_text_frame(utils.valid_utf8(message)) - client.tcp_handle:write(text_frame, callback) + -- libuv runs the callback only for a write it queued. A write it refuses at once returns nil and the error, and + -- never runs the callback, so the caller hears about that failure here. + local queued, err = client.tcp_handle:write(text_frame, callback) + if not queued and err and callback then + callback(err) + end end ---Send a ping to a client diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index f6bf3b83b..e49552c92 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -232,6 +232,9 @@ describe("The @ mention queue", function() assert(not claude.closed, "write after close") if data:sub(1, 12) == "HTTP/1.1 101" then claude.reply_written = callback + elseif claude.write_refused then + -- libuv refuses the write at once: it returns nil and the error, and never runs the callback. + return nil, claude.write_refused elseif claude.write_error then -- The socket fails during the write: libuv reports it only to the write callback, in a later loop turn. if callback then @@ -423,6 +426,20 @@ describe("The @ mention queue", function() assert.matches("connection failed while the @ mention /project/a%.lua was sent", errors[1]) end) + it("H: a write that libuv refuses at once shows an error too", function() + local claude = claude_connects() + claude.handshake() + run(1000) + claude.write_refused = "EBADF: bad file descriptor" + send("/project/a.lua") + run(1000) + + assert.same({}, claude.received()) + assert.same({}, claudecode.state.mention_queue) + assert.equals(1, #errors) + assert.matches("connection failed while the @ mention /project/a%.lua was sent", errors[1]) + end) + it("H: with disable_broadcast_debouncing, a send whose write fails shows an error with the absolute path", function() claudecode.state.config.disable_broadcast_debouncing = true local claude = claude_connects() diff --git a/tests/unit/server_spec.lua b/tests/unit/server_spec.lua index aecd3acbf..99787bdad 100644 --- a/tests/unit/server_spec.lua +++ b/tests/unit/server_spec.lua @@ -284,6 +284,41 @@ describe("WebSocket Server", function() assert.same({ 2 }, reports) end) + it("counts a write that libuv refuses at once as failed, and still reports once", function() + server.start({ port_range = { min = 10000, max = 65535 } }) + local function add_client(id, write) + server.state.server.clients[id] = { + id = id, + state = "connected", + tcp_handle = { + write = write, + is_closing = function() + return false + end, + close = function() end, + }, + } + end + -- libuv returns nil and the error for a write it refuses, and never runs that write's callback. + add_client("refused", function() + return nil, "EBADF: bad file descriptor" + end) + add_client("written", function(_, _, callback) + callback() + return true + end) + local reports = {} + local took = server.broadcast("m", {}, function(count) + table.insert(reports, count) + end) + + server.state.server.clients = {} + server.stop() + + assert.is_true(took) + assert.same({ 1 }, reports) + end) + describe("invalid JSON-RPC messages", function() local tcp = require("claudecode.server.tcp") local original = {} From 4ba93426d9f61a4106eb5a082467c4d8cda42636 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 15:15:18 +0000 Subject: [PATCH 17/35] chore(skills): re-record the diff model for the refused-write report Only the file hash of server/client.lua changed, outside create_client, the one client.lua block the diff model covers. check.sh passes. --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 182cca471..4daf45cfd 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -135,7 +135,7 @@ file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua file lua/claudecode/selection.lua b19b6556c3162ac5 == lua/claudecode/server/client.lua -file lua/claudecode/server/client.lua 3454ff62e057b632 +file lua/claudecode/server/client.lua 1406fc214c779652 block create_client 647b7523dea26af6 == lua/claudecode/server/frame.lua file lua/claudecode/server/frame.lua 3c50766875878d56 From 42bb41ea511d213f96c7eafa1646220f882a723f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:21:56 +0000 Subject: [PATCH 18/35] fix(mention): send a flush only once a connected client has had connection_wait_delay Codex round 1 on #480 (P2). The previous fix kept a due flush when another client was connected at the handshake. When that older client then left before the flush ran, the flush went to the new client tens of milliseconds after its handshake, not connection_wait_delay after. A client now records when its handshake completed (connected_at in get_client_info). A flush sends only once some connected client has been connected for connection_wait_delay ms, and otherwise waits for the first such moment. A handshake no longer restarts the flush timer, so another client's handshakes still cannot postpone it. --- lua/claudecode/README.md | 2 +- lua/claudecode/init.lua | 31 +++++++++++++++++++------------ lua/claudecode/server/client.lua | 4 ++++ tests/unit/mention_queue_spec.lua | 21 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index d8265ae3a..a7b078099 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -111,7 +111,7 @@ Module map (direct files): - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] - A mention sent while disconnected goes to the queue with the same absolute path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] -- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] +- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 860624fc0..ea8e81b2d 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -80,22 +80,25 @@ function M.is_claude_connected() end end ----@return integer count The clients that count as connected for M.is_claude_connected -local function connected_client_count() - local count = 0 +---@return number|nil wait Milliseconds until some connected client completed its handshake connection_wait_delay ms +--- ago: 0 when one did, nil when no connected client reports when it connected +local function ms_until_a_client_is_ready() + local wait for _, info in ipairs(require("claudecode.server.init").get_status().clients or {}) do - if info.state == "connected" and info.handshake_complete == true then - count = count + 1 + if info.state == "connected" and info.handshake_complete == true and info.connected_at then + local left = math.max(0, info.connected_at + M.state.config.connection_wait_delay - vim.loop.now()) + wait = wait and math.min(wait, left) or left end end - return count + return wait end -- The @ mention queue. A send that does not go out at once waits in M.state.mention_queue, oldest first. A mention -- leaves the queue only when a connected Claude took it, when its wait timed out with an error, or when the -- integration stops. While the queue holds a mention, one of two timers runs: -- - M.state.mention_timer is the next flush: 50 ms after a send, connection_wait_delay ms after Claude's handshake, --- or 25 ms after the previous mention went out. A flush sends only the oldest mention, so the order holds. +-- or 25 ms after the previous mention went out. A flush sends only the oldest mention, so the order holds, and only +-- once some connected client completed its handshake connection_wait_delay ms ago. -- - M.state.connection_timer runs while the mentions wait for Claude to connect. After connection_timeout ms it clears -- them with an error. Claude's handshake stops it, and a flush that finds no connected Claude starts it. -- A timer's callback runs on the main loop and does nothing once its timer was stopped or replaced, so a stop cancels @@ -191,6 +194,13 @@ local function flush_mention_queue() end -- M.stop clears the queue when it drops the server, and the stop cancels this flush too. local server = assert(M.state.server, "the @ mention queue holds mentions while the integration is stopped") + -- Claude gets connection_wait_delay ms after its handshake before the first mention. A client connected that long + -- takes the mention now, whatever other clients do meanwhile. + local wait = ms_until_a_client_is_ready() + if wait and wait > 0 then + start_timer("mention_timer", wait, flush_mention_queue) + return + end local mention = queue[1] local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line } @@ -239,11 +249,8 @@ end ---@param from_new_connection boolean|nil true after a handshake; otherwise the oldest mention goes out now function M.process_mention_queue(from_new_connection) if from_new_connection then - -- A flush that is already due while another client was connected stays: repeated handshakes of a second client - -- must not postpone it again and again. - if not (M.state.mention_timer and connected_client_count() > 1) then - stop_timer("mention_timer") - end + -- The handshake stops the connection timeout, and a flush is due. The flush itself waits until a client has been + -- connected for connection_wait_delay ms, so another client's handshake cannot postpone it. schedule_mention_flush(M.state.config.connection_wait_delay) else flush_mention_queue() diff --git a/lua/claudecode/server/client.lua b/lua/claudecode/server/client.lua index 8ad355464..950f70a8d 100644 --- a/lua/claudecode/server/client.lua +++ b/lua/claudecode/server/client.lua @@ -28,6 +28,7 @@ local CLOSE_REASONS = { ---@field fragments table|nil Joined pieces of the text message whose last fragment has not arrived, and their total `size` ---@field handshake_complete boolean Whether WebSocket handshake is complete ---@field close_pending boolean|nil True while a Close frame waits to be written: its write callback closes the socket +---@field connected_at number|nil When the WebSocket handshake completed (vim.loop.now()) ---@field last_ping number Timestamp of last ping sent ---@field last_pong number Timestamp of last pong received @@ -195,6 +196,8 @@ function M.process_data(client, data, on_message, on_close, on_error, auth_token -- The keepalive window starts now. The ping timer ignores a client that is not connected, so the main loop can -- block between the accept and this point for longer than the timeout, and no ping reached this client yet. client.last_pong = vim.loop.now() + -- init.lua sends the first @ mention to a client connection_wait_delay ms after this. + client.connected_at = client.last_pong logger.debug("client", "WebSocket connection established for client:", client.id) if on_open then on_open(client) @@ -403,6 +406,7 @@ function M.get_client_info(client) buffer_size = #client.buffer + (client.pending and client.pending.size or 0), last_ping = client.last_ping, last_pong = client.last_pong, + connected_at = client.connected_at, } end diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index e49552c92..c517745c7 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -471,6 +471,27 @@ describe("The @ mention queue", function() assert.same({}, errors) end) + it( + "D: when the Claude a flush was kept for leaves, a newly connected Claude still gets connection_wait_delay", + function() + local first = claude_connects() + first.handshake() + run(1000) + send("/project/a.lua") + run(10) + local second = claude_connects() + second.handshake() + run(10) + first.drop() + run(280) + assert.same({}, second.received(), "nothing goes out before the new Claude's connection_wait_delay ends") + + run(400) + assert.same({ "a.lua" }, names(second.received())) + assert.same({}, errors) + end + ) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() From c97b6121269ca0950640a147bb510d661ccd66f3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:28:24 +0000 Subject: [PATCH 19/35] chore(skills): re-record the diff model for the flush readiness change Only the file hashes of init.lua and server/client.lua changed. The client.lua change sets connected_at at the handshake and returns it in get_client_info, outside create_client, the block the diff model covers. check.sh passes. --- .agents/skills/verify-diff-lifecycle/covered.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 158029ef0..8d400ddde 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -124,7 +124,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua b4780d1b0d2a0bdb +file lua/claudecode/init.lua f1be92cef9ea1e6c block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -138,7 +138,7 @@ file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua file lua/claudecode/selection.lua 0fa651cc95e0af0d == lua/claudecode/server/client.lua -file lua/claudecode/server/client.lua 1406fc214c779652 +file lua/claudecode/server/client.lua a56269bb855247a1 block create_client 647b7523dea26af6 == lua/claudecode/server/frame.lua file lua/claudecode/server/frame.lua 3c50766875878d56 From e0131fb9ac64d7e4010a226a80e0346918e46999 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 21:52:32 +0000 Subject: [PATCH 20/35] fix(mention): bound the wait for a ready Claude, and pin the readiness rule with tests Verifier findings on 0585211 (#480). - When clients kept connecting and leaving so that one was always connected but none stayed connection_wait_delay, a queued mention waited forever with no error. Once the first wait for a ready client lasted connection_timeout + connection_wait_delay ms, the queue is now cleared with the connection timeout error. - Tests for the readiness rule: the wait starts at the handshake, not at the TCP connect; a closing client does not count as ready; the flush checks again after its wait; a pong does not restart the wait. - process_mention_queue() without the argument, the handshake comment in client.lua and the module README now state the real rule: a mention goes out once some connected client has been connected for connection_wait_delay, and the broadcast also reaches clients that connected later. --- lua/claudecode/README.md | 4 +- lua/claudecode/init.lua | 26 ++++++--- lua/claudecode/server/client.lua | 3 +- tests/unit/mention_queue_spec.lua | 89 +++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index a7b078099..340b206da 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -45,7 +45,7 @@ Module map (direct files): - `M.state`: `config`, `server`, `port`, `auth_token`, `initialized`, `mention_queue`, `mention_timer`, and `connection_timer` [init.lua:M.state] - `M.is_claude_connected()`: false when the server is not running. When `get_status().clients` is non-empty, true only when a client is in state `connected` with `handshake_complete`, so a `closing` client does not count. When it is empty, true when `client_count > 0`, with no handshake check [init.lua:M.is_claude_connected] - `M.send_at_mention(file_path, start_line?, end_line?, context?) -> ok, err?`: line numbers are 0-indexed [init.lua:M.send_at_mention] -- `M.process_mention_queue(from_new_connection?)`: the server's `on_open` callback calls it with `true` once a client completed its WebSocket handshake. Without the argument it sends the oldest queued mention now [init.lua:M.process_mention_queue] +- `M.process_mention_queue(from_new_connection?)`: the server's `on_open` callback calls it with `true` once a client completed its WebSocket handshake. Without the argument it flushes the queue now. A flush sends the oldest mention only once some connected client has been connected for `connection_wait_delay` ms, and otherwise waits for that moment [init.lua:M.process_mention_queue] - `M.open_with_model(args)`, `M.get_version()`, and `M.version`, read from the repository's `VERSION` file at load [init.lua:M.version] - Internal API: `M._create_commands`, `M._broadcast_at_mention`, `M._add_paths_to_claude` (default `max_files` 100, `batch_size` 10), `M._format_path_for_at_mention`, `M._ensure_terminal_visible_if_connected`, and `M._maybe_warn_unfocusable_provider` [init.lua:M._add_paths_to_claude] @@ -111,7 +111,7 @@ Module map (direct files): - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] - A mention sent while disconnected goes to the queue with the same absolute path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] -- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] +- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. The broadcast then reaches every connected client, also one that connected later and is still in its own wait. When clients keep connecting and leaving so that none stays connected that long, the queue is cleared with the connection timeout error once the first wait for a ready client lasted `connection_timeout + connection_wait_delay` ms [init.lua:flush_mention_queue]. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index ea8e81b2d..94d7ad391 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -80,6 +80,9 @@ function M.is_claude_connected() end end +-- vim.loop.now() when a flush first had to wait for a ready client, until a mention goes out or the queue is cleared +local ready_wait_since + ---@return number|nil wait Milliseconds until some connected client completed its handshake connection_wait_delay ms --- ago: 0 when one did, nil when no connected client reports when it connected local function ms_until_a_client_is_ready() @@ -149,6 +152,7 @@ local function clear_mention_queue() logger.debug("queue", "Clearing " .. #queue .. " queued @ mentions") end M.state.mention_queue = {} + ready_wait_since = nil stop_timer("mention_timer") stop_timer("connection_timer") end @@ -194,10 +198,18 @@ local function flush_mention_queue() end -- M.stop clears the queue when it drops the server, and the stop cancels this flush too. local server = assert(M.state.server, "the @ mention queue holds mentions while the integration is stopped") - -- Claude gets connection_wait_delay ms after its handshake before the first mention. A client connected that long - -- takes the mention now, whatever other clients do meanwhile. + -- A mention goes out only once some connected client has been connected for connection_wait_delay ms. The broadcast + -- then reaches every connected client, also one that connected later. Another client's handshake cannot postpone it. local wait = ms_until_a_client_is_ready() if wait and wait > 0 then + -- Clients that keep connecting and leaving before any of them is ready must not hold the mentions forever. + ready_wait_since = ready_wait_since or vim.loop.now() + local limit = M.state.config.connection_timeout + M.state.config.connection_wait_delay + if vim.loop.now() - ready_wait_since >= limit then + logger.error("queue", "Connection timeout - clearing " .. #queue .. " queued @ mentions") + clear_mention_queue() + return + end start_timer("mention_timer", wait, flush_mention_queue) return end @@ -212,6 +224,7 @@ local function flush_mention_queue() return end table.remove(queue, 1) + ready_wait_since = nil logger.debug("queue", "Sent queued @ mention: " .. mention.file_path) schedule_mention_flush(inter_message_delay) end @@ -245,12 +258,13 @@ local function queue_mention(file_path, start_line, end_line) end ---Process the mention queue. Claude completed its WebSocket handshake when from_new_connection is true (the server's ----on_open callback): the queue then flushes connection_wait_delay ms later, so that Claude is ready for the mentions. ----@param from_new_connection boolean|nil true after a handshake; otherwise the oldest mention goes out now +---on_open callback): the handshake stops the connection timeout, and a flush is due in connection_wait_delay ms +---unless one is due already. Without the argument, the flush runs now. +---Either way, a flush sends the oldest mention only once some connected client has been connected for +---connection_wait_delay ms, and otherwise waits for that moment (see flush_mention_queue). +---@param from_new_connection boolean|nil true after a handshake; otherwise the queue flushes now function M.process_mention_queue(from_new_connection) if from_new_connection then - -- The handshake stops the connection timeout, and a flush is due. The flush itself waits until a client has been - -- connected for connection_wait_delay ms, so another client's handshake cannot postpone it. schedule_mention_flush(M.state.config.connection_wait_delay) else flush_mention_queue() diff --git a/lua/claudecode/server/client.lua b/lua/claudecode/server/client.lua index 97072b0ea..2124b32cf 100644 --- a/lua/claudecode/server/client.lua +++ b/lua/claudecode/server/client.lua @@ -197,7 +197,8 @@ function M.process_data(client, data, on_message, on_close, on_error, auth_token -- The keepalive window starts now. The ping timer ignores a client that is not connected, so the main loop can -- block between the accept and this point for longer than the timeout, and no ping reached this client yet. client.last_pong = vim.loop.now() - -- init.lua sends the first @ mention to a client connection_wait_delay ms after this. + -- init.lua sends queued @ mentions only once some connected client has been connected for + -- connection_wait_delay ms since this point. The broadcast then also reaches clients that connected later. client.connected_at = client.last_pong logger.debug("client", "WebSocket connection established for client:", client.id) if on_open then diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index c517745c7..d199f7d71 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -277,6 +277,10 @@ describe("The @ mention queue", function() handle._read_cb(nil, frame.create_frame(frame.OPCODE.CLOSE, "\3\232", true, true)) assert.equals("closing", client.state) end + -- Claude answers a ping: the server sets last_pong. + function claude.pong() + handle._read_cb(nil, frame.create_frame(frame.OPCODE.PONG, "", true, true)) + end ---@return string[] paths The filePath of each at_mentioned notification Claude received, in order. function claude.received() local paths = {} @@ -492,6 +496,91 @@ describe("The @ mention queue", function() end ) + it("D: a Claude whose handshake came 700 ms after its TCP connect still gets connection_wait_delay", function() + local claude = claude_connects() + run(700) + claude.handshake() + send("/project/a.lua") + run(300) + assert.same({}, claude.received(), "the wait starts at the handshake, not at the TCP connect") + + run(400) + assert.same({ "a.lua" }, names(claude.received())) + end) + + it("D: a Claude whose CLOSE frame arrives as a flush is due does not count as ready", function() + local first = claude_connects() + first.handshake() + run(1000) + local second = claude_connects() + second.handshake() + send("/project/a.lua") + -- In the timer phase of the due flush, after it: the ready Claude sends its CLOSE frame and is "closing". + at(50, first.close) + run(300) + assert.same({}, second.received(), "only a connected Claude counts as ready") + + run(400) + assert.same({ "a.lua" }, names(second.received())) + assert.same({}, first.received()) + end) + + it("D: when the Claude a flush waits for leaves during the wait, the next Claude gets its own wait", function() + local first = claude_connects() + first.handshake() + send("/project/a.lua") + run(300) + first.drop() + run(200) + local second = claude_connects() + second.handshake() + run(300) + assert.same({}, second.received(), "the flush checks again after its wait") + + run(400) + assert.same({ "a.lua" }, names(second.received())) + end) + + it("D: a Claude that answered a ping just now has still been connected since its handshake", function() + local claude = claude_connects() + claude.handshake() + run(1000) + claude.pong() + send("/project/a.lua") + run(100) + + assert.same({ "a.lua" }, names(claude.received())) + end) + + it("S: clients that keep connecting and leaving before any is ready do not hold a mention forever", function() + -- Each client lives 300 ms and a new one connects every 200 ms, for 20 s: one is always connected, and none is + -- connected for connection_wait_delay (600 ms). + local clients = { claude_connects() } + clients[1].handshake() + send("/project/a.lua") + local error_at + for i = 2, 101 do + local new = claude_connects() + new.handshake() + clients[i] = new + run(100) + clients[i - 1].drop() + run(100) + if #errors > 0 and not error_at then + error_at = loop.now + end + end + + assert.equals(1, #errors) + assert.matches("Connection timeout %- clearing 1 queued", errors[1]) + assert.is_true(error_at >= 10000 and error_at <= 11500, "the error came at " .. tostring(error_at) .. " ms") + assert.same({}, claudecode.state.mention_queue) + for _, claude in ipairs(clients) do + assert.same({}, claude.received()) + end + assert_no_lua_error() + end) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() From 7bc97b99539d1602a79a6b2937f94f388111fa4d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 21:54:32 +0000 Subject: [PATCH 21/35] test(mention): the bound on the wait for a ready Claude starts again after a send or a stop --- tests/unit/mention_queue_spec.lua | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index d199f7d71..1e56205f6 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -581,6 +581,39 @@ describe("The @ mention queue", function() assert_no_lua_error() end) + it("S, control: the bound on the wait for a ready Claude starts again after a mention went out", function() + local first = claude_connects() + first.handshake() + send("/project/a.lua") + run(12000) + assert.same({ "a.lua" }, names(first.received())) + first.drop() + local second = claude_connects() + second.handshake() + send("/project/b.lua") + run(1000) + + assert.same({ "b.lua" }, names(second.received())) + assert.same({}, errors) + end) + + it("S, control: the bound on the wait for a ready Claude starts again after a stop", function() + local first = claude_connects() + first.handshake() + send("/project/a.lua") + run(100) + claudecode.stop() + assert(claudecode.start(false)) + run(12000) + local second = claude_connects() + second.handshake() + send("/project/b.lua") + run(1000) + + assert.same({ "b.lua" }, names(second.received())) + assert.same({}, errors) + end) + it("E: a mention sent during the wait after a connect arrives after the one queued before it", function() send("/project/first.lua") local claude = claude_connects() From d1a67241f6f972e5d0486efe2a5d8fa47afbf0fe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 21:59:43 +0000 Subject: [PATCH 22/35] chore(skills): re-record the diff model after the readiness bound and the #479 merge Only the file hashes of init.lua and server/client.lua changed. The queue code calls no diff function, and the client.lua change is outside create_client. check.sh passes. --- .agents/skills/verify-diff-lifecycle/covered.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 8829c1786..7bc64bc92 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -124,7 +124,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua f7c7fff32489cedd +file lua/claudecode/init.lua efec21ba11ec8201 block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -138,7 +138,7 @@ file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua file lua/claudecode/selection.lua 0fa651cc95e0af0d == lua/claudecode/server/client.lua -file lua/claudecode/server/client.lua 5789c6ff18045f9f +file lua/claudecode/server/client.lua baa6fa0846824ec9 block create_client 647b7523dea26af6 == lua/claudecode/server/frame.lua file lua/claudecode/server/frame.lua 3c50766875878d56 From 96731609c9ff5577e0398aa50252d7bfdbe7106d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 23:16:46 +0000 Subject: [PATCH 23/35] fix(mention): give each queued mention a deadline that no handshake moves A queued @ mention now has a deadline, connection_timeout + connection_wait_delay ms after its send. One timer clears the mentions whose deadline passed with an error, whatever clients do. Before, each handshake stopped the connection timeout, so clients that connected and left without overlap held a mention with no error, and the bound on the wait for a ready client ran only while a client was connected. The error says "Send timeout" when a Claude is connected, and keeps "Connection timeout" when none is. --- lua/claudecode/README.md | 2 +- lua/claudecode/init.lua | 90 ++++---- lua/claudecode/types.lua | 3 +- tests/unit/at_mention_edge_cases_spec.lua | 2 +- tests/unit/mention_queue_spec.lua | 266 ++++++++++++++++------ 5 files changed, 243 insertions(+), 120 deletions(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 340b206da..a6b1bd1e2 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -111,7 +111,7 @@ Module map (direct files): - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] - A mention sent while disconnected goes to the queue with the same absolute path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] -- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. The broadcast then reaches every connected client, also one that connected later and is still in its own wait. When clients keep connecting and leaving so that none stays connected that long, the queue is cleared with the connection timeout error once the first wait for a ready client lasted `connection_timeout + connection_wait_delay` ms [init.lua:flush_mention_queue]. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] +- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. The broadcast then reaches every connected client, also one that connected later and is still in its own wait. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. Each mention has a deadline, `connection_timeout + connection_wait_delay` ms after its send, and no handshake or disconnect moves it. At its deadline, a mention still in the queue is cleared with the error "Connection timeout - clearing N queued @ mentions", or with "Send timeout - clearing N queued @ mentions that the connected Claude did not take in time" while a client is connected [init.lua:expire_mentions]. A client that completes its handshake less than `connection_timeout` ms after the send, and stays connected, is ready before the deadline. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 94d7ad391..c853cfeb5 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -80,9 +80,6 @@ function M.is_claude_connected() end end --- vim.loop.now() when a flush first had to wait for a ready client, until a mention goes out or the queue is cleared -local ready_wait_since - ---@return number|nil wait Milliseconds until some connected client completed its handshake connection_wait_delay ms --- ago: 0 when one did, nil when no connected client reports when it connected local function ms_until_a_client_is_ready() @@ -97,13 +94,15 @@ local function ms_until_a_client_is_ready() end -- The @ mention queue. A send that does not go out at once waits in M.state.mention_queue, oldest first. A mention --- leaves the queue only when a connected Claude took it, when its wait timed out with an error, or when the --- integration stops. While the queue holds a mention, one of two timers runs: +-- leaves the queue only when a connected Claude took it, when its deadline passed, or when the integration stops. +-- While the queue holds a mention, M.state.connection_timer runs, and M.state.mention_timer too while Claude is +-- connected: -- - M.state.mention_timer is the next flush: 50 ms after a send, connection_wait_delay ms after Claude's handshake, -- or 25 ms after the previous mention went out. A flush sends only the oldest mention, so the order holds, and only -- once some connected client completed its handshake connection_wait_delay ms ago. --- - M.state.connection_timer runs while the mentions wait for Claude to connect. After connection_timeout ms it clears --- them with an error. Claude's handshake stops it, and a flush that finds no connected Claude starts it. +-- - M.state.connection_timer clears each mention with an error at its deadline, connection_timeout + +-- connection_wait_delay ms after its send. No handshake or disconnect moves a deadline. A Claude that completes its +-- handshake less than connection_timeout ms after the send, and stays connected, is ready before the deadline. -- A timer's callback runs on the main loop and does nothing once its timer was stopped or replaced, so a stop cancels -- every pending flush and timeout. local debounce_delay = 50 -- ms from a send to its flush, so a burst of sends goes out as one batch @@ -152,28 +151,40 @@ local function clear_mention_queue() logger.debug("queue", "Clearing " .. #queue .. " queued @ mentions") end M.state.mention_queue = {} - ready_wait_since = nil stop_timer("mention_timer") stop_timer("connection_timer") end local schedule_mention_flush ----Start the timeout of the mentions that wait for Claude to connect, unless it runs already. -local function start_connection_timeout_if_needed() - if M.state.connection_timer then - return +---Clear the queued mentions whose deadline passed, with an error, then wait for the deadline of the oldest one left. +---The connection_timer callback: the timer may fire for a mention that went out since, and then clears nothing. +local function expire_mentions() + local queue = mention_queue() + local now = vim.loop.now() + local expired = 0 + -- Deadlines grow with the queue order, so the mentions whose deadline passed are the oldest ones. + while expired < #queue and queue[expired + 1].deadline <= now do + expired = expired + 1 end - start_timer("connection_timer", M.state.config.connection_timeout, function() - local queue = mention_queue() - if #queue > 0 and not M.is_claude_connected() then - logger.error("queue", "Connection timeout - clearing " .. #queue .. " queued @ mentions") - clear_mention_queue() + if expired > 0 then + if M.is_claude_connected() then + logger.error( + "queue", + "Send timeout - clearing " .. expired .. " queued @ mentions that the connected Claude did not take in time" + ) else - -- Claude completed its handshake, and the flush that the handshake schedules has not run yet. - schedule_mention_flush(M.state.config.connection_wait_delay) + logger.error("queue", "Connection timeout - clearing " .. expired .. " queued @ mentions") end - end) + for _ = 1, expired do + table.remove(queue, 1) + end + end + if #queue == 0 then + clear_mention_queue() + else + start_timer("connection_timer", queue[1].deadline - now, expire_mentions) + end end ---A server.broadcast callback that shows an error when no write of the mention succeeded: the connection failed after @@ -202,14 +213,6 @@ local function flush_mention_queue() -- then reaches every connected client, also one that connected later. Another client's handshake cannot postpone it. local wait = ms_until_a_client_is_ready() if wait and wait > 0 then - -- Clients that keep connecting and leaving before any of them is ready must not hold the mentions forever. - ready_wait_since = ready_wait_since or vim.loop.now() - local limit = M.state.config.connection_timeout + M.state.config.connection_wait_delay - if vim.loop.now() - ready_wait_since >= limit then - logger.error("queue", "Connection timeout - clearing " .. #queue .. " queued @ mentions") - clear_mention_queue() - return - end start_timer("mention_timer", wait, flush_mention_queue) return end @@ -218,31 +221,24 @@ local function flush_mention_queue() local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line } if not server.broadcast("at_mentioned", params, report_unwritten("queue", mention.file_path)) then -- No connected client took the frame, as Claude went away since this flush was scheduled. The mention waits for - -- Claude's next handshake, or times out with an error. + -- Claude's next handshake, or for its deadline. logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path) - start_connection_timeout_if_needed() return end table.remove(queue, 1) - ready_wait_since = nil logger.debug("queue", "Sent queued @ mention: " .. mention.file_path) schedule_mention_flush(inter_message_delay) end ----Keep the queue moving. While Claude is connected, a flush is due: in delay ms, unless one is due already. Otherwise ----the connection timeout runs. An empty queue stops both timers. +---Keep the queue moving. While Claude is connected, a flush is due: in delay ms, unless one is due already. An empty +---queue stops both timers. ---@param delay number Milliseconds to the flush when Claude is connected and no flush is due yet schedule_mention_flush = function(delay) if #mention_queue() == 0 then stop_timer("mention_timer") stop_timer("connection_timer") - elseif M.is_claude_connected() then - stop_timer("connection_timer") - if not M.state.mention_timer then - start_timer("mention_timer", delay, flush_mention_queue) - end - else - start_connection_timeout_if_needed() + elseif M.is_claude_connected() and not M.state.mention_timer then + start_timer("mention_timer", delay, flush_mention_queue) end end @@ -252,14 +248,20 @@ end ---@param end_line number|nil Optional end line local function queue_mention(file_path, start_line, end_line) local queue = mention_queue() - table.insert(queue, { file_path = file_path, start_line = start_line, end_line = end_line }) + -- Claude has connection_timeout ms to complete its handshake, and connection_wait_delay ms after it to get ready. + local timeout = M.state.config.connection_timeout + M.state.config.connection_wait_delay + local deadline = vim.loop.now() + timeout + table.insert(queue, { file_path = file_path, start_line = start_line, end_line = end_line, deadline = deadline }) logger.debug("queue", "Queued @ mention: " .. file_path .. " (queue size: " .. #queue .. ")") + if not M.state.connection_timer then + start_timer("connection_timer", timeout, expire_mentions) + end schedule_mention_flush(debounce_delay) end ---Process the mention queue. Claude completed its WebSocket handshake when from_new_connection is true (the server's ----on_open callback): the handshake stops the connection timeout, and a flush is due in connection_wait_delay ms ----unless one is due already. Without the argument, the flush runs now. +---on_open callback): a flush is due in connection_wait_delay ms unless one is due already. Without the argument, the +---flush runs now. ---Either way, a flush sends the oldest mention only once some connected client has been connected for ---connection_wait_delay ms, and otherwise waits for that moment (see flush_mention_queue). ---@param from_new_connection boolean|nil true after a handshake; otherwise the queue flushes now @@ -1379,7 +1381,7 @@ function M._broadcast_at_mention(file_path, start_line, end_line) queue_mention(formatted_path, start_line, end_line) -- Always return success since we're queuing the message. The queue sends it to a connected Claude, or shows an - -- error when Claude does not connect within connection_timeout. + -- error when no Claude took it by its deadline, connection_timeout + connection_wait_delay ms after the send. return true, nil, sent end diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 865f4e8fe..27d792550 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -59,6 +59,7 @@ ---@field file_path string The absolute file path to mention ---@field start_line number? Optional start line (0-indexed for Claude compatibility) ---@field end_line number? Optional end line (0-indexed for Claude compatibility) +---@field deadline number When the queue clears the mention with an error (vim.loop.now() time) -- Payload delivered as `data` to a `User ClaudeCodeSendComplete` autocmd. Fired -- once per file, synchronously, when a send is accepted while Claude is connected. @@ -151,7 +152,7 @@ ---@field initialized boolean ---@field mention_queue ClaudeCodeMention[] ---@field mention_timer uv.uv_timer_t? -- the next flush of the mention queue (compatible with vim.loop timer) ----@field connection_timer uv.uv_timer_t? -- the timeout of the mentions that wait for Claude to connect +---@field connection_timer uv.uv_timer_t? -- the deadline of the oldest queued mention -- This module only defines types, no runtime functionality return {} diff --git a/tests/unit/at_mention_edge_cases_spec.lua b/tests/unit/at_mention_edge_cases_spec.lua index 1684822e4..c9c328f89 100644 --- a/tests/unit/at_mention_edge_cases_spec.lua +++ b/tests/unit/at_mention_edge_cases_spec.lua @@ -204,7 +204,7 @@ describe("At Mention Edge Cases", function() end, } init_module.state.server = { broadcast = function() end } - init_module.state.config = { connection_timeout = 10000 } + init_module.state.config = { connection_timeout = 10000, connection_wait_delay = 600 } init_module.state.mention_queue = {} init_module.is_claude_connected = function() return false diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 1e56205f6..3e3a217ff 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -25,7 +25,7 @@ describe("The @ mention queue", function() "claudecode.server.handshake", } local saved = {} - local loop, errors, claudecode, frame, terminal_opens + local loop, errors, error_times, claudecode, frame, terminal_opens -- A fake libuv loop. Like libuv, it fires every timer that is due in one phase, in the order they were started, -- and then the main loop runs every scheduled callback before time moves on. A callback that raises is recorded, @@ -108,6 +108,7 @@ describe("The @ mention queue", function() before_each(function() loop = { now = 0, timers = {}, scheduled = {}, lua_errors = {} } errors = {} + error_times = {} saved = { new_timer = vim.loop.new_timer, now = vim.loop.now, @@ -155,6 +156,7 @@ describe("The @ mention queue", function() parts[i] = tostring(select(i, ...)) end table.insert(errors, table.concat(parts, " ")) + table.insert(error_times, loop.now) end package.loaded["claudecode.logger"] = { setup = function() end, @@ -314,6 +316,28 @@ describe("The @ mention queue", function() assert.same({}, loop.lua_errors) end + ---From now until `till` ms from now, a client connects every `period` ms and completes its handshake. Each one + ---leaves (EOF) `life` ms after it connected. + ---@return table[] clients The clients that connected so far + local function churn(period, life, till) + local clients = {} + for start = 0, till - 1, period do + at(start, function() + local claude = claude_connects() + claude.handshake() + table.insert(clients, claude) + at(life, claude.drop) + end) + end + return clients + end + + local function assert_nothing_received(clients) + for _, claude in ipairs(clients) do + assert.same({}, claude.received()) + end + end + it("control: mentions queued while Claude starts reach Claude once it connects, in order, each once", function() send("/project/a.lua") send("/project/b.lua") @@ -342,7 +366,7 @@ describe("The @ mention queue", function() assert.same({}, errors) end) - it("A: Claude's handshake just before connection_timeout stops the timeout", function() + it("A: a Claude whose handshake comes 9.9 s after the send is ready at 10.5 s and gets the mention", function() send("/project/a.lua") run(9900) local claude = claude_connects() @@ -351,8 +375,29 @@ describe("The @ mention queue", function() assert.same({ "a.lua" }, names(claude.received())) assert.same({}, errors) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.connection_timer) end) + it( + "A, F4: a Claude whose handshake comes 10.3 s after the send is not ready by the deadline: you see an error", + function() + send("/project/a.lua") + run(10300) + local claude = claude_connects() + claude.handshake() + run(1500) + + assert.same({}, claude.received()) + assert.same({ 10600 }, error_times) + assert.matches( + "Send timeout %- clearing 1 queued @ mentions that the connected Claude did not take in time", + errors[1] + ) + assert.same({}, claudecode.state.mention_queue) + end + ) + it("H: Claude drops during the wait after its handshake: the next Claude gets the mention", function() send("/project/a.lua") local first = claude_connects() @@ -385,22 +430,19 @@ describe("The @ mention queue", function() assert.same({}, claudecode.state.mention_queue) end) - it("H: after a drop in the wait, the mention gets a whole connection_timeout for Claude to come back", function() + it("H: a handshake and a drop just before the deadline do not move it", function() send("/project/a.lua") run(9900) - local first = claude_connects() - first.handshake() + local claude = claude_connects() + claude.handshake() run(50) - first.drop() + claude.drop() run(2000) - assert.same({}, errors, "the timeout that ran before the handshake must not fire after it") - - local second = claude_connects() - second.handshake() - run(1500) - assert.same({ "a.lua" }, names(second.received())) - assert.same({}, errors) + assert.same({}, claude.received()) + assert.same({ 10600 }, error_times) + assert.matches("Connection timeout %- clearing 1 queued @ mentions", errors[1]) + assert.same({}, claudecode.state.mention_queue) end) it("control: a send in the tick after the handshake waits connection_wait_delay with the queued mentions", function() @@ -552,36 +594,46 @@ describe("The @ mention queue", function() assert.same({ "a.lua" }, names(claude.received())) end) - it("S: clients that keep connecting and leaving before any is ready do not hold a mention forever", function() - -- Each client lives 300 ms and a new one connects every 200 ms, for 20 s: one is always connected, and none is - -- connected for connection_wait_delay (600 ms). - local clients = { claude_connects() } - clients[1].handshake() + it("S: clients that overlap but each leave before they are ready do not hold a mention past its deadline", function() + -- A client connects every 200 ms and stays 300 ms, for 20 s: one is always connected, and none is connected for + -- connection_wait_delay (600 ms). The mention is sent while the first client is connected. + local clients = churn(200, 300, 20000) + run(1) send("/project/a.lua") - local error_at - for i = 2, 101 do - local new = claude_connects() - new.handshake() - clients[i] = new - run(100) - clients[i - 1].drop() - run(100) - if #errors > 0 and not error_at then - error_at = loop.now - end - end + run(20400) - assert.equals(1, #errors) - assert.matches("Connection timeout %- clearing 1 queued", errors[1]) - assert.is_true(error_at >= 10000 and error_at <= 11500, "the error came at " .. tostring(error_at) .. " ms") + assert.same({ 10601 }, error_times) + assert.matches("Send timeout %- clearing 1 queued", errors[1]) assert.same({}, claudecode.state.mention_queue) - for _, claude in ipairs(clients) do - assert.same({}, claude.received()) - end + assert_nothing_received(clients) assert_no_lua_error() end) - it("S, control: the bound on the wait for a ready Claude starts again after a mention went out", function() + -- A client connects every `period` ms and stays `life` ms, for 30 s: never two at once, and none for + -- connection_wait_delay (600 ms). The mention is sent while the first client is connected, after its handshake. + for _, case in ipairs({ { 1000, 300 }, { 700, 300 }, { 650, 500 }, { 900, 100 } }) do + local period, life = case[1], case[2] + it( + ("S: clients that connect every %d ms and stay %d ms do not hold a mention past its deadline"):format( + period, + life + ), + function() + local clients = churn(period, life, 30000) + run(1) + send("/project/a.lua") + run(30000) + + assert.same({ 10601 }, error_times) + assert.matches("clearing 1 queued", errors[1]) + assert.same({}, claudecode.state.mention_queue) + assert_nothing_received(clients) + assert_no_lua_error() + end + ) + end + + it("control: a mention sent 12 s after the last one went out gets its own deadline", function() local first = claude_connects() first.handshake() send("/project/a.lua") @@ -597,7 +649,7 @@ describe("The @ mention queue", function() assert.same({}, errors) end) - it("S, control: the bound on the wait for a ready Claude starts again after a stop", function() + it("C, control: a mention sent 12 s after a stop and a start gets its own deadline", function() local first = claude_connects() first.handshake() send("/project/a.lua") @@ -647,7 +699,9 @@ describe("The @ mention queue", function() claude.handshake() run(100) claudecode.stop() - run(1500) + assert.is_nil(claudecode.state.mention_timer) + assert.is_nil(claudecode.state.connection_timer) + run(11000) assert_no_lua_error() assert.same({}, claude.received()) @@ -669,57 +723,103 @@ describe("The @ mention queue", function() assert.same({}, second.received()) end) - it("C: a stop cancels a connection timeout whose callback already waits for the main loop", function() + it("C: a stop cancels a deadline whose callback already waits for the main loop", function() send("/project/old.lua") - -- In the timer phase in which the timeout fires, before its callback runs: stop, start, and send again. - at(10000, function() + -- In the timer phase in which the deadline passes, before its callback runs: stop, start, and send again. + at(10600, function() claudecode.stop() assert(claudecode.start(false)) send("/project/new.lua") end) - run(10000) - assert.same({}, errors, "the old timeout must not clear the mention sent after the stop") + run(10600) + assert.same({}, errors, "the old deadline must not clear the mention sent after the stop") - run(10100) - assert.equals(1, #errors) + run(10700) + assert.same({ 21200 }, error_times) assert.matches("clearing 1 queued", errors[1]) assert_no_lua_error() end) - it("B: :ClaudeCodeStop resets the connection timeout", function() - send("/project/old.lua") + it( + "B, C: :ClaudeCodeStop drops the deadline of the old mention: a mention sent after a restart gets its own", + function() + send("/project/old.lua") + run(5000) + claudecode.stop() + assert(claudecode.start(false)) + send("/project/new.lua") + run(10599) + assert.same({}, errors, "the old deadline at 10.6 s must not clear the mention sent after the restart") + + run(1) + assert.same({ 15600 }, error_times) + assert.matches("Connection timeout %- clearing 1 queued", errors[1]) + assert_no_lua_error() + end + ) + + it("B: mentions sent 5 s apart with no Claude each time out at their own deadline", function() + send("/project/first.lua") run(5000) - claudecode.stop() - assert(claudecode.start(false)) - send("/project/new.lua") - run(9000) - assert.same({}, errors, "a mention sent after a restart gets the whole connection_timeout") + send("/project/second.lua") + run(5000) + send("/project/third.lua") + run(599) + assert.same({}, errors) - run(1100) - assert.equals(1, #errors) - assert.matches("Connection timeout", errors[1]) + run(1) + assert.matches("Connection timeout %- clearing 1 queued", errors[1]) + assert.equals(2, #claudecode.state.mention_queue) + run(10000) + + assert.same({ 10600, 15600, 20600 }, error_times) + assert.matches("Connection timeout %- clearing 1 queued", errors[2]) + assert.matches("Connection timeout %- clearing 1 queued", errors[3]) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.connection_timer) end) - it("B, control: the connection timeout runs from the first queued mention, not from the latest", function() + it("B: of two mentions sent 5 s apart, a Claude ready between their deadlines gets the second", function() send("/project/first.lua") - run(8000) + run(5000) send("/project/second.lua") - run(1900) - assert.same({}, errors) + run(6000) + local claude = claude_connects() + claude.handshake() + run(10000) - run(200) - assert.equals(1, #errors) - assert.matches("clearing 2 queued", errors[1]) + assert.same({ "second.lua" }, names(claude.received())) + assert.same({ 10600 }, error_times) assert.same({}, claudecode.state.mention_queue) end) + it("B, control: the deadline of a mention that went out clears no later mention", function() + send("/project/first.lua") + run(5000) + send("/project/second.lua") + run(1000) + local first = claude_connects() + first.handshake() + -- The first mention goes out once Claude is ready, at 6.6 s. Claude leaves before the second one goes out. + run(610) + first.drop() + run(5390) + local second = claude_connects() + second.handshake() + run(1000) + + assert.same({ "first.lua" }, names(first.received())) + assert.same({ "second.lua" }, names(second.received())) + assert.same({}, errors) + end) + it("B: after a connection timeout, a later mention times out with an error too", function() send("/project/first.lua") - run(10100) + run(10700) assert.equals(1, #errors) send("/project/second.lua") - run(10100) + run(10700) assert.equals(2, #errors) assert.matches("Connection timeout", errors[2]) @@ -791,16 +891,36 @@ describe("The @ mention queue", function() assert.same({}, errors) end) - it("D: a connection timeout that fires as Claude completes its handshake leaves the queue to that Claude", function() - send("/project/a.lua") - run(9000) + it( + "D: a Claude that gets ready just at the deadline is too late: you see one error, and Claude gets nothing", + function() + send("/project/a.lua") + run(9000) + local claude = claude_connects() + claude.upgrade() + -- The handshake completes at 10 s, so the flush for this Claude is due at the deadline, 10.6 s. + at(1000, claude.handshake_written) + run(2000) + + assert.same({}, claude.received()) + assert.same({ 10600 }, error_times) + assert.matches("Send timeout %- clearing 1 queued", errors[1]) + assert_no_lua_error() + end + ) + + it("E: a send while a flush is due does not postpone that flush", function() local claude = claude_connects() - claude.upgrade() - -- The 101 write completes in the timer phase in which connection_timeout fires, before the main loop runs. - at(1000, claude.handshake_written) - run(2000) + claude.handshake() + run(1000) + send("/project/first.lua") + run(40) + send("/project/second.lua") + run(15) + assert.same({ "first.lua" }, names(claude.received()), "the flush was due 50 ms after the first send") - assert.same({ "a.lua" }, names(claude.received())) + run(100) + assert.same({ "first.lua", "second.lua" }, names(claude.received())) assert.same({}, errors) end) From a063770efbcca9c0b0df5c8241d26a01ec884d24 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 23:32:25 +0000 Subject: [PATCH 24/35] test(mention): a flush that the deadline cancelled does not send a later mention early --- tests/unit/mention_queue_spec.lua | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 3e3a217ff..b170dc466 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -909,6 +909,28 @@ describe("The @ mention queue", function() end ) + it("C: a flush that the deadline cancelled does not send a mention sent right after the deadline", function() + send("/project/a.lua") + run(9000) + local claude = claude_connects() + claude.upgrade() + -- The handshake completes at 10 s, so the flush for this Claude is due at the deadline of a.lua, 10.6 s. On the + -- main loop, after the deadline's callback cancelled that flush and before the flush's callback runs, you send b.lua. + at(1000, claude.handshake_written) + at(1600, function() + vim.schedule(function() + send("/project/b.lua") + end) + end) + run(1600) + assert.same({}, claude.received(), "b.lua waits the 50 ms after its send") + + run(100) + assert.same({ "b.lua" }, names(claude.received())) + assert.same({ 10600 }, error_times) + assert_no_lua_error() + end) + it("E: a send while a flush is due does not postpone that flush", function() local claude = claude_connects() claude.handshake() From 46add4ea887744b59c3ff86b3ccde92800864f6b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 23:34:51 +0000 Subject: [PATCH 25/35] test(mention): fail a run whose timers keep firing without letting time pass --- tests/unit/mention_queue_spec.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index b170dc466..8bdbb53c2 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -65,7 +65,10 @@ describe("The @ mention queue", function() ---Let `ms` milliseconds pass. local function run(ms) local stop_at = loop.now + ms + local phases = 0 while true do + phases = phases + 1 + assert(phases < 100000, "the timers keep firing without letting time pass") while #loop.scheduled > 0 do protect(table.remove(loop.scheduled, 1)) end From 3539f86b74f3be365cc472012f60c80ff774fb04 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 23:37:05 +0000 Subject: [PATCH 26/35] test(mention): fail fast when the timers keep firing at one moment --- tests/unit/mention_queue_spec.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index 8bdbb53c2..a92747eaa 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -65,10 +65,8 @@ describe("The @ mention queue", function() ---Let `ms` milliseconds pass. local function run(ms) local stop_at = loop.now + ms - local phases = 0 + local phases_now = 0 while true do - phases = phases + 1 - assert(phases < 100000, "the timers keep firing without letting time pass") while #loop.scheduled > 0 do protect(table.remove(loop.scheduled, 1)) end @@ -81,6 +79,8 @@ describe("The @ mention queue", function() if not due then break end + phases_now = due == loop.now and phases_now + 1 or 0 + assert(phases_now < 1000, "the timers keep firing without letting time pass") loop.now = due local firing = {} for _, timer in ipairs(loop.timers) do From cde121b47205b486135b6899d4d048be347b8030 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 23:40:09 +0000 Subject: [PATCH 27/35] chore(skills): re-record the diff model after the mention deadline change --- .agents/skills/verify-diff-lifecycle/covered.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 7bc64bc92..cf5a7f6ce 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -124,7 +124,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua efec21ba11ec8201 +file lua/claudecode/init.lua 0413b50d6b493719 block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -210,7 +210,7 @@ file lua/claudecode/tools/open_file.lua 6c7154d5427af9df == lua/claudecode/tools/save_document.lua file lua/claudecode/tools/save_document.lua df68d71e47300988 == lua/claudecode/types.lua -file lua/claudecode/types.lua beb7511d7913a58f +file lua/claudecode/types.lua a18055a0a83540c5 == lua/claudecode/utils.lua file lua/claudecode/utils.lua 97fae24d0ce4e33b == lua/claudecode/visual_commands.lua From 3391909ba3e3495aa49c6005840c17e4a10b5981 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 07:27:55 +0000 Subject: [PATCH 28/35] chore(skills): re-record the diff model after merging main into the mention deadline change --- .agents/skills/verify-diff-lifecycle/covered.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 7c8a06e5c..b4db3c2f2 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -125,7 +125,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua f7c7fff32489cedd +file lua/claudecode/init.lua 0413b50d6b493719 block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() @@ -139,7 +139,7 @@ file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua file lua/claudecode/selection.lua 4d6678af786fa69f == lua/claudecode/server/client.lua -file lua/claudecode/server/client.lua 4304e6496b5c4f32 +file lua/claudecode/server/client.lua 28bb7595d23d3985 block create_client 24fa2a266b76e6ad == lua/claudecode/server/frame.lua file lua/claudecode/server/frame.lua 3c50766875878d56 @@ -211,7 +211,7 @@ file lua/claudecode/tools/open_file.lua 6c7154d5427af9df == lua/claudecode/tools/save_document.lua file lua/claudecode/tools/save_document.lua df68d71e47300988 == lua/claudecode/types.lua -file lua/claudecode/types.lua 92f8f6ccda38e8c6 +file lua/claudecode/types.lua 20a068449e575b55 == lua/claudecode/utils.lua file lua/claudecode/utils.lua 97fae24d0ce4e33b == lua/claudecode/visual_commands.lua From cefe25868978804633cdbc606c9eddda91ddd4b0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 07:40:33 +0000 Subject: [PATCH 29/35] docs(fixtures): name the mention deadline in the issue-70 notes --- fixtures/issue-70/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fixtures/issue-70/README.md b/fixtures/issue-70/README.md index 2b8463126..77ae7d74b 100644 --- a/fixtures/issue-70/README.md +++ b/fixtures/issue-70/README.md @@ -7,10 +7,11 @@ ## The one fact behind every report `:ClaudeCodeSend` (and the file/buffer/tree senders) call `send_at_mention`. When -Claude is not connected, the mention is **queued** and a `connection_timeout` -timer (default **10s**, `lua/claudecode/init.lua`) is armed. If Claude has not -opened a WebSocket back to the plugin's server by then, the queue is cleared with -the error above (`start_connection_timeout_if_needed`). +Claude is not connected, the mention is **queued** with a deadline, +`connection_timeout + connection_wait_delay` after the send (default **10.6s**, +`lua/claudecode/init.lua`). If no Claude has opened a WebSocket back to the +plugin's server and taken the mention by then, the mention is cleared with the +error above (`expire_mentions`). So the bug is never really "send is broken" — it is always **"the Claude CLI that the plugin launched never connected back to the plugin's WebSocket server."** The From 5f35b11a554931c16d77f553795208d4dba6f71f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 07:48:14 +0000 Subject: [PATCH 30/35] fix(server): stop a large frame at a piece that libuv refuses at once Codex round 4 on #480: write() returned only the last piece's result, so a refused earlier piece was hidden when a later one was queued, and the peer got a cut frame while send_message saw success. Return the first refusal and write no later piece. --- lua/claudecode/server/client.lua | 8 +++++++- .../unit/server/connection_lifecycle_spec.lua | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/server/client.lua b/lua/claudecode/server/client.lua index de5df421c..9b313cd10 100644 --- a/lua/claudecode/server/client.lua +++ b/lua/claudecode/server/client.lua @@ -72,7 +72,8 @@ local WRITE_PIECE_BYTES = 1024 * 1024 ---Write to the socket of a client. Every write goes through here, so `queued_bytes` counts them all: the bytes still in ---libuv's write queue are `queued_bytes - drained_bytes`, and `is_client_reading` tells a stalled queue from a slow one. ---A frame of more than `WRITE_PIECE_BYTES` is queued in pieces. libuv finishes the requests of a socket in order, so the ----callback of the last piece says that the whole frame is written, or that a piece failed. +---callback of the last piece says that the whole frame is written, or that a piece failed. A piece that libuv refuses at +---once ends the frame there. ---@param client WebSocketClient ---@param data string ---@param callback function? Optional callback: function(err) @@ -87,6 +88,11 @@ local function write(client, data, callback) local last_piece = first + WRITE_PIECE_BYTES > #data queued, err = client.tcp_handle:write(data:sub(first, first + WRITE_PIECE_BYTES - 1), last_piece and callback or nil) + if not queued and err then + -- The frame is cut short, and the callback rides on the last piece: no later piece may follow, and the caller + -- hears the error here. + return nil, err + end end return queued, err end diff --git a/tests/unit/server/connection_lifecycle_spec.lua b/tests/unit/server/connection_lifecycle_spec.lua index b7f6996f3..5106ebc14 100644 --- a/tests/unit/server/connection_lifecycle_spec.lua +++ b/tests/unit/server/connection_lifecycle_spec.lua @@ -1186,6 +1186,25 @@ describe("The lifecycle of a WebSocket connection", function() end) end + it("stops at a piece that libuv refuses at once, and passes its error to the callback once", function() + local client, writes, handle = connect_and_upgrade() + local queue_write, refused = handle.write, false + function handle:write(data, callback) + if not refused then -- the first piece: libuv refuses it, and never runs its callback + refused = true + return nil, "EPIPE: broken pipe" + end + return queue_write(self, data, callback) + end + local callback = spy.new(function() end) + + tcp.send_to_client(server, client.id, string.rep("x", 3 * piece), callback) + + assert.equals(1, #writes, "a piece followed the refused one") + assert.spy(callback).was_called(1) + assert.spy(callback).was_called_with("EPIPE: broken pipe") + end) + it("queues a message of one piece or less as one write", function() local client, writes = connect_and_upgrade() From cf824cd9ba80c41bc01e1a580cae136d3ff68aab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 08:26:22 +0000 Subject: [PATCH 31/35] chore(skills): re-record the diff model after the refused-piece fix --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index b4db3c2f2..bb7c9d617 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -139,7 +139,7 @@ file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua file lua/claudecode/selection.lua 4d6678af786fa69f == lua/claudecode/server/client.lua -file lua/claudecode/server/client.lua 28bb7595d23d3985 +file lua/claudecode/server/client.lua 83fe08a0a9e3b478 block create_client 24fa2a266b76e6ad == lua/claudecode/server/frame.lua file lua/claudecode/server/frame.lua 3c50766875878d56 From ad864db3b2b13a64a9f2e4313cb1670f0aad120e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 09:11:36 +0000 Subject: [PATCH 32/35] fix(mention): let a ready Claude drain the queue past the deadline The deadline per mention cleared a long queue while a ready Claude was taking it 25 ms apart: with 100 mentions and a handshake at 8.6 s, 57 went out and the rest were cleared with "Send timeout". A deadline now clears mentions only while no ready client is connected. While one is, the queue drains to it, and a flush that finds no ready client clears the mentions whose deadline passed meanwhile. The connection_timeout comment says what the setting does now. --- dev-config.lua | 2 +- lua/claudecode/README.md | 2 +- lua/claudecode/config.lua | 4 +- lua/claudecode/init.lua | 34 +++++--- tests/unit/mention_queue_spec.lua | 130 +++++++++++++++++++++++------- 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/dev-config.lua b/dev-config.lua index 18b1ad4f7..2427d123d 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -76,7 +76,7 @@ return { -- Connection Management -- connection_wait_delay = 600, -- Wait time after Claude's handshake before sending queued @ mentions (ms) - -- connection_timeout = 10000, -- Max time queued @ mentions wait for Claude Code to connect (ms) + -- connection_timeout = 10000, -- Time Claude Code has to connect before queued @ mentions time out (ms) -- queue_timeout = 5000, -- Unused, still accepted: queued @ mentions wait up to connection_timeout -- Diff Integration diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 479964460..91005e71c 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -111,7 +111,7 @@ Module map (direct files): - Line numbers sent to Claude are 0-indexed. `:ClaudeCodeAdd` converts the 1-indexed input [init.lua:M._create_commands] - A mention sent while disconnected goes to the queue with the same absolute path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] -- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. The broadcast then reaches every connected client, also one that connected later and is still in its own wait. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. Each mention has a deadline, `connection_timeout + connection_wait_delay` ms after its send, and no handshake or disconnect moves it. At its deadline, a mention still in the queue is cleared with the error "Connection timeout - clearing N queued @ mentions", or with "Send timeout - clearing N queued @ mentions that the connected Claude did not take in time" while a client is connected [init.lua:expire_mentions]. A client that completes its handshake less than `connection_timeout` ms after the send, and stays connected, is ready before the deadline. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] +- The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention goes out only once some connected client completed its handshake `connection_wait_delay` ms ago, so another client's handshake does not postpone it [init.lua:ms_until_a_client_is_ready]. The broadcast then reaches every connected client, also one that connected later and is still in its own wait. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. Each mention has a deadline, `connection_timeout + connection_wait_delay` ms after its send, and no handshake or disconnect moves it. At its deadline, a mention still in the queue is cleared with the error "Connection timeout - clearing N queued @ mentions", or with "Send timeout - clearing N queued @ mentions that the connected Claude did not take in time" while a client that is not ready yet is connected [init.lua:expire_mentions]. While a ready client is connected, no deadline clears a mention: the queue drains to it, 25 ms apart. When that client leaves, the next flush clears the mentions whose deadline passed meanwhile [init.lua:flush_mention_queue]. A client that completes its handshake less than `connection_timeout` ms after the send, and stays connected, is ready before the deadline and gets every queued mention. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] - Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. The held text can also change while you are in the Claude terminal (a reload, a plugin's edit), where `TextChanged` does not fire for its buffer: a listener from `nvim_buf_attach` on that buffer sends the file's cursor then [selection.lua:watch_selection_buffer]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 6c8771f7d..71a9ba5f4 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -18,7 +18,9 @@ M.defaults = { focus_after_send = false, visual_demotion_delay_ms = 50, -- Milliseconds to wait before demoting a visual selection connection_wait_delay = 600, -- Milliseconds to wait after connection before sending queued @ mentions - connection_timeout = 10000, -- Maximum time queued @ mentions wait for Claude Code to connect (milliseconds) + -- Milliseconds Claude Code has to connect for queued @ mentions. Each one leaves with an error connection_timeout + + -- connection_wait_delay ms after its send, unless a ready Claude takes the queue first. + connection_timeout = 10000, queue_timeout = 5000, -- Unused, kept so that configs that set it stay valid (connection_timeout bounds the wait) diff_opts = { layout = "vertical", diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index c853cfeb5..607ab5e2c 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -94,15 +94,17 @@ local function ms_until_a_client_is_ready() end -- The @ mention queue. A send that does not go out at once waits in M.state.mention_queue, oldest first. A mention --- leaves the queue only when a connected Claude took it, when its deadline passed, or when the integration stops. --- While the queue holds a mention, M.state.connection_timer runs, and M.state.mention_timer too while Claude is --- connected: --- - M.state.mention_timer is the next flush: 50 ms after a send, connection_wait_delay ms after Claude's handshake, --- or 25 ms after the previous mention went out. A flush sends only the oldest mention, so the order holds, and only --- once some connected client completed its handshake connection_wait_delay ms ago. --- - M.state.connection_timer clears each mention with an error at its deadline, connection_timeout + --- connection_wait_delay ms after its send. No handshake or disconnect moves a deadline. A Claude that completes its +-- leaves the queue only when a connected Claude took it, when its deadline passed while no client was ready, or when +-- the integration stops. Two timers move it on: +-- - M.state.mention_timer is the next flush, while Claude is connected: 50 ms after a send, connection_wait_delay ms +-- after Claude's handshake, or 25 ms after the previous mention went out. A flush sends only the oldest mention, so +-- the order holds, and only once some connected client is ready: it completed its handshake connection_wait_delay +-- ms ago. +-- - M.state.connection_timer clears the mentions whose deadline passed, connection_timeout + connection_wait_delay ms +-- after their send, with an error. No handshake or disconnect moves a deadline. A Claude that completes its -- handshake less than connection_timeout ms after the send, and stays connected, is ready before the deadline. +-- While a ready client is connected, no deadline clears a mention: the queue drains to it, 25 ms apart. A flush that +-- finds no ready client clears the mentions whose deadline passed meanwhile, and starts the timer again. -- A timer's callback runs on the main loop and does nothing once its timer was stopped or replaced, so a stop cancels -- every pending flush and timeout. local debounce_delay = 50 -- ms from a send to its flush, so a burst of sends goes out as one batch @@ -159,7 +161,12 @@ local schedule_mention_flush ---Clear the queued mentions whose deadline passed, with an error, then wait for the deadline of the oldest one left. ---The connection_timer callback: the timer may fire for a mention that went out since, and then clears nothing. +---While a ready client is connected, it clears nothing and stops: the queue drains to that client, and a flush that +---finds no ready client calls it again. local function expire_mentions() + if ms_until_a_client_is_ready() == 0 then + return + end local queue = mention_queue() local now = vim.loop.now() local expired = 0 @@ -213,7 +220,11 @@ local function flush_mention_queue() -- then reaches every connected client, also one that connected later. Another client's handshake cannot postpone it. local wait = ms_until_a_client_is_ready() if wait and wait > 0 then - start_timer("mention_timer", wait, flush_mention_queue) + -- A ready client that took the queue may have left: the mentions whose deadline passed meanwhile leave now. + expire_mentions() + if #mention_queue() > 0 then + start_timer("mention_timer", wait, flush_mention_queue) + end return end @@ -221,8 +232,9 @@ local function flush_mention_queue() local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line } if not server.broadcast("at_mentioned", params, report_unwritten("queue", mention.file_path)) then -- No connected client took the frame, as Claude went away since this flush was scheduled. The mention waits for - -- Claude's next handshake, or for its deadline. + -- Claude's next handshake, or for its deadline. One that passed while a ready client took the queue leaves now. logger.debug("queue", "No connected client took queued @ mention: " .. mention.file_path) + expire_mentions() return end table.remove(queue, 1) @@ -1381,7 +1393,7 @@ function M._broadcast_at_mention(file_path, start_line, end_line) queue_mention(formatted_path, start_line, end_line) -- Always return success since we're queuing the message. The queue sends it to a connected Claude, or shows an - -- error when no Claude took it by its deadline, connection_timeout + connection_wait_delay ms after the send. + -- error when no ready Claude took it by its deadline, connection_timeout + connection_wait_delay ms after the send. return true, nil, sent end diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index a92747eaa..e22a1a3a2 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -894,43 +894,113 @@ describe("The @ mention queue", function() assert.same({}, errors) end) - it( - "D: a Claude that gets ready just at the deadline is too late: you see one error, and Claude gets nothing", - function() - send("/project/a.lua") - run(9000) - local claude = claude_connects() - claude.upgrade() - -- The handshake completes at 10 s, so the flush for this Claude is due at the deadline, 10.6 s. - at(1000, claude.handshake_written) - run(2000) - - assert.same({}, claude.received()) - assert.same({ 10600 }, error_times) - assert.matches("Send timeout %- clearing 1 queued", errors[1]) - assert_no_lua_error() - end - ) - - it("C: a flush that the deadline cancelled does not send a mention sent right after the deadline", function() + it("D: a Claude that gets ready just at the deadline gets the mention", function() send("/project/a.lua") run(9000) local claude = claude_connects() claude.upgrade() - -- The handshake completes at 10 s, so the flush for this Claude is due at the deadline of a.lua, 10.6 s. On the - -- main loop, after the deadline's callback cancelled that flush and before the flush's callback runs, you send b.lua. + -- The handshake completes at 10 s, so Claude is ready at the deadline, 10.6 s, and the flush is due then too. at(1000, claude.handshake_written) - at(1600, function() - vim.schedule(function() - send("/project/b.lua") - end) - end) - run(1600) - assert.same({}, claude.received(), "b.lua waits the 50 ms after its send") + run(2000) + + assert.same({ "a.lua" }, names(claude.received())) + assert.same({}, errors) + assert_no_lua_error() + end) + + ---Send `count` mentions at once, as a tree add of that many files does, and return their names in order. + local function send_many(count) + local sent = {} + for index = 1, count do + send("/project/f" .. index .. ".lua") + sent[index] = "f" .. index .. ".lua" + end + return sent + end + + for _, handshake_at in ipairs({ 8000, 9800 }) do + it( + ("F1: 100 mentions sent at once all reach a Claude whose handshake comes %d ms later"):format(handshake_at), + function() + local sent = send_many(100) + run(handshake_at) + local claude = claude_connects() + claude.handshake() + -- Claude is ready connection_wait_delay later, and the queue drains to it, 25 ms apart, past the deadline. + run(5000) + + assert.same(sent, names(claude.received())) + assert.same({}, errors) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.mention_timer) + assert.is_nil(claudecode.state.connection_timer) + assert_no_lua_error() + end + ) + end + + ---The count in the error that cleared the queue. + local function cleared_count(message) + return tonumber((assert(message:match("clearing (%d+) queued"), message))) + end + + it("F1: when a ready Claude leaves in the middle of the queue, the rest leave at their deadline", function() + send_many(100) + run(8000) + local claude = claude_connects() + claude.handshake() + run(1000) + claude.drop() + local got = #claude.received() + assert.is_true(got > 0 and got < 100, "Claude got " .. got) + run(5000) - run(100) - assert.same({ "b.lua" }, names(claude.received())) assert.same({ 10600 }, error_times) + assert.matches("Connection timeout", errors[1]) + assert.equals(100 - got, cleared_count(errors[1])) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.connection_timer) + end) + + it("F1: when a ready Claude leaves after the deadline, the rest leave at the next flush", function() + send_many(100) + run(9800) + local claude = claude_connects() + claude.handshake() + run(1200) + claude.drop() + local got = #claude.received() + assert.is_true(got > 0 and got < 100, "Claude got " .. got) + run(5000) + + assert.equals(1, #error_times) + assert.is_true(error_times[1] > 11000 and error_times[1] <= 11025, "the error came at " .. error_times[1]) + assert.matches("Connection timeout", errors[1]) + assert.equals(100 - got, cleared_count(errors[1])) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.connection_timer) + end) + + it("F1: when a ready Claude leaves after the deadline while another waits, the rest leave at once", function() + send_many(100) + run(9800) + local first = claude_connects() + first.handshake() + run(1100) + -- A second Claude connects at 10.9 s and is ready only at 11.5 s. + local second = claude_connects() + second.handshake() + run(100) + first.drop() + local got = #first.received() + assert.is_true(got > 0 and got < 100, "the first Claude got " .. got) + run(5000) + + assert.equals(1, #error_times) + assert.is_true(error_times[1] > 11000 and error_times[1] <= 11025, "the error came at " .. error_times[1]) + assert.matches("Send timeout", errors[1]) + assert.equals(100 - got, cleared_count(errors[1])) + assert.same({}, claudecode.state.mention_queue) assert_no_lua_error() end) From 4f1c2e2e3b29ea4eff8796584cb018f1756afa12 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 09:14:58 +0000 Subject: [PATCH 33/35] chore(skills): re-record the diff model after the drain rule --- .agents/skills/verify-diff-lifecycle/covered.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index bb7c9d617..3ca5d44a1 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -1,5 +1,5 @@ == lua/claudecode/config.lua -file lua/claudecode/config.lua 35f1bc960cb293e4 +file lua/claudecode/config.lua 3f804a98cec4446e == lua/claudecode/cwd.lua file lua/claudecode/cwd.lua 55d26ea362173afa == lua/claudecode/diff.lua @@ -125,7 +125,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua 0413b50d6b493719 +file lua/claudecode/init.lua 42b52ef17245b92c block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands() From e625545349e1ab3af8e2810603ada01aa283205d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 09:25:07 +0000 Subject: [PATCH 34/35] fix(mention): keep a flush due when the deadline spares the queue for 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. --- lua/claudecode/init.lua | 3 +++ tests/unit/mention_queue_spec.lua | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 607ab5e2c..11c96c9c4 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -165,6 +165,9 @@ local schedule_mention_flush ---finds no ready client calls it again. local function expire_mentions() if ms_until_a_client_is_ready() == 0 then + -- A flush must be due, so that the drain goes on, or finds the client gone and calls this again. The handshake's + -- own flush can come later than this: Neovim runs the scheduled callbacks in order, late when its loop was busy. + schedule_mention_flush(inter_message_delay) return end local queue = mention_queue() diff --git a/tests/unit/mention_queue_spec.lua b/tests/unit/mention_queue_spec.lua index e22a1a3a2..1b1a81384 100644 --- a/tests/unit/mention_queue_spec.lua +++ b/tests/unit/mention_queue_spec.lua @@ -1004,6 +1004,34 @@ describe("The @ mention queue", function() assert_no_lua_error() end) + it("F1: a ready Claude that leaves before its handshake callback ran does not strand the queue", function() + send("/project/a.lua") + run(10000) + -- Neovim runs the scheduled callbacks in order, late when its loop was busy: the handshake's on_open callback runs + -- only after the deadline, at which Claude is ready, and after Claude left. + local held = {} + local schedule = vim.schedule + vim.schedule = function(fn) + table.insert(held, fn) + end + local claude = claude_connects() + claude.handshake() + vim.schedule = schedule + run(610) + claude.drop() + for _, fn in ipairs(held) do + fn() + end + run(1000) + + assert.same({}, claude.received()) + assert.equals(1, #errors) + assert.matches("Connection timeout %- clearing 1 queued", errors[1]) + assert.same({}, claudecode.state.mention_queue) + assert.is_nil(claudecode.state.connection_timer) + assert_no_lua_error() + end) + it("E: a send while a flush is due does not postpone that flush", function() local claude = claude_connects() claude.handshake() From 3de15f5ca4a44210fe2c337d124466fd1ce8de1b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 09:27:25 +0000 Subject: [PATCH 35/35] chore(skills): re-record the diff model after the spared-queue flush --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 375912fa0..063db64aa 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -125,7 +125,7 @@ site lua/claudecode/diff_inline.lua: resolution_callback = resolution_callback, == lua/claudecode/health.lua file lua/claudecode/health.lua 5ab73bf388f3ac97 == lua/claudecode/init.lua -file lua/claudecode/init.lua 42b52ef17245b92c +file lua/claudecode/init.lua fc28de832b4056df block user_close_all 10e024fd8ada1c16 site lua/claudecode/init.lua: local pending = diff.close_pending_diffs("user command") @ function M._create_commands() site lua/claudecode/init.lua: local rejected = diff.close_rejected_diffs("user command") @ function M._create_commands()