Skip to content

fix(diff): say the unified layout runs on Neovim 0.8.0, and run its specs there - #564

Merged
ThomasK33 merged 6 commits into
mainfrom
fix/unified-layout-neovim-08
Nov 12, 2026
Merged

ThomasK33 merged 6 commits into
mainfrom
fix/unified-layout-neovim-08

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

The README says the unified diff layout needs Neovim 0.9. It does not. The layout uses vim.diff, and Neovim 0.8.0 has that function. The 0.9 claim made eight integration specs skip every unified case on Neovim 0.8.0, so the 0.8.0 CI job ran no accept, reject, :q, window-size or trailing-line-break check for that layout. A regression there would have passed CI.

What changed

  1. Both READMEs drop the 0.9 claim. README.md (the diff_opts.layout comment) no longer says "Requires Neovim >= 0.9.0". lua/claudecode/README.md (the unified bullet) now says that Neovim 0.8.0 has vim.diff, so the layout runs on every Neovim the plugin supports.
  2. Eight specs lose their 0.9 skip. In each, the unified cases now run on every Neovim: diff_quit_reject_spec.lua, diff_window_choice_spec.lua, diff_window_sizes_spec.lua, diff_close_save_spec.lua, diff_hand_over_spec.lua, diff_accept_trailing_newlines_spec.lua, diff_accept_missing_final_newline_spec.lua, diff_accept_emptied_buffer_spec.lua. In diff_quit_reject_spec.lua and diff_window_choice_spec.lua the skip covered the whole file.
  3. The error message tells the truth. setup_inline_diff in lua/claudecode/diff_inline.lua raised -32000 with "Inline diff requires Neovim >= 0.9.0" when Neovim has no diff function. The guard checks for the function (vim.text.diff or vim.diff) and never for a version, so the message now says what is missing: "The unified layout needs vim.text.diff or vim.diff, and this Neovim has neither". A unit test drives the guard with both functions removed. It fails on the old message (50 successes, 1 failure) and passes on the new one (51 successes).
  4. One 0.9 gate stays. diff_hand_over_spec.lua picks :w ++p only when Neovim has it (0.9 and later) and a mkdir plus :w on 0.8.0. That is a real difference between the versions, and it is not about the unified layout.
  5. One case asserts the error it expects. In diff_window_sizes_spec.lua, the case "gives the sizes back when the unified diff's setup fails" checked only that some error came back. With the unified path broken on Neovim 0.8.0 (a diff function that ignores vim.diff), it stayed green, because the guard raised an error too. It now asserts the Diff setup failed message and that the error data carries the failure of the user's autocmd. With that broken path it fails on 0.8.0, and it passes on 0.8.0 and 0.12.3.

The tree holds no other 0.9 claim about the unified layout. I checked every 0.9 and nvim-0.9 line in code, docs, comments, error strings, specs, scripts and the CI workflow. The ones that remain are about something else: nvim -l (0.9 and later only), statuscolumn and :w ++p gates in diff.lua that are correct, the LuaJIT bug in Neovim 0.8 and 0.9, visual mode in 0.8 and 0.9, and the RPC process model. CHANGELOG.md and its fixture copy keep their old lines, because they describe what the release did when it shipped.

diff_inline.lua is covered by the diff lifecycle model. The change touches only the setup_inline block, and only a comment and an error string in it, so no behavior changed. The model passes (89205 states, every fact TRUE) and covered.lock is recorded for the new file.

How I checked it

The specs ran on a real Neovim 0.8.0 (v0.8.0-1210-gd367ed9b2) before and after the change:

spec (Neovim 0.8.0) successes before successes after
diff_quit_reject_spec.lua 0 (skipped) 26
diff_window_choice_spec.lua 0 (skipped) 14
diff_window_sizes_spec.lua 19 24
diff_close_save_spec.lua 40 77
diff_hand_over_spec.lua 12 13
diff_accept_trailing_newlines_spec.lua 14 28
diff_accept_missing_final_newline_spec.lua 64 94
diff_accept_emptied_buffer_spec.lua 34 40

That is 133 more tests on Neovim 0.8.0, with 0 failures and 0 errors. 13 of the 133 are vertical-layout cases of diff_quit_reject_spec.lua, which the file's 0.9 skip also hid. Three other specs (diff_new_tab_bufenter_spec.lua, diff_new_tab_mode_spec.lua, diff_terminal_float_spec.lua) already ran their unified cases on 0.8.0 without a 0.9 skip, and they pass.

I also ran a unified diff by hand in the real Neovim 0.8.0 terminal UI (tmux, 110 columns). The proposal changed two to TWO and added four. The screen showed a red - sign and a red-tinted line for the deleted row, and a green + sign and a green-tinted line for each added row. :w answered FILE_SAVED with the exact proposal text one\nTWO\nthree\nfour\n. :q answered DIFF_REJECTED.

Gates on the final commit 598b0acc, which merges origin/main 523ee20 into the branch (private XDG folders). Since #545, mise run all also runs the static proofs of the verify-claudecode-nvim skill, and they pass:

  • mise run all: native exit 0. luacheck: 0 warnings / 0 errors in 185 files. busted: 1738 successes / 0 failures / 0 errors / 28 pending. treefmt: 0 files changed.
  • mise run format-check: native exit 0 (427 files, 0 changed).
  • scripts/run_integration_tests_individually.sh on Neovim 0.12.3: 527 successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 523 successes, 0 failed, 0 errors, native exit 0 (390 on origin/main, 133 more now).
  • Full verify-diff-lifecycle/check.sh, then --record, then all four .agents/skills/verify-*/check.sh --hash-only: every one matches its covered.lock.

Left as it is

CHANGELOG.md keeps its old lines about 0.9 and about the 0.8.0 job skipping unified. They describe what the release did when it shipped.


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

…specs there

The unified layout uses vim.diff, which Neovim 0.8.0 has. The README said it
needs Neovim 0.9, and eight integration specs skipped every unified case on
0.8.0 because of that. With the skips removed, the specs pass on 0.8.0: accept,
reject, :q, window choice and sizes, the trailing line break cases, and the
close and hand-over cases. 133 more tests run there.

Both READMEs drop the 0.9 claim. The 0.9 gate that stays is the one for
`:w ++p` in diff_hand_over_spec.lua, which Neovim 0.8.0 does not have.
setup_inline_diff raises -32000 when Neovim has neither vim.text.diff nor
vim.diff. The message said "Inline diff requires Neovim >= 0.9.0", but the
guard checks for the function and never for a version. Neovim 0.8.0 has
vim.diff, so the layout runs there. The message now names the missing
function, and a unit test drives the guard.

The diff lifecycle model passes on the changed block (89205 states, every
fact TRUE), so covered.lock is recorded for it.
The case "gives the sizes back when the unified diff's setup fails" checked
only that some error came back. With the unified layout broken on Neovim 0.8.0
(a diff function that ignores vim.diff) it stayed green, because the guard
raised an error too. It now asserts the "Diff setup failed" message and that
the error data carries the failure of the user's autocmd, so it fails when the
unified path itself breaks. It passes on Neovim 0.8.0 and 0.12.3.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

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

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T13:19:25.305896Z 598b0ac Manual request
🔒 Security Review ✅ Completed 2026-11-12T13:20:06.433211Z 598b0ac Manual request
ℹ️ About Codex in GitHub

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

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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 598b0acce2

ℹ️ About Codex in GitHub

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

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

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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

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

Reviewed commit: 598b0acce2

View security finding report

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

ℹ️ About Codex security reviews in GitHub

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

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

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

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 37ee1e3 Nov 12, 2026
6 checks passed
@ThomasK33
ThomasK33 deleted the fix/unified-layout-neovim-08 branch November 12, 2026 15:55
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…oder#558)

## Why

The goal is that every openDiff call that reached the server gets one
answer when Neovim exits. A pending diff gets `DIFF_REJECTED`. A call
that the exit cut off during its setup gets a -32603 error. Before, two
cases left Claude waiting on a connection that then closed with 1001.

Case 1: diffs that no `BufUnload` reaches. At exit, Neovim fires
`BufUnload` for each loaded buffer before `VimLeavePre`, and a proposal
buffer's `BufUnload` autocmd rejects its diff. That loop stops as soon
as an autocmd deletes the buffer it fired for (`src/nvim/main.c` in
v0.12.3, the `bufref_valid` check). Three things leave a diff without
its `BufUnload`:

1. The `keep_empty` reject of a new-file diff
(`diff_opts.on_new_file_reject`, the default), in a tab that the diff
did not open, deletes its proposal buffer. The diffs after it get no
`BufUnload`.
2. Another plugin's buffer that deletes itself in its own `BufUnload`
stops the loop the same way, so a single diff can lose its `BufUnload`.
3. SIGTERM and SIGHUP run `VimLeavePre` without the loop.

Such a diff reached the `VimLeavePre` autocmd of `diff.lua` pending,
which removed it without an answer. `server.stop` then found nothing to
reject. The docs verifier found the first case (B1).

Case 2: an exit inside a handler, during setup. A `nested = true`
`ClaudeCodeDiffOpened` handler that runs `:qa!` exits Neovim while the
openDiff call is still in setup. That call has no response sender yet,
because the server stores one only after the tool handler yielded. The
exit never unwinds the stack, so the early answer of the setup never
leaves. A verifier of this PR found it, and it also exists on
`origin/main`.

Repro with a real WebSocket client that plays Claude (a plugin server,
`auto_start = true`, default `diff_opts`). The client sends `openDiff`
for a new file (id 1), then for an existing file (id 2):

| Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5, 0.8.0 | origin/main | this PR |
|---|---|---|
| `qa!` once both diffs are open | `DIFF_REJECTED` for id 1 only, then
Close 1001 | `DIFF_REJECTED` for id 1 and for id 2, once each, then
Close 1001 |
| a handler runs `qa!` during the setup of id 2 | `DIFF_REJECTED` for id
1 only, then Close 1001 | `DIFF_REJECTED` for id 1, a -32603 error for
id 2, then Close 1001 |

## What changed

1. `lua/claudecode/diff.lua`. The `VimLeavePre` callback (now the local
`cleanup_at_exit`) first rejects each diff whose status is `pending`,
oldest first. It runs `_resolve_diff_as_rejected` under `pcall`, so one
diff that fails to reject cannot leave the others waiting. Then it calls
`M._cleanup_all_active_diffs("shutdown")`. The pass is inline because it
has one call site, as AGENTS.md (Code Shape) asks.
2. `lua/claudecode/server/init.lua`. `_handle_request` records each
request in `M.state.in_flight` while its handler runs. `M.stop` answers
every request still recorded with -32603 "Internal error" before it
stops the TCP server, and `_handle_request` sends nothing for a request
that `stop` answered. A call in setup gets this error, and a waiting
call keeps getting `DIFF_REJECTED` from the reject pass.
3. Tests. Three specs run under `mise run test`, and one real-Neovim
spec runs in the integration runner. One test checks the order of the
stop: the -32603 answer must leave before the TCP server stops. See
Verification.
4. Model. `verify-diff-lifecycle` gets the exit modeled. See
Verification.
5. Docs. The root `README.md` has one bullet, "Exit answers" (under
Global Invariants), that states what Claude gets for each kind of call
in each kind of exit. Each fact in it was probed. The other docs
describe only the mechanism that they own and link to that bullet:
`ARCHITECTURE.md` (the Resource Cleanup section and the Resource Cleanup
bullet under Performance & Security: which autocmd runs what, in which
order), `lua/claudecode/README.md` (the `BufUnload` loop and the two
passes), the server README (what `M.stop` sends), `SKILL.md`,
`check.sh`, the comments of `diff.lua`, and two test headers.
6. `covered.lock` of `verify-diff-lifecycle` records the new hashes of
`diff.lua` and `server/init.lua`. It was re-recorded on the merged tree
each time main moved, after the full `check.sh` passed. The locks of
`verify-mention-queue` and `verify-selection-tracking` also cover
`server/init.lua`, so each records its new file hash (see Gates, item
7).

## Scope

Not changed:

1. `_cleanup_all_active_diffs`. It still removes every entry, in any
status. A saved diff is removed at exit as before, and it gets no second
answer.
2. `close_pending_diffs` in `server.stop`. It stays for a stop that is
not an exit (`:ClaudeCodeStop`). At exit the diff autocmd runs first, so
it finds nothing pending there.
3. SIGKILL, a crash, and a quit from inside an autocmd that is not
`nested`. They run no `VimLeavePre`, so no diff gets an answer, Claude's
socket closes with 1006, and the lock file stays. No code in the plugin
runs then, so the plugin cannot change this. The READMEs say so.

## Follow-up PR (after this merges)

1. `README.md` (Global Invariants, "Exit answers") says that with
`"close_window"` both diffs have their answers before the first
`ClaudeCodeDiffClosed` fires. That holds for `:qa!` and SIGTERM, the
exits that were probed. For a nested `:qa!` during the setup of a diff,
the call that the exit cuts off gets its -32603 from `server.stop`,
after the `ClaudeCodeDiffClosed` events. Every call still gets exactly
one answer before Close 1001. Every call still gets its answer, so this
does not block the merge. A follow-up PR limits that README sentence to
waiting calls and says that a call cut off in its setup gets its -32603
after the `ClaudeCodeDiffClosed` events.
2. `tests/integration/diff_exit_reject_spec.lua` skips the unified
layout below Neovim 0.9, with the comment "The unified layout needs
Neovim 0.9". coder#564, now on main, removed that claim and those skips. The
same follow-up PR drops the three skips and the comment, so the 14
unified scenarios also run on v0.8.0 (28 pass there in a trial run).

## Blast Radius

Only a call that is open at exit, or a call whose handler a non-exit
`server.stop` interrupts, changes.

1. A pending diff that gets no `BufUnload` (an exit through SIGTERM or
SIGHUP, or another autocmd that deletes its buffer first) went
unanswered on main, and now gets `DIFF_REJECTED`. A single pending diff
that gets its `BufUnload` behaves as before, because the new pass finds
nothing pending.
2. The `ClaudeCodeDiffClosed` event of a diff that the new pass rejects
carries the reason `shutdown`. In the `vertical` and `horizontal`
layouts, a new-file diff with `keep_empty` in a tab it did not open
fires `diff rejected (keep_empty)` instead, as it does for a `BufUnload`
reject. The `unified` layout keeps the reason `shutdown`. That event
fires while a later diff is still `pending`, so a `ClaudeCodeDiffClosed`
handler can see a later diff that is not rejected yet. The README says
so.
3. `server.stop` answers a request whose handler is still running with
an error. Before, an exit inside a handler answered nothing. A stop that
is not an exit and runs inside a handler (for example `:ClaudeCodeStop`
from a `ClaudeCodeDiffOpened` handler) also answered nothing: the
handler's result came after the stop, when `send_response` sends
nothing. Now the call gets the error once, before Close 1001.

## Verification

Red, then green, run again at the head. The new specs fail on
`origin/main` code and pass with the fix.

| Spec | `origin/main` code | This PR |
|---|---|---|
| `tests/unit/diff_exit_reject_spec.lua` (6 tests) | 4 red, 2 green | 6
green |
| `tests/unit/diff_exit_during_setup_spec.lua` (2 tests: the exit inside
a handler, both orders of the two autocmds) | 2 red | 2 green |
| `tests/unit/server/in_flight_request_spec.lua` (6 tests) | 5 red, 1
green. Two of the red tests, "a finished request" and "a request whose
handler raised", fail on main only at `next(server.state.in_flight)`,
because the table does not exist there. Main answers those requests
correctly, so they guard the new table. | 6 green |
| `tests/integration/diff_exit_reject_spec.lua` (28 scenarios, a child
Neovim does the real exit) | 11 red, 17 green on 0.12.3, 0.11.5, 0.10.4
and 0.9.5. On 0.8.0, 7 red and 7 green (14 run) | all green on all five
versions |

The integration scenarios are 20 for `:qa!` (split and unified layout,
`keep_empty` and `close_window`, 5 open orders), 4 for SIGTERM and for a
buffer that deletes itself in its own `BufUnload` (both layouts), and 4
for the exit inside a handler (both layouts). On Neovim 0.8.0 the
unified layout is pending, so 14 run there.

Mutants of the code (one edit each), against the three unit specs and
the integration spec on Neovim 0.12.3:

| Mutant | unit red | integration red |
|---|---|---|
| `M.stop` answers nothing | 5 | 4 |
| `M.stop` sends the answer after `stop_server`, or after
`M.state.server = nil` (it never reaches Claude) | 1 | 0 |
| `M.stop` keeps the entry, so the handler sends a second answer | 2 | 0
|
| `_handle_request` sends after a stop | 2 | 0 |
| `_handle_request` never removes an entry | 4 | 2 |
| the reject pass sorts by tab name, not by open order | 1 | 0 |
| the reject pass skips unified diffs | 0 | 2 |
| no reject pass, or the pass after the cleanup | 5 | 7 |

Probes of the exit facts. A real WebSocket client plays Claude: it sends
`openDiff` for a new file (id 1), then for an existing file (id 2). A
`ClaudeCodeDiffClosed` handler logs the status of every diff. Each
scenario ran at the head `43fc68d619e218995dbba2fc07a128c6ff1fbaee` on
Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5 and 0.8.0, and gave the same result
on all five:

| Scenario | Claude gets | Close | Lock file left |
|---|---|---|---|
| `:qa!` | id 1 and id 2 `DIFF_REJECTED`, once each | 1001 | no |
| SIGTERM, or SIGHUP | the same | 1001 | no |
| id 1 accepted (`FILE_SAVED`), then `:qa!` | id 1 `FILE_SAVED` once, id
2 `DIFF_REJECTED` once | 1001 | no |
| a nested `ClaudeCodeDiffOpened` handler runs `:qa!` during the setup
of id 2 | id 1 `DIFF_REJECTED`, id 2 a -32603 error, once each | 1001 |
no |
| the same handler runs `:ClaudeCodeStop`, which is not an exit | the
same | 1001 | no |
| SIGKILL | nothing | 1006 | yes |
| `:qa!` inside an autocmd that is not `nested` | nothing | 1006 | yes |

Order of `ClaudeCodeDiffClosed`, with one new-file diff and one
existing-file diff, in the `vertical` and `horizontal` layouts, for
`:qa!` and SIGTERM (40 runs). With `keep_empty`, the handler of the
new-file diff runs while the existing-file diff is still `pending`. With
`close_window`, both diffs are `rejected` before the first
`ClaudeCodeDiffClosed` fires. A crash was not probed, and SIGKILL stands
in for it.

Order of the two `VimLeavePre` autocmds. A probe fixed the order that
`ARCHITECTURE.md` states. A real WebSocket client holds two open diffs,
tracer autocmds sit before and after `setup`, and wrappers log each
call. All 15 runs gave one sequence: the `BufUnload` loop answers the
first diff, tracer 1, the `diff.lua` autocmd (the reject pass of
`cleanup_at_exit` answers the second diff, then
`_cleanup_all_active_diffs("shutdown")`), the `init.lua` autocmd
(`stop`, `lockfile.remove`, `tcp.stop_server`), tracer 2. The runs cover
Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5 and 0.8.0, each with a plain
`setup`, a second `setup` call, and `claudecode.diff` loaded before
`setup`. The client got `DIFF_REJECTED` for ids 1 and 2, then close code
1001, and no lock file stayed. The order holds because `M.setup` loads
`diff.lua` before it registers its own autocmd.

Model (`verify-diff-lifecycle`). The exit was not modeled, and the
model's `Idle` condition excluded a call in setup, so the model could
not see the case 2 gap. The model now has the action `ExitInSetup` (an
exit inside an opened handler during one call's setup), the fact
`ExitAnswersInFlight` (read by `check.sh` from `_handle_request` and
`M.stop`), and the rule `ExitedCallsAnswered`. A call that nothing
answered gets the new phase `gone`. From the first round the model also
has `DiffCleanupAtExit` and the fact `ExitRejectsPending`.

1. `check.sh --mutants` ends with `every guard is caught`.
`ExitAnswersInFlight=FALSE` is caught by `ExitedCallsAnswered`, so the
action is reachable and the old behavior breaks the rule.
`ExitRejectsPending=FALSE` is caught by `NoStrandedCall` and
`DisconnectedCallsAnswered`. Every other guard is caught as before, and
no rule is vacuous.
2. After `--record`, `check.sh` reports PASS: `main: pass (93477
distinct states found)` and `live-DisconnectedCallsAnswered: pass (5557
distinct states found)`. Before this PR the counts were 89205 and 5269.
`check.sh --hash-only` reports `covered.lock matches the code`.

The model runs the two `VimLeavePre` autocmds of an exit in setup in one
order, and it does not model a `ClaudeCodeDiffClosed` handler in the
exit pass or a saved diff lost at exit, because Neovim quits right
after. `SKILL.md` says so.

Gates. The head is `43fc68d619e218995dbba2fc07a128c6ff1fbaee`. It merges
`origin/main` at `f7f89cb5`. Items 1 to 5 and 7 ran at that head, on a
host where nothing else of mine ran:

1. `mise run all` (`BUSTED_SEED=97531`) exits 0. 1751 successes, 0
failures, 0 errors, 29 pending. luacheck: 0 warnings, 0 errors in 189
files. The 29 pending tests need a real Neovim.
2. The unit specs of the diff and server modules (484 tests) exit 0 with
0 not ok. The three new specs in shuffled order, seeds 1 to 6: each
exits 0 with 14 ok.
3. `scripts/run_integration_tests_individually.sh` exits 0 on Neovim
0.12.3 (Total Success 555, Failed 0, Errors 0) and on Neovim 0.8.0, the
version of the `integration-tests (v0.8.0)` job (Total Success 404,
Failed 0, Errors 0).
4. `mise run format-check` (430 files, 0 changed) and `check.sh
--hash-only` exit 0. The full `check.sh` exits 0 with `main: pass (93477
distinct states found)` and `live-DisconnectedCallsAnswered: pass (5557
distinct states found)`.
5. `ExitRejectsPending` is the only fact of `check.sh` that reads the
reject pass. This is with `check.sh` as it stands at the head. It reads
TRUE at the head. With the pass removed from `cleanup_at_exit`, or moved
after the cleanup, it reads FALSE, `check.sh` exits 1, and
`NoStrandedCall` and `DisconnectedCallsAnswered` break.
6. `check.sh --mutants` of `verify-diff-lifecycle` ends with `every
guard is caught`. It caught `ExitRejectsPending=FALSE` by
`NoStrandedCall` and `DisconnectedCallsAnswered`, and
`ExitAnswersInFlight=FALSE` by `ExitedCallsAnswered`. An independent
verifier ran it on a clean checkout of the head `43fc68d6` (exit 0, 22
guard rows caught).
7. CI runs the `check.sh` of all four `verify-*` skills.
`verify-mention-queue` and `verify-selection-tracking` cover
`lua/claudecode/server/init.lua`, and their locks went stale. Only the
file hash changed in each. No covered block changed. The change cannot
reach a queue, a timer, the handshake, a broadcast or the selection
state: `M.stop` sends the response of a request whose handler still
runs, before it stops the TCP server. The full `check.sh` of each skill
passes (mention queue: `main` 270120 distinct states, three live
properties; selection tracking: `main` 129986). The 12 mention-queue
replays all kept their promise, among them `restart-leak` and
`stop-in-wait`, which go through `M.stop`. The four `--hash-only` runs
exit 0. Both locks were recorded again after the last commit, which only
wraps a comment in `server/init.lua`.

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

## What this fixes

With `disable_broadcast_debouncing = true` and an empty queue,
`_broadcast_at_mention` broadcast a send at once and skipped the
readiness check. A Claude whose WebSocket handshake completed less than
`connection_wait_delay` ms ago (600 ms by default) got the mention while
it was still starting up. On real Neovim 0.8.0 and 0.12.3 it arrived 3
to 10 ms after the handshake. With the default config the same send
waits the full 600 ms.

The option is not in the README or the `config.lua` defaults. It is
typed in `types.lua` and described in `lua/claudecode/README.md`, so a
user config can set it.

The fix is in `lua/claudecode/init.lua` (`M._broadcast_at_mention`): the
immediate path now also needs `ms_until_a_client_is_ready()` to be 0.
That means some connected client completed its handshake
`connection_wait_delay` ms ago. Otherwise the mention goes through the
queue, which sends it once a client is ready. Behavior is unchanged in
two cases:

- Some client is ready: the send still goes out at once, with nothing
queued.
- No client reports a handshake time: the immediate path still runs and
fails at once with "Failed to broadcast …", as before.

The verify-mention-queue skill (coder#523, now on main) found this through a
Codex review on its model. coder#523 excluded this config from its
`SentWhenReady` rule as a known bug. coder#523 merged first, so this PR adds
the model branch and flips that wording.

## The verify-mention-queue model now covers this path

- `model/MentionQueue.tla`: a new variable `immediate` holds the user's
`disable_broadcast_debouncing`. Each run starts with either value and
keeps it. With it on, a send while Claude is connected and the queue is
empty broadcasts at once while some connected client is ready. Otherwise
the send queues as before. The writes all succeed, all fail later, or
are refused at once, as in a flush.
- `check.sh`: a new fact, `ImmediateWaitsForReady`, read from
`_broadcast_at_mention`. It reads TRUE only when the readiness guard
comes before every `.broadcast(` and `queue_mention(` line of that
function (a broadcast written as an indexed call or through an alias
still reads TRUE; the unit tests catch those), so an unguarded broadcast
added before the guard reads FALSE. With it FALSE, `SentWhenReady`
breaks. `FailedWriteReported` now also reads the immediate send's
`report_unwritten("command", …)`.
- `SKILL.md`: `SentWhenReady` holds with `disable_broadcast_debouncing`
on or off. The scope note now describes the check instead of a known
bug, and the facts table lists the new fact.

## Tests

The first two tests were committed before the fix (ebcdb89) and failed
on main:

- `tests/unit/mention_queue_spec.lua`, `D: with debouncing off, a send
right after the handshake still waits for connection_wait_delay`: a fake
loop runs a send 100 ms after the handshake. It expects the mention in
the queue, nothing at 590 ms and the mention at 610 ms. On main the
queue stayed empty and the mention went out at once.
- `tests/integration/mention_immediate_send_spec.lua` (real Neovim, so
CI runs it on stable and v0.8.0): a loopback Claude completes the
handshake.
- A send right after the handshake must reach Claude only once
`connection_wait_delay` has passed. The time is measured on the loop
clock that stamps `connected_at`. On main: `Claude got the mention 3 ms
after its handshake, before 600 ms` (0.8.0) and `6 ms` (0.12.3).
- A send once Claude is ready must still go out at once, with nothing
queued. This one passes before and after the fix.
- `tests/unit/mention_queue_spec.lua`, `D: with debouncing off, a send 1
ms before Claude is ready still waits in the queue` (verifier F1 on
24b7ee7, tightened after the verifier on 700cbf6). The first two tests
send 500 ms or more before ready, so a readiness check with a tolerance
survived them. The fake clock makes the 1 ms exact on any Neovim. Each
of these mutations of the guard fails only this test: `< 2`, `< 20`, `<=
20` and `< 100`. With the fix, the spec passes 53 of 53.

## Replay runner and wording (minor findings from coder#523 round 6)

- `replays/run.sh`: it exits 2 before the first replay when `NVIM` names
no Neovim, and the error names the path as given. A `NVIM` in a missing
folder used to turn into `/nvim`. bash's "Alarm clock" note now goes to
the replay's log, not the terminal. The comment no longer cites a
VimLeavePre error, because Neovim exits 0 after one.
- `drain.lua` and `SKILL.md` now say "an 8.6 s wait before Claude
connects" where they said "handshake".

## Checks run

The gates ran on 4a06553, with main at d123707. The head then merges
main's 37ee1e3 (coder#554, coder#564): `diff_inline.lua`, the integration runner
and diff specs. None of them touches the mention queue or a file of this
PR, and all four `covered.lock` files match the head. Two results ran on
earlier commits:

- `--mutants` ran on 3980bc3. Since then `check.sh` changed only in how
it reads `ImmediateWaitsForReady`, which still reads TRUE, so each
mutant run gets the same TLC input.
- The statechart runs ran on 334f122. The model is the same on the head.

The results on 4a06553:

- `verify-mention-queue/check.sh`: PASS, 20 facts TRUE. `main` 540,240
distinct states, `live-NoStrandedMention` 115,628, `live-ReadyDelivers`
115,628, `live-FlushDelivers` 48,296. Three counts are exactly twice the
old ones and `live-FlushDelivers` is 1.96 times (24,692 before), because
each run checks both values of `immediate`.
- `verify-mention-queue/check.sh --mutants`: exit 0 in 6543 s on a
loaded host (load average about 60 to 118). Every guard is caught by the
rule `EXPECT` names (`ImmediateWaitsForReady=FALSE` by `SentWhenReady`),
and every rule breaks at least once.
- A scratch copy of the model with an action property that says the
immediate send never happens: TLC breaks it (314 states, 1 mention, 1
connect), so the branch is reachable. With `ImmediateWaitsForReady =
FALSE`, `SentWhenReady` breaks.
- `/statechart` with the committed view still charts three scratch
breaks, `StopClearsQueue` and `FlushWaitsForReady` off. All 3 exited 1
with a chart.
- Every other `verify-*/check.sh` passes:
- `verify-diff-lifecycle`: 89,205 / 5,269 states. Its lock merged from
main.
  - `verify-selection-tracking`: 129,986 states.
  - `verify-terminal-lifecycle`: 263,060 states.
- The selection and terminal models hash all of `init.lua`, so I
re-recorded their `covered.lock` files after the fix. Neither model
reads `_broadcast_at_mention`.
- `replays/run.sh`: 12 of 12 kept on Neovim v0.8.0 and 12 of 12 on
v0.12.3, on the head.
- With `NVIM=/nonexistent/nvim`, `./no-such-nvim` or `nvim-not-on-path`,
it exits 2 before any replay and names the path.
- On a copy of the head, a replay that prints KEPT and exits 1 reads
BROKEN. A replay that hangs is ended by the alarm and reads BROKEN with
exit 142. "Alarm clock" is in its log, and the terminal got 0 bytes of
stderr.
- busted, full suite, shuffled (seed 12345): 1769 successes, 0 failures,
0 errors, 29 pending.
- `scripts/run_integration_tests_individually.sh`: Neovim v0.8.0, 400
successes, 0 failed, 0 errors. Neovim v0.12.3, 537 successes, 0 failed,
0 errors. `mention_immediate_send_spec.lua` passed 2 of 2 on each
version.
- `ImmediateWaitsForReady` reads TRUE on the head. With an unguarded
broadcast before the guard (the verifier's probe R4), it reads FALSE.
- luacheck: 0 warnings in 186 files. `treefmt --ci`: 0 files changed.

## Repro

```lua
-- In a headless Neovim with the plugin on 'runtimepath', CLAUDE_CONFIG_DIR set to a temp dir:
require("claudecode").setup({ terminal = { provider = "none" }, track_selection = false, disable_broadcast_debouncing = true })
-- Connect a WebSocket client, complete the handshake, then right away:
require("claudecode").send_at_mention(vim.fn.getcwd() .. "/a.txt", nil, nil, "repro")
-- Before this fix the client gets the at_mentioned frame within a few ms. After it, it arrives 600 ms after the handshake.
```

`tests/integration/mention_immediate_send_spec.lua` is the runnable
form.

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

## Why

In the `vertical` and `horizontal` layouts, a proposal whose line breaks
are all CRLF was loaded into the proposal buffer with `'fileformat'`
`unix` and a CR at the end of each line. The save joined the lines with
LF. So a line that the user typed, or the join of two lines, came back
with LF, or with a CR in the middle of a line, where Neovim's `:w`
writes CRLF. A CRLF proposal that equals a CRLF file also showed every
line as changed, because the file's own buffer is `dos` and holds its
lines without CRs. I found it while verifying coder#551.

## What changed

1. `_setup_blocking_diff` (`lua/claudecode/diff.lua`) loads a proposal
as Neovim loads a file. When `'fileformats'` includes `dos`, and the
proposal has a line break and only CRLF line breaks, the buffer gets
`'fileformat'` `dos` and lines without their CRs. Any other proposal
gets `'fileformat'` `unix` and is split at LF, as before. The diff sets
`'fileformat'` in both cases, because a new buffer takes the first entry
of `'fileformats'`, and that entry can be `dos`.
2. `_resolve_diff_as_saved` joins the lines with the line break of the
buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), and adds the
final one when `'eol'` is on. Before, it always used LF. So `:setlocal
fileformat=dos` on an LF proposal now answers CRLF, as `:w` writes it.
3. The `unified` layout does not change. Its buffer is read-only, and
its answer is the proposal.
4. `lua/claudecode/README.md` states both rules, and that one empty line
is saved as one line break of the buffer's `'fileformat'`.
5. `tests/integration/diff_accept_crlf_spec.lua` is new (159 tests).
`tests/mocks/vim.lua` gives the mock `vim.o` the default `fileformats =
"unix,dos"`, because the unit specs of the diff now read it.
6. `covered.lock` of `verify-diff-lifecycle` records the new hashes of
`diff.lua` (the file, `resolve_saved` and `setup_blocking`).

## What a user sees

1. A line that the user types into an accepted CRLF proposal, or two
lines that the user joins, comes back with CRLF, as `:w` writes it. For
the proposal `"a\r\nb\r\n"` and a typed line after the last line, main
answers `"a\r\nb\r\nx\n"` and this PR answers `"a\r\nb\r\nx\r\n"`.
2. A CRLF proposal that equals the CRLF file shows no difference. Main
marked every line.
3. An unedited proposal still comes back as exactly the proposal, in
every layout, under every `'fileformats'` value that I ran.
4. Nothing else changes for the default `'fileformats'` (`unix,dos`):
the controls of the spec (an LF proposal, a proposal with CRLF and LF
line breaks, a lone CR, no line break, unedited, emptied) give the same
bytes on main and here.
5. `'fileformats'` `unix` (no `dos`) keeps a CRLF proposal as `unix`
with its CRs, as the file's own buffer does. The equal-text view shows
no difference there, and a typed line gets LF, as `:w` writes it.

## Verification

The spec compares the answer with the bytes that Neovim's own `:w`
writes for the same text after the same commands (an oracle that reads
the text as a file under the same `'fileformats'`), or with an exact
expected text.

1. Red, then green, run on Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5 and
0.8.0 (all five give the same counts): 159 tests pass with the fix. With
`origin/main`'s `diff.lua`, 105 pass and 54 fail. The unified cases run
on 0.8.0 too (coder#564).
2. Fourteen mutants of the changed lines are each red on Neovim 0.12.3
(the last seven also on 0.8.0, with the same counts). Failed tests per
mutant:
- Split and join: the CRs stay in the lines 70, the join always uses LF
66, the final break is always LF 56, `mac` is ignored 2, dos for any
text with a CRLF 12, dos for a text with no line break 2, dos even with
a lone LF 32.
- `'fileformats'` check: no check (always dos) 8, an exact match of
`unix,dos` or `dos,unix` 4, dos for any value except `unix` 4, dos only
when it is the first entry 42, `'fileformat'` always `unix` 64, always
`dos` 48, set only for `dos` 12.
3. A probe of 1860 cases per run on Neovim 0.12.3 and 0.8.0 (three
layouts, four old files, 15 proposals, 15 edits) compares each answer
with the bytes of a fresh `nvim --clean` that reads the proposal as a
file under the same `'fileformats'`, runs the same commands and writes
with `:w`. The columns give mismatches. The two Neovim versions give the
same numbers.

| `'fileformats'` | main | this PR | unedited answers that differ from
the proposal (main / this PR) |
   |---|---|---|---|
   | `unix,dos` | 748 | 144 | 0 / 0 |
   | `dos,unix` | 788 | 184 | 0 / 0 |
   | `unix` | 304 | 192 | 0 / 0 |
   | `dos` | 1104 | 516 | 0 / 0 |
   | `unix,mac` | 608 | 544 | 0 / 0 |

Every mismatch that remains is one of the items under "Not fixed here",
or a fold (see below). Cleanup leaves no state, buffer, window or tab
behind in any case. Under `unix,dos`, `dos,unix` and `unix`, the
equal-text view marks 0 lines in every case.
4. The only cases where this PR has a mismatch that main does not have
are `dup_line` and `dd_first` (`yyp` and `dd` on line one) against an
old file that equals the proposal, for CRLF proposals: 28 cases for each
of `unix,dos`, `dos,unix` and `dos`. Diff mode folds every line of an
equal text, so those commands act on the whole fold. Main does the same
for an equal LF text (`same|lf|dup_line`), and showed every line of an
equal CRLF text as changed, so it did not fold. This follows from item 2
of "What a user sees".
5. An independent verifier ran its own probe (2604 cases per run) under
`unix,dos`, `dos,unix`, `unix` and `dos` on all five Neovim versions,
and under `unix,dos,mac`, `unix,mac` and `dos,mac` on 0.12.3. In every
run, 0 unedited answers differ from the proposal.

## Not fixed here

These items are tracked in coder#581. All of them exist on `origin/main`
already. An independent probe that compares each answer with `:w` of the
same buffer found this PR never worse than main.

1. A CR inside a line, then `:setlocal ff=mac`. `:w` writes that CR as
NL, and the save keeps the CR. The proposal `"a\rb\n"` gives `"a\rb\r"`,
and `:w` gives `"a\nb\r"`. Main joined with LF, which was also wrong.
2. With `'fileformats'` `unix,mac`, Neovim reads a text such as
`"a\r\nb\r\r\n"` as `mac`. The diff never detects `mac` (main neither).
In 24 cases (an `ff=mac` edit only), main's answer equals the file-read
oracle and this PR's answer does not, because main ignores
`'fileformat'` when it joins. Against `:w` of the same buffer, both are
wrong in these 24 cases.
3. An empty proposal, then typed lines. `'eol'` stays off (coder#542), so
`Gox` gives `"\nx"`, where `:w` of an empty file gives `"\nx\n"`.
4. `'fileformats'` that starts with `dos`, and a proposal with no LF
(`""`, `"a"`, `"a\r"`). Neovim reads it as `dos`. The diff keeps `unix`,
so a typed line gets LF where `:w` writes CRLF.
5. `'fileformats'` with the single entry `dos`. Neovim then reads every
file as `dos`, an LF file too. An LF or mixed proposal stays `unix`, so
a typed line gets LF where `:w` of the file writes CRLF. Neovim reads
the equal file as `dos`, so the equal-text view still marks lines for a
text that is not all CRLF: this PR marks 2 of the 15 probe texts (a
mixed text, and one with a leading lone LF), and main marks 11 of 15.

## Gates

The head is `1c7b5adcfc165963ffc250ab9ebeedad00ddf954`. It merges
`origin/main` at `7dbad04b`. Main moved three times while this PR was in
progress, and each time only `covered.lock` conflicted. The gates below
ran in full at `4525dad1` (the merge with `origin/main` at `c43b6a30`).
The last merge brought coder#574. At the head I ran the checks of item 6, and
CI runs the rest on this head.

1. `mise run all` (`BUSTED_SEED=97531`) exits 0: 1837 successes, 0
failures, 0 errors, 34 pending. luacheck: 0 warnings, 0 errors in 194
files.
2. `mise run format-check` exits 0.
3. `scripts/run_integration_tests_individually.sh` exits 0 on Neovim
0.12.3 (Total Success 828, Failed 0, Errors 0) and on Neovim 0.8.0
(Total Success 810, Failed 0, Errors 0). The new spec alone gives 159
passes on Neovim 0.12.3, 0.11.5, 0.10.4, 0.9.5 and 0.8.0.
4. `check.sh --hash-only` of the four `verify-*` skills exits 0. The
full `check.sh` of `verify-diff-lifecycle` exits 0 (`main` 93477
distinct states, `live-DisconnectedCallsAnswered` 5557). The merge with
main conflicted in `covered.lock` only. I took my side, ran the full
`check.sh` on the merged tree (all 23 facts TRUE, the model passes), and
then ran `--record`. The last merge moved only `file` hashes
(`diff.lua`, `init.lua`, `selection.lua`) and no block. Against
`origin/main`, the lock moves 3 lines: the file hash of `diff.lua`, and
the blocks `resolve_saved` and `setup_blocking`. No guard moved. The
change decides which bytes the accepted text holds. It does not change a
diff's status, who gets its answer, or when.

5. At the head, after the last merge: the full `check.sh` of
`verify-diff-lifecycle` (all 23 facts TRUE, `main` 93477,
`live-DisconnectedCallsAnswered` 5557) and then `--record`. coder#574 changed
the blocks `open_blocking`, `handle_invoke` and `setup_blocking` on
main, and the lock of this PR moves only the 3 lines of this PR against
`origin/main` (the file hash of `diff.lua`, `resolve_saved`,
`setup_blocking`).
6. At the head: `format-check` exits 0, `check.sh --hash-only` of the
four skills exits 0, the new spec gives 159 passes on Neovim 0.12.3 and
0.8.0, and the 44 unit specs of the diff, tools and server modules give
752 successes, 0 failures.

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

## Why

Keep the exit tests and the "Exit answers" docs true after coder#558 and
coder#564.

1. coder#564 made the unified layout work on Neovim 0.8.0 and removed the
"needs Neovim 0.9" skips from eight specs. `diff_exit_reject_spec.lua`
came in with coder#558 and still skipped the unified layout below 0.9 in
three places. So 14 unified exit scenarios stayed pending in the v0.8.0
job, and the file kept a comment that is no longer true.
2. The advisor and the verifier of coder#558 found README sentences that say
more than the code does. They concern the exits that run no
`VimLeavePre`.

## What changed

- `tests/integration/diff_exit_reject_spec.lua`:
- The three skips and the comment "The unified layout needs Neovim 0.9"
are gone.
- Three new cases (each in the vertical and unified layouts) start the
exit inside an autocmd, with two waiting calls. A direct `:qa!` in an
autocmd that is not `nested`, and SIGTERM while such an autocmd waits in
`vim.wait`: no call is answered. A direct `:qa!` in a `nested` autocmd:
both calls get `DIFF_REJECTED`. Each case first checks that both calls
were waiting. The two no-answer cases also check that the child ended by
itself.
- The spec has 34 passes on Neovim 0.8.0 and 34 on 0.12.3. On main it
has 14 passes and 14 pending on 0.8.0, and 28 passes on 0.12.3.
- `README.md`, Global Invariants, "Exit answers":
- The `"close_window"` sentence now says "waiting calls". A call that a
nested `:qa!` cuts off during its `openDiff` setup has no response
sender yet. The server stop answers it with -32603 after the
`ClaudeCodeDiffClosed` events. Every call still gets one answer before
close code 1001.
- The sub-bullet for exits without `VimLeavePre` keeps SIGKILL, a crash
and a `:qa!` from inside an autocmd that is not `nested`, as on main,
and adds `:noautocmd qa!` and a Lua `os.exit()`, which also run no
`VimLeavePre`. It adds one rule: a `:qa!`, SIGTERM or SIGHUP that Neovim
handles while an autocmd that is not `nested` is still running can end
Neovim without `VimLeavePre` too. A synchronous LSP format on save,
which waits in `vim.wait`, is the common example. It says "can" because
Neovim looks only at the autocmd that runs when it handles the exit, so
a `nested = true` handler that such an autocmd started (with
`:doautocmd` or `nvim_exec_autocmds`) still gets `VimLeavePre`. It
promises nothing for a signal during a plain Lua loop, because Neovim
handles a signal only at its next event check.
- `ARCHITECTURE.md`, `lua/claudecode/README.md` and the comment of
`cleanup_at_exit` in `lua/claudecode/diff.lua` say the same.

## Scope

Tests and docs. The only change under `lua/` is that comment in
`diff.lua`. `verify-diff-lifecycle` passes (93,477 distinct states, and
5,557 for the live case), and its `covered.lock` records the new file
hash. The plugin does not answer calls in the exits that the README
lists as running no `VimLeavePre` (SIGKILL, a crash, `:noautocmd qa!`,
`os.exit()`, and `:qa!` in an autocmd that is not `nested`), because
none of its exit code runs in them. The docs say so.

## Blast Radius

None at runtime. The v0.8.0 CI job runs 20 more tests of this spec, and
the stable job runs 6 more.

## Verification

- Spec on Neovim 0.8.0 and 0.12.3: 34 passes, 0 failures, in 5 of 5 runs
on each version, and in 3 of 3 runs again after the last commits, which
change comments, docs and the lock only. Mutants:
- The autocmd's `:qa!` replaced by a no-op: the first no-answer case is
red on both versions (the file then runs into the runner's 40 s limit,
because each affected case waits 20 s).
- `cleanup_at_exit` returns at once: 8 cases are red on both versions,
among them the nested `:qa!` case in the vertical layout. The no-answer
cases stay green, because they pin what Neovim does. A Neovim that
changes it turns them red, and the README has to change.
- Probes on 0.8.0 and 0.12.3 (vertical and unified, two waiting calls, 6
runs per row, a `VimLeavePre` marker and the answers in one log). In an
autocmd that is not `nested`, each of these runs no `VimLeavePre` and
answers no call, in all 54 runs per version: a direct `:qa!`, a timer
`:qa!` during `vim.wait`, SIGTERM and SIGHUP during `vim.wait`, SIGTERM
and SIGHUP in the Lua part of a handler that waits later, SIGTERM in the
first of two handlers when the second waits, SIGTERM in a Vim script
loop, and SIGTERM in a `:%s` loop. In a `nested = true` autocmd the same
cases answer both calls. With no autocmd, a timer `:qa!`, SIGTERM and
SIGHUP answer both calls (18 of 18). `:noautocmd qa!` and a Lua
`os.exit()` from a timer run no `VimLeavePre` and answer no call (9 of 9
on each version, three layouts), and a plain `:qa!` and `:cq` run it and
answer both calls (9 of 9). A non-nested autocmd that starts a `nested =
true` handler which waits in `vim.wait`, with SIGTERM during the wait,
runs `VimLeavePre` and answers both calls (6 of 6 on each version).
- Waiting calls with `close_window`, `:qa!` and SIGTERM, three layouts:
both answers come before the first `ClaudeCodeDiffClosed` (6 of 6 on
each version). A nested `:qa!` that cuts a call off in its setup: the
-32603 comes after every `ClaudeCodeDiffClosed` event (12 of 12 on each
version), and no id is answered twice.
- Independent reads with a real WebSocket client on Neovim 0.8.0, 0.9.5,
0.10.4, 0.11.5 and 0.12.3: a verifier (330 runs), an advisor (225 runs)
and two claim checks (375 runs in the first, and 140 runs for the
nested-handler case in the second). They found that the first wording
dropped the direct `:qa!` case, that the second promised `VimLeavePre`
for a signal during a Lua loop, and that "ends" is false when the
running autocmd started a `nested` handler. The sub-bullet above says
only what survives all three, with "can". A last narrow check found only
that "Without `VimLeavePre`" was too general, because `eventignore` can
skip `VimLeavePre` and a `:qa!` still rejects diffs while buffers
unload. The README now says "In these exits".
- At the head after the merge of main (coder#573): `mise run format-check`
and `mise run all` pass (1892 successes, 0 failures, 0 errors, 35
pending), and the integration runner passes on 0.12.3 (844) and on 0.8.0
(840). The commits of this PR change comments, docs, the spec header and
the lock only. `check.sh --hash-only` passes for the four verify skills,
and the full `verify-diff-lifecycle` check passes (93,477 and 5,557
states).

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
ThomasK33 added a commit that referenced this pull request Nov 13, 2026
The guard said the unified layout needs Neovim 0.9. Main dropped that claim in #564, and the 24 cases pass on v0.8.0.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking:
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 14, 2026
## Why

Neovim refuses window changes in two states: while the user is in the
command-line window (`q:`, E11) and under textlock (E565, for example
while an `<expr>` mapping waits for a key). coder#499 holds the disconnect
cleanup in those states. Five other paths still tear a diff down at that
moment and raise:

- the server stops (`:ClaudeCodeStop`, or the plugin stops itself),
- Claude calls `close_tab`,
- Claude calls `closeAllDiffTabs`,
- a second Claude client sends `openDiff` with the `tab_name` of an open
diff (a replacing openDiff),
- a bulk close of many diffs.

What the user sees on `main` at `d8e767c` (coder#499): `stop` raises E11 or
E565 before the TCP teardown, a pending openDiff call is never answered,
the server keeps running, and the next start says "Already running". A
replacing openDiff raises in `diffoff`, and the old rejected diff stays
on screen after the user leaves the window. The unified layout is worse:
nothing raises, but the registry entry is dropped while its windows
stay, so nothing can close them.

`main` has moved since (`700209ee`, with coder#485 and coder#536), and a replacing
openDiff in the command-line window now behaves differently there. On
Neovim 0.12.3 it no longer raises in `diffoff`. It drops the registry
entry and leaves the old windows, and the new call gets "Diff setup
failed". On v0.8.0 the cleanup still raises E11, and the old rejected
diff stays registered and on screen. This PR's `diff_cleanup_locked`
spec fails 15 of 24 cases on `700209ee` with 0.12.3 and 14 of 24 with
v0.8.0.

## What changed

- `_cleanup_diff_state` now hands the teardown to
`utils.when_windows_allowed`, the gate from coder#499. The old body is
`teardown_diff`, and only the gate calls it. When Neovim allows window
changes, it runs at once, as before. Otherwise the diff stays registered
and the gate's one 50 ms timer runs the teardown later.
- The held teardown runs only while `active_diffs[tab_name]` still holds
the entry that waited, because a new diff can take the tab name before
the timer fires.
- The answer to each waiting call goes out before the teardown, so the
server stop, `close_tab`, `closeAllDiffTabs` and the bulk closes answer
every call at once.
- Many held cleanups share the gate's one timer. A spec pins this with
20 held diffs.
- A replacing openDiff that finds its old diff held by the gate fails at
once with error `-32000` ("Diff setup failed") and runs no setup. A
waiting old call gets `DIFF_REJECTED` (an old diff the user already
accepted keeps its `FILE_SAVED` answer), and the held teardown closes
the old diff after the user leaves the locked state.
- `.agents/skills/verify-diff-lifecycle`: the model has a lock on window
changes. Three facts (`CleanupHoldsWhenLocked`, `CleanupRetries`,
`RetryChecksEntry`) read the gate and `teardown_diff`, and the liveness
rule `DeferredCleanupDone` says a held cleanup runs once windows can
change. After the merge the model also holds coder#536's reject pass and exit
cleanup, about 256,000 states.
- `.agents/skills/verify-claudecode-nvim`: two journeys,
`journey-diff-disconnect-cmdwin.sh` and
`journey-diff-replace-cmdwin.sh`, drive the paths in a real Neovim
terminal with the scripted Claude client.

## Merge of main

This head merges `origin/main` at `700209ee` (coder#485 is in) into the
branch with a normal merge commit (`9baedc48`). No rebase, no force
push. Eight files conflicted. I kept main's code in each one and put
this PR's gate around it.

| File | Resolution |
| --- | --- |
| `lua/claudecode/diff.lua` | Kept main's `end_diff_mode_if_alone`,
`end_diff_mode_in_windows_that_show_the_file`,
`release_surviving_diff_tab` and the `diffoff` changes. `teardown_diff`
now holds main's whole body, including the new-tab release from coder#485.
`_cleanup_diff_state` still calls the gate. The replace loop of coder#536
asserted that the old entry is gone after `_cleanup_diff_state`. With
the gate the entry can stay while windows are locked. The merge turned
the assert into a `break`, and a verifier found that this was wrong (see
"Finding F1" below). `ebff0dc2` replaced the `break` with the call that
fails at once. |
| `lua/claudecode/README.md` | Kept main's bullets and this PR's gate
bullet. The bullet on a refused `:diffoff` now says the cleanup waits
for the gate. |
| `verify-diff-lifecycle/model/DiffLifecycle.tla` | Kept both sides:
main's `RejectEach`, `DiffCleanupAtExit`, `ExitInSetup` and the exit
constants, and this PR's `DeferredCleanupRuns`. `Next` lists all of
them. |
| `verify-diff-lifecycle/check.sh` | The rules end with main's
`ExitedCallsAnswered`. The live properties are
`DisconnectedCallsAnswered` and `DeferredCleanupDone`. |
| `verify-diff-lifecycle/model/statechart-view.ts` and `SKILL.md` | Both
sides kept. The notes state the merged model's size and the replace
loop's exit. |
| `verify-claudecode-nvim/SKILL.md` | Main's journey list plus this PR's
two journeys. |

`covered.lock` of `verify-diff-lifecycle` came from main and I
re-recorded it in its own commit. It was the only STALE lock of the
five. Two more re-records followed, one for a comment-only edit of
`diff.lua` and one for the replace-loop fix. The model's facts still
match the code, and the model never lets a replacing openDiff find
windows locked (its `SKILL.md` says what the code does there).

Every teardown path that main added since `ef610b7d`:

| Path | Gate |
| --- | --- |
| coder#485 new-tab release (`release_surviving_diff_tab`) | Behind the gate.
`teardown_diff` calls it, directly in the split layout and through
`cleanup_inline_diff` in the unified layout, and nothing else calls it.
|
| coder#573 and the split-close helpers (`_close_proposal_window`,
`end_diff_mode_in_windows_that_show_the_file`) | Behind the gate through
`teardown_diff`. |
| coder#536 exit cleanup (`cleanup_at_exit`) | The reject pass resolves
statuses, and its one window change (`keep_empty`) calls
`_cleanup_diff_state`. The cleanup pass calls
`_cleanup_all_active_diffs`. Both go through the gate. |
| coder#536 replace loop in `open_diff_blocking` | Calls
`_cleanup_diff_state`, so it is gated. When the gate holds the old diff,
the loop fails the call before any setup. |
| Setup-failure cleanup and `remove_failed_proposal` | No gate for the
setup's own leftovers (the branch for a diff that the setup already
registered goes through the gate). They run only for a setup that
failed, and they tell a diff that the setup registered from one that was
already registered only by `active_diffs[tab_name]`. The merge's `break`
broke that assumption (F1). The replace loop now fails the call before
its setup runs, so these handlers see only an entry that the setup
registered, or none. |

## Finding F1 (found by a verifier on `0b34b13c`, fixed in `ebff0dc2`)

The merge turned coder#536's assert into a `break`. A replacing openDiff B
then ran its setup while the old diff A was still registered under the
same `tab_name`. When that setup failed in a locked state, its failure
handlers saw A's entry. They held A's teardown a second time and skipped
B's own cleanup. What B's setup had made stayed behind:

- on Neovim 0.8, a window that shows the `[Command Line]` buffer after
the user leaves `q:`,
- a hidden proposal buffer that is marked modified, so `:qa` stops with
E37 on v0.8.0,
- a buffer name that made every later openDiff of that kind and tab name
fail with E95 ("Failed to rename buffer" on v0.8.0).

It needs a second openDiff with the `tab_name` of an open diff while the
user is in `q:` or under textlock. PROTOCOL.md says Claude Code sends a
new random `tab_name` per call, so only other clients that reuse a
`tab_name` reach it.

Fix: when the gate kept A's entry, the replace loop fails B's call with
`-32000` ("Diff setup failed") before any setup. I used this form and
not the verifier's alternative (the failure handlers compare the entry
with the one registered before the setup). Every setup fails in those
states anyway, so the first form runs nothing that would need undoing.
It changes one place, where the alternative changes the failure handlers
in `diff.lua` and in `diff_inline.lua` and still runs a setup that
cannot work. The loop holds A's teardown once. B's setup used to hold it
a second time.

## Known limit

`close_tab` of a diff the user already saved still raises E11 in those
states. The file reload that runs 100 ms after the close
(`reload_file_buffers_manual` from `close_diff_by_tab_name`) does not go
through the gate yet. I re-ran it on this head on 0.12.3 and on v0.8.0,
in the command-line window (E11) and under textlock (E565). The reload
raises from `edit`, and the file's buffer keeps its old text, because
the reload never runs. The diff itself closes after the lock. `main` is
no better: its `close_tab` itself fails in those states, the diff stays
registered, and the reload raises the same way. A follow-up PR routes
the reload through the gate.

A replacing openDiff in those states holds the old diff, and the new
call fails with error `-32000` ("Diff setup failed") before it sets
anything up, so it leaves no window or buffer. The old diff closes once
the user leaves the window. Holding the new openDiff itself is not part
of this PR.

## Tests

I used the `test-audit` authoring gate,
`principle-test-behavior-not-implementation` and `tdd` (failing check
before the fix, then pass after) for every test change after the merge:

- New `diff_replace_locked_spec.lua` (24 cases on each build) drives a
replacing openDiff through `tools.handle_invoke` in the command-line
window and under textlock, in the split and unified layouts, with the
old and the new file of different kinds (existing and new, both orders),
while the user is in another tab. Per combination it checks three
behaviors. The old call gets `DIFF_REJECTED` once, the new call gets one
`-32000` answer, and the old diff stays registered while locked. After
the lock the old diff closes, no window shows `[Command Line]`, no
proposal buffer is left and no window is left over. A later openDiff
with the same tab name and the new call's kind opens and is answered
once. On `0b34b13c` 10 of the 24 cases fail on both builds (the
command-line window, the leftover proposal buffer, and the later
openDiff). With the fix all 24 pass on 0.12.3 and on v0.8.0.
- `diff_left_window_spec.lua`, case "answers close_tab from the
command-line window...": it pinned the pre-gate behavior (the copy keeps
its diff mode). It now expects the diff to stay registered while the
window is open, to close after the window closes, and to end diff mode
in the copy afterwards. The `no gate` mutant turns it red.
- `diff_locked_paths_spec.lua`, replacing openDiff cases: the old
assertion looked for `"error"` in the reply. The mutant that restores
coder#536's "entry is gone" assert in the replace loop survived it (0 red).
The assertion now looks for "Diff setup failed", and the same mutant
turns 2 cases red (command-line window and textlock).
- `diff_cleanup_locked_spec.lua`: I removed its skip of the unified
layout below Neovim 0.9. Main dropped that claim in coder#564, so all 24
cases now run on v0.8.0.
- `diff_locked_paths_spec.lua` counts `vim.defer_fn` calls and uses
`nvim_replace_termcodes` through `tests/helpers/window_lock.lua`,
because v0.8.0 has no `vim.keycode` and `uv.walk` crashes there.
- `journey-diff-replace-cmdwin.sh` no longer sends Enter at a "Press
ENTER" prompt. The refused call now logs nothing, and Enter would run
the command line and close the window.

## Verification

On `d8e767c` I replayed each path over a real socket in headless Neovim
0.12.3 (the textlock cases use `getchar()` in a typed `<expr>` mapping).
Result after the change:

| Path | `main` | this branch |
| --- | --- | --- |
| stop in the command-line window | `stop` raises E11, server keeps
running, restart says "Already running" | stop returns true, both calls
answered, both diffs close after the window, restart works |
| stop under textlock | raises E565, same leftovers | same as above |
| replacing openDiff, command-line window | error names `diffoff`, old
rejected diff stays | new call gets -32000 before its setup, old diff
closes after the window |
| replacing openDiff, textlock | same | same |
| 30 held cleanups | `close_pending_diffs` raises E11 after the first |
30 diffs close, 1 timer armed |

Replacing openDiff, all combinations (a verifier's matrix, rerun by me
on this fix and on `700209ee`, headless, one fresh Neovim per row): 288
scenarios on each of 0.12.3 and v0.8.0, 576 rows. Scenario axes are
layout (vertical, horizontal, unified), new-tab diff or not, lock (`q:`,
textlock, none), user in the same or another tab, old and new file
existing or new, and the old diff pending or saved. Each row also opens
a later openDiff, accepts it and closes its tab.

- No row is worse than `700209ee`, and 376 rows are strictly better. The
384 rows with a lock have no leftover. The other 192 rows have no lock
and give the same result line as `700209ee` in every row.
- `0b34b13c` is worse than `700209ee` in 40 rows (a row can count in
more than one: 24 with a later openDiff that fails, 16 with a leftover
buffer, 8 with an extra window), and none of them is worse on this head.
- With the new call arriving through `vim.schedule` (the way a socket
message arrives), the result is the same: 0 worse rows, 376 better.
- coder#536's close-handler probe (224 runs) and split-path probe (320 runs)
give identical output on `700209ee` and on this head in every run. The
locked close-handler orders (448 runs): 0 problems on this head, 358 on
`700209ee`, none worse.
- The verifier's standalone repro (user in another tab, textlock, new
file, then two later new-file openDiffs) answers both later calls with
E95 on `0b34b13c` and opens both on this head, as `700209ee` does, on
0.12.3 and on v0.8.0.

Gates on the merged head `74bbd458`, one run each, none passed only on
retry:

- `mise run clean` and `mise run all`: exit 0, 2026 successes, 0
failures, 0 errors, 54 pending, luacheck 0 warnings in 227 files, and
all 5 static proofs ok. The new spec is one more pending case under
busted, because it needs a real Neovim.
- Twelve specs, each run in real Neovim on 0.12.3 and on v0.8.0, native
exit 0 each time: `diff_cleanup_locked` 24 cases, `diff_locked_paths` 7,
`diff_disconnect_window_lock` 2, `diff_new_tab_autocmds` 14,
`diff_new_tab_close_mode` 10, `diff_new_tab_last_tab` 12,
`diff_new_tab_mode` 12, `utils_without_autocmds` 4,
`diff_answer_every_call` 9, `diff_close_save` 77, `diff_left_window` 40
and `diff_replace_locked` 24. All 0 failures and 0 errors. The first two
and the last are this PR's specs. The other nine cover the new-tab,
answer-every-call, close-save and left-window teardown code that main
changed, and the disconnect window lock from coder#499.
- `scripts/run_integration_tests_individually.sh` with the stub `claude`
first on `PATH`: 1161 successes on 0.12.3 and 1157 on v0.8.0, 0
failures, 0 errors, exit 0. Each ran twice, once with a plain `TMPDIR`
and once with a `TMPDIR` that holds a space and a percent sign.
- `check.sh` of all five skills: PASS. States: `verify-diff-lifecycle`
255,881 (the two live properties 13,509 each), `verify-mention-queue`
540,240, `verify-selection-tracking` 95,938, `verify-terminal-lifecycle`
279,492, `verify-ws-connection` 262,285. The three fact mutants of the
merged model fail as expected (run before the F1 fix, which does not
touch the model): `CleanupHoldsWhenLocked=FALSE` breaks
`NoRejectedLeftover`, `CleanupRetries=FALSE` breaks
`DeferredCleanupDone`, and `RetryChecksEntry=FALSE` breaks
`NoStrandedCall`.
- `journey-diff-disconnect-cmdwin.sh` and
`journey-diff-replace-cmdwin.sh` both print PASS on this head in a real
Neovim terminal, and `vcn.sh cleanup` leaves no process. They read `OUT`
and `WORK` through `vcn.sh get`, because main's `no-source-run-env`
static proof refused the `source` of `run.env`.
- Eight mutants, each run on the five spec files that cover the gate and
the replace loop (`diff_cleanup_locked`, `diff_locked_paths`,
`diff_disconnect_window_lock`, `diff_left_window`,
`diff_replace_locked`). The count is red cases on 0.12.3 and on v0.8.0,
and a no-op control is green on both:
- no gate (`_cleanup_diff_state` calls `teardown_diff` directly): 38 and
35,
- no identity check (the held function drops the `active_diffs[tab_name]
== diff_data` test): 12 and 12,
- own timer per cleanup (`when_windows_allowed` arms a `vim.defer_fn` on
every call): 3 and 3,
- held function never runs (the retry loop walks an empty list): 39 and
39,
- entry dropped while held (the entry is removed before the gate holds
the teardown): 39 and 37,
  - probe always allows (`windows_allowed` returns true): 40 and 37,
- replace loop breaks (the merge's form, the cause of F1): 10 and 10,
all in `diff_replace_locked`,
- replace loop asserts the entry is gone (coder#536's assert): 2 and 2, both
in `diff_locked_paths`.
The counts depend on the exact edit, so they are not comparable with the
counts of an earlier head.
- Earlier on `d8e767c`: 6 of the 7 `diff_locked_paths_spec.lua` cases
and 16 of the 24 `diff_cleanup_locked_spec.lua` cases fail there.
`journey-diff-replace-cmdwin.sh` fails there at the `diffoff` step. On
`700209ee` this PR's specs fail 15 and 14 of the 24
`diff_cleanup_locked` cases (0.12.3 and v0.8.0), 6 of the 7
`diff_locked_paths` cases on both, and 16 and 14 of the 24
`diff_replace_locked` cases.

Refs coder#483, coder#485, coder#499. coder#485 is merged, and this PR resolves the overlap
in `_cleanup_diff_state`.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant