From 3a7df251dcdfff3155e16534af44680b4bb8fca1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 10:27:45 +0000 Subject: [PATCH 01/13] docs: refresh EGM README contracts against current code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-ran egm-bootstrap over lua/, plugin/ and scripts/, then an EGM drift pass, and fixed every claim the code contradicted or left uncovered. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- README.md | 45 ++++++++----- lua/claudecode/README.md | 105 +++++++++++++++++++++--------- lua/claudecode/server/README.md | 42 ++++++++---- lua/claudecode/terminal/README.md | 42 +++++++----- lua/claudecode/tools/README.md | 45 +++++++++---- plugin/README.md | 80 ++++++++++++++++++----- scripts/README.md | 105 +++++++++++++++++------------- 7 files changed, 312 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 703b8eaf1..470865b60 100644 --- a/README.md +++ b/README.md @@ -975,8 +975,9 @@ of one package. ### System Overview Neovim loads [`plugin/claudecode.lua`](./plugin/README.md), which guards the load and can schedule -`require("claudecode").setup()`. `setup` in [`lua/claudecode`](./lua/claudecode/README.md) validates config, registers the -`:ClaudeCode*` commands, and (when `auto_start` is true) calls `start`. `start` opens the +`require("claudecode").setup()`. `setup` in [`lua/claudecode`](./lua/claudecode/README.md) validates config, sets up +the logger, terminal, and diff modules, calls `start` when `auto_start` is true, and then registers the `:ClaudeCode*` +commands. `start` opens the [WebSocket server](./lua/claudecode/server/README.md) on `127.0.0.1`, writes a lock file with an auth token, and enables selection tracking when `track_selection` is true. The Claude CLI finds the lock file, connects, and calls the [MCP tools](./lua/claudecode/tools/README.md). The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/README.md). Files under @@ -1014,34 +1015,48 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ - A WebSocket upgrade must carry `x-claude-code-ide-authorization` that matches the lock-file token, compared in constant time [lua/claudecode/server/handshake.lua:M.validate_upgrade_request]. `start` returns `false` before it starts the server if the token is shorter than 10 characters. It stops the server if the token does not match the token that - `lockfile.create` wrote [lua/claudecode/init.lua:M.start]. + `lockfile.create` wrote, and it leaves that lock file on disk [lua/claudecode/init.lua:M.start]. - The token comes from `vim.loop.random` or `/dev/urandom`, never `math.random` [lua/claudecode/lockfile.lua:get_random_bytes]. -- Lock files live in `$CLAUDE_CONFIG_DIR/ide` or else `~/.claude/ide`, named `.lock` +- Lock files live in `$CLAUDE_CONFIG_DIR/ide`, or in `~/.claude/ide` when that variable is unset or empty, named `.lock` [lua/claudecode/lockfile.lua:M.lock_dir]. The shell clients that use `find_claude_lockfile` read the port from that basename [scripts/lib_claude.sh:find_claude_lockfile]. `repro_issue_70.sh` and `repro_issue_277.sh` get the port another way. - `:ClaudeCode*` commands exist only after `setup` runs [lua/claudecode/init.lua:M.\_create_commands]. The loader defines none [plugin/claudecode.lua]. -- `openDiff` must run in a non-main coroutine. Its response is deferred until the user accepts or rejects the diff - [lua/claudecode/diff.lua:M.open_diff_blocking] [lua/claudecode/server/init.lua:M.\_setup_deferred_response]. A diff - that resolves before `openDiff` yields, for example from a `ClaudeCodeDiffOpened` handler, is answered at once - [lua/claudecode/diff.lua:M.open_diff_blocking]. +- `openDiff` must run in a non-main coroutine. Its response is deferred until the diff resolves + [lua/claudecode/diff.lua:M.open_diff_blocking] [lua/claudecode/server/init.lua:M.\_setup_deferred_response]. The user + resolves it by accepting or rejecting it. The plugin also rejects a pending diff when the server stops + [lua/claudecode/server/init.lua:M.stop], when the client that opened it disconnects + [lua/claudecode/server/init.lua:M.start], or when a new diff reuses its `tab_name` + [lua/claudecode/diff.lua:M.open_diff_blocking]. A diff that resolves before `openDiff` yields, for example from a + `ClaudeCodeDiffOpened` handler, is answered at once [lua/claudecode/diff.lua:M.open_diff_blocking]. ### Global Side Effects -- Lock file: `start` writes `.lock` (file `0600`, atomic rename). A new lock directory gets `0700`, and the chmod of an existing one is best-effort [lua/claudecode/lockfile.lua:M.create]. -- Network: one loopback TCP listener plus WebSocket frames and JSON-RPC broadcasts (`selection_changed`, `at_mentioned`) - [lua/claudecode/server/init.lua:M.start] [lua/claudecode/selection.lua:M.send_selection_update]. +- Lock file: `start` writes `.lock` (file `0600`, atomic rename). A new lock directory gets `0700`, and the chmod of an existing one is best-effort [lua/claudecode/lockfile.lua:M.create]. `stop` deletes it + [lua/claudecode/lockfile.lua:M.remove]. The `ClaudeCodeShutdown` `VimLeavePre` autocmd runs `stop` + [lua/claudecode/init.lua:M.setup]. +- Network: one loopback TCP listener [lua/claudecode/server/init.lua:M.start] plus WebSocket frames and JSON-RPC + broadcasts [lua/claudecode/server/init.lua:M.broadcast]: `selection_changed` + [lua/claudecode/selection.lua:M.send_selection_update] and `at_mentioned` [lua/claudecode/init.lua:M.\_broadcast_at_mention]. - Processes: the terminal provider spawns the Claude CLI with `ENABLE_IDE_INTEGRATION`, `FORCE_CODE_TERMINAL`, loopback `no_proxy`/`NO_PROXY`, and, while the server runs, `CLAUDE_CODE_SSE_PORT` [lua/claudecode/terminal.lua:get_claude_command_and_env]. - Disk: `open_diff` writes diff temp files under `stdpath("cache")/claudecode_diffs`, or `stdpath("cache")/claudecode_diffs_fallback` when a directory cannot be created in the first. If both fail, it returns an error that names both directories [lua/claudecode/diff.lua:create_temp_file]. The `openDiff` tool writes no temp - files [lua/claudecode/diff.lua:M.open_diff_blocking]. + files [lua/claudecode/diff.lua:M.open_diff_blocking]. The `saveDocument` tool writes an open buffer to its file + [lua/claudecode/tools/save_document.lua:handler]. - Editor state: autocmd groups (`ClaudeCodeShutdown`, `ClaudeCodeSelection`, `ClaudeCodeMCPDiff`, - `ClaudeCodeDiffCleanup`), `User` events (`ClaudeCodeSendComplete`, `ClaudeCodeDiffOpened`, `ClaudeCodeDiffClosed`), - and a wrapped global `vim.paste` when `terminal.fix_streamed_paste` is `true`, or `"auto"` on Neovim < 0.12.2 - [lua/claudecode/terminal/paste_fix.lua:M.apply] [lua/claudecode/terminal/paste_fix.lua:M.install]. + `ClaudeCodeDiffCleanup`, `ClaudeCodeNativeAdopted`, `ClaudeCodeOpenFileNewFiles`), `User` events + (`ClaudeCodeSendComplete`, `ClaudeCodeDiffOpened`, `ClaudeCodeDiffClosed`), the four global `ClaudeCodeInlineDiff*` + highlight groups for the `"unified"` diff layout [lua/claudecode/diff_inline.lua:setup_highlights], and a wrapped + global `vim.paste` when `terminal.fix_streamed_paste` is `true`, or `"auto"` on Neovim < 0.12.2 or a 0.12.2 + prerelease [lua/claudecode/terminal/paste_fix.lua:M.apply] [lua/claudecode/terminal/paste_fix.lua:M.install] + [lua/claudecode/terminal/paste_fix.lua:M.is_affected_version]. +- Lua globals: the server stores deferred `openDiff` response senders in `_G.claude_deferred_responses` + [lua/claudecode/server/init.lua:M.\_setup_deferred_response]. The diff module calls and clears them + [lua/claudecode/diff.lua:M.open_diff_blocking]. `_G.claudecode_clients_created` counts connections + [lua/claudecode/server/client.lua:M.create_client]. See the [server README](./lua/claudecode/server/README.md). - Release tooling: `scripts/release-please-runner.js` without `--dry-run` creates GitHub tags, releases, and PRs [scripts/release-please-runner.js:main]. diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index dffa26f68..44b5b3d6c 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -46,21 +46,25 @@ Module map (direct files): - `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.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.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] +- `M.open_with_model(args)`, `M.get_version()`, and `M.version`, read from the first line of the repository's `VERSION` file at load. Without a readable, well-formed `VERSION` file the version is `0.0.0-unknown` [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]. Nothing outside `tests/` calls `M._add_paths_to_claude` or `M._ensure_terminal_visible_if_connected`. The user commands use a local `add_paths_to_claude` that calls `M.send_at_mention` for each path, with no file cap and no batches [init.lua:M._create_commands] ### User commands [init.lua:M._create_commands] - Server: `:ClaudeCodeStart`, `:ClaudeCodeStop`, and `:ClaudeCodeStatus`. - Context: `:ClaudeCodeSend` (range, tree-aware), `:ClaudeCodeTreeAdd`, and `:ClaudeCodeAdd [start] [end]`. -- Terminal: `:ClaudeCode [args]`, `:ClaudeCodeFocus [args]`, `:ClaudeCodeOpen [args]`, `:ClaudeCodeClose`, and `:ClaudeCodeSendText[!] `. These commands exist only when `claudecode.terminal` loads. +- `:ClaudeCodeAdd` reads Neovim's split arguments (`opts.fargs`), so a path with escaped spaces, such as the expansion of `%` for a buffer named `my file.lua`, stays one argument. It needs a running server and at most 3 arguments. Only a path that starts with `%`, `#`, or `<` goes through `vim.fn.expand`. Other paths expand only a leading `~`, so `$VAR` stays literal. The path must be a readable file or a directory. Line numbers must be at least 1, and start must not be past end. +- `:ClaudeCodeTreeAdd` refuses without a running server. The tree handlers detect a tree buffer by filetype only, and the visual `:ClaudeCodeSend` handler does not accept `snacks_picker_list`, which the normal handler accepts. +- Terminal: `:ClaudeCode [args]`, `:ClaudeCodeFocus [args]`, `:ClaudeCodeOpen [args]`, `:ClaudeCodeClose`, and `:ClaudeCodeSendText[!] `. These commands exist only when `claudecode.terminal` loads. `:ClaudeCodeSendText!` inserts the text without submitting it. - Diff: `:ClaudeCodeDiffAccept`, `:ClaudeCodeDiffDeny`, and `:ClaudeCodeCloseAllDiffs`. -- Model: `:ClaudeCodeSelectModel [args]`. +- Model: `:ClaudeCodeSelectModel [args]`. It exists even when `claudecode.terminal` does not load, and its choice runs `:ClaudeCode --model [args]` [init.lua:M.open_with_model] ### User autocmd events - `User ClaudeCodeSendComplete` with `data = { file_path, start_line, end_line, context }` [init.lua:fire_send_complete] -- `User ClaudeCodeDiffOpened` and `User ClaudeCodeDiffClosed` [diff.lua:fire_diff_event] +- `User ClaudeCodeDiffOpened` with `data = { tab_name, file_path, new_file_path, is_new_file, diff_window, target_window, terminal_window, tab_number }`, fired after the diff is registered [diff.lua:M._setup_blocking_diff] +- `User ClaudeCodeDiffClosed` with `data = { tab_name, file_path, reason }`, for example `replaced by new diff`, `diff tab closed after save`, `diff tab closed after reject`, `setup failed`, or `shutdown` [diff.lua:M._cleanup_diff_state] +- `fire_diff_event` runs the autocmds in `pcall` with `modeline = false`, so an error in a handler never reaches the diff code [diff.lua:fire_diff_event] ### Configuration (`config.lua`) @@ -71,76 +75,111 @@ Module map (direct files): ### Other modules - `terminal.lua`: `setup`, `open`, `close`, `simple_toggle`, `focus_toggle`, `toggle`, `toggle_open_no_focus`, `ensure_visible`, `get_active_terminal_bufnr`, and `send_to_terminal(text, { submit = true, focus = false })` [terminal.lua:M.send_to_terminal] +- `terminal.lua`: `open`, `simple_toggle`, and `focus_toggle` take `(opts_override, cmd_args)` and call the provider method of the same name with `(cmd_string, env_table, effective_config)`. `M.toggle` calls `M.simple_toggle`, not the provider's `toggle`. `toggle_open_no_focus` and `ensure_visible` call the provider's `ensure_visible` when it has one, else open the terminal without focus when it is not visible [terminal.lua:M.toggle] [terminal.lua:ensure_terminal_visible_no_focus] - `terminal.lua` test hook: `_get_managed_terminal_for_test()` returns the provider's `_get_terminal_for_test()` result, or nil [terminal.lua:M._get_managed_terminal_for_test] -- `terminal.lua` defaults: `split_side = "right"`, `split_width_percentage = 0.30`, `provider = "auto"`, `auto_close = true`, `auto_insert = true`, `fix_streamed_paste = "auto"`, and `git_repo_cwd = false` [terminal.lua:M.defaults] -- `diff.lua`: `setup`, `open_diff(old_file_path, new_file_path, new_file_contents, tab_name)` (wraps `_open_native_diff`, a legacy path that nothing in the plugin calls. It never checks for a user's diff. It opens the old file in the main editor window, current tab first, and runs `:diffthis` there, so it joins a vimdiff in that tab. With no main editor window, it uses the window right of the top-left one, which can be one of the user's diff windows), `open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name, client_id)`, `close_diff_by_tab_name`, `close_all_diffs`, `close_pending_diffs`, `close_rejected_diffs`, `close_saved_diffs`, `close_diffs_for_client`, `accept_current_diff`, `deny_current_diff`, and `reload_file_buffers_manual` [diff.lua:M.open_diff_blocking] +- `terminal.lua` defaults: `split_side = "right"`, `split_width_percentage = 0.30`, `diff_split_width_percentage = nil`, `provider = "auto"`, `show_native_term_exit_tip = true`, `auto_close = true`, `auto_insert = true`, `env = {}`, `snacks_win_opts = {}`, `fix_streamed_paste = "auto"`, `cwd = nil`, `git_repo_cwd = false`, and `cwd_provider = nil` [terminal.lua:M.defaults] +- `diff.lua`: `setup`, `open_diff(old_file_path, new_file_path, new_file_contents, tab_name)` (wraps `_open_native_diff`, a legacy path that nothing in the plugin calls. It never checks the tab for a user's diff. It opens the old file in the main editor window, current tab first, which is never itself a diff window, and runs `:diffthis` there, so it joins a vimdiff in that tab. With no main editor window, it uses the window right of the top-left one, which can be one of the user's diff windows, and splits it when it shows a terminal or `nofile` buffer or is the preview window), `open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name, client_id)`, `close_diff_by_tab_name`, `close_all_diffs`, `close_pending_diffs`, `close_rejected_diffs`, `close_saved_diffs`, `close_diffs_for_client`, `accept_current_diff`, `deny_current_diff`, and `reload_file_buffers_manual` [diff.lua:M.open_diff_blocking] +- `diff.lua` internals that `diff_inline.lua` depends on: `_register_diff_state`, `_get_active_diffs`, `_cleanup_diff_state`, `_resolve_diff_as_saved`, `_resolve_diff_as_rejected`, `_save_window_sizes`, `_restore_window_sizes`, `_restore_cursor`, `_resize_terminal_for_diff`, `_find_main_editor_window`, `_find_claudecode_terminal_window`, `_leave_preview_window`, `_is_buffer_dirty`, `_detect_filetype`, `_get_autocmd_group`, and `_display_terminal_in_new_tab` [diff.lua:M._register_diff_state] [diff.lua:M._get_active_diffs] +- `diff.lua` internals exposed for testing: `_resolve_split_width_percentage`, `_auto_resize_enabled`, `_tabpage_has_diff_window`, and `_register_diff_autocmds`. `_setup_blocking_diff` calls `_create_diff_view_from_window` through `M`, so tests can replace it [diff.lua:M._register_diff_autocmds] [diff.lua:M._create_diff_view_from_window] - `diff_inline.lua`: `setup_inline_diff`, `compute_inline_diff`, `extract_new_content`, `render_diff_buffer`, `resolve_inline_as_saved`, `resolve_inline_as_rejected`, and `cleanup_inline_diff` [diff_inline.lua:M.setup_inline_diff] - `selection.lua`: `enable(server, visual_demotion_delay_ms)`, `disable`, `get_latest_selection`, `send_current_selection`, `get_range_selection`, `send_at_mention_for_visual_selection`, and the autocmd handlers `on_cursor_moved`, `on_mode_changed`, and `on_text_changed` [selection.lua:M.enable] -- `selection.lua` internals: `state`, `debounce_update`, `update_selection`, `handle_selection_demotion`, `get_visual_selection`, `get_visual_selection_from_marks`, `flush_visual_selection`, `get_cursor_position`, `has_selection_changed`, and `send_selection_update` [selection.lua:M.update_selection] +- `selection.lua` internals: `state`, `server` (set by `enable`, cleared by `disable`), `debounce_update`, `update_selection`, `handle_selection_demotion`, `get_visual_selection`, `get_visual_selection_from_marks`, `flush_visual_selection`, `get_cursor_position`, `has_selection_changed`, `send_selection_update`, `_cancel_debounce_timer`, `_cancel_demotion_timer`, `_create_autocommands`, and `_clear_autocommands` [selection.lua:M.update_selection] [selection.lua:M.enable] - `lockfile.lua`: `lock_dir`, `generate_auth_token`, `create(port, auth_token?) -> ok, path, token`, `remove`, `update`, `get_auth_token`, and `get_workspace_folders` [lockfile.lua:M.create] -- `integrations.lua`: `get_selected_files_from_tree()` dispatches on filetype to `_get_nvim_tree_selection`, `_get_neotree_selection`, `_get_oil_selection`, `_get_mini_files_selection`, `_get_mini_files_selection_with_range(start_line, end_line)`, `_get_netrw_selection`, and `_get_snacks_picker_selection` [integrations.lua:M.get_selected_files_from_tree] +- `lockfile.update(port)` removes the lock file and calls `create(port)` without a token, so the new file holds a newly generated token that no longer matches the server's token. Nothing outside `tests/` calls it [lockfile.lua:M.update] +- `lockfile.get_workspace_folders()` returns `getcwd()` first, then each LSP client's workspace folder with `file://` removed (no percent-decoding), without duplicates [lockfile.lua:M.get_workspace_folders] +- `integrations.lua`: `get_selected_files_from_tree()` picks a reader by filetype: `NvimTree` (`_get_nvim_tree_selection`), `neo-tree` (`_get_neotree_selection`), `oil` (`_get_oil_selection`), `minifiles` (`_get_mini_files_selection`), `netrw` (`_get_netrw_selection`), and `snacks_picker_list` (`_get_snacks_picker_selection`). For any other filetype it returns `nil` and an error, not `{}` [integrations.lua:M.get_selected_files_from_tree] +- `integrations._get_mini_files_selection_with_range(start_line, end_line)` is not a dispatch target. The visual `:ClaudeCodeSend` handler calls it in a `minifiles` buffer [integrations.lua:M._get_mini_files_selection_with_range] [init.lua:M._create_commands] +- The tree readers keep these paths: nvim-tree drops a root-level file (`^/[^/]*$`), oil skips `.` and `..` and adds `/` to a directory, and mini.files, netrw, and the snacks picker keep only paths that exist [integrations.lua:M._get_nvim_tree_selection] [integrations.lua:M._get_oil_selection] - `visual_commands.lua`: `create_visual_command_wrapper`, `exit_visual_and_schedule`, `validate_visual_mode`, `get_visual_range`, `get_tree_state`, `capture_visual_selection_data`, and `get_files_from_visual_selection` [visual_commands.lua:M.create_visual_command_wrapper] -- `cwd.lua`: `resolve(term_cfg, ctx)` and `git_root(start_dir)` [cwd.lua:M.resolve] +- `visual_commands.get_tree_state()` knows only neo-tree (when its window is current), nvim-tree, oil, and netrw. It returns `nil` for `minifiles` and `snacks_picker_list` [visual_commands.lua:M.get_tree_state] +- `cwd.lua`: `resolve(term_cfg, ctx)` and `git_root(start_dir)` [cwd.lua:M.resolve]. Nothing in `lua/` calls `cwd.resolve`. `terminal.lua:build_config` has its own copy of the cwd order and calls only `cwd.git_root`, so a change to `resolve` does not change the terminal [terminal.lua:build_config] - `utils.lua`: `shell_split`, `parse_command`, `expand_tilde`, `normalize_focus`, `selection_type`, `utf8_sequence_length`, and `valid_utf8` [utils.lua:M.parse_command] [utils.lua:M.valid_utf8] - `logger.lua`: `levels`, `setup`, `error`, `warn`, `info`, `one_line`, `debug`, `trace`, and `is_level_enabled` [logger.lua:M.setup] -- `health.lua`: `check()` [health.lua:M.check] +- `health.lua`: `check()`. It checks, in order: Neovim 0.8.0 or later, `require("claudecode")`, that `setup()` ran, the CLI (the first word of `terminal_cmd`, or `claude`) and its `--version`, the terminal provider, then the server, its lock file, and a connected client. It stops after a failed load, a missing `setup()`, or a stopped server [health.lua:M.check] [health.lua:check_cli] [health.lua:check_server] ## Invariants ### Lifecycle and auth -- `setup` runs `config.apply` before logger, terminal, and diff setup. The auto-start runs only after `diff.setup` [init.lua:M.setup] +- `setup` runs `config.apply` before logger, terminal, and diff setup. The auto-start runs only after `diff.setup`, and the user commands are created after the auto-start [init.lua:M.setup] +- `setup` writes the top-level `git_repo_cwd`, `cwd`, and `cwd_provider` options into the caller's `opts.terminal` table, and passes that table, not `config.terminal`, to `terminal.setup`. Before `setup`, `M.state.config` is the `config.defaults` table itself [init.lua:M.setup] [init.lua:M.state] +- `setup` warns when `focus_after_send = true` and the provider is `none`, or `external` with a usable `external_terminal_cmd`, because those providers cannot take the focus [init.lua:M._maybe_warn_unfocusable_provider] - `start` refuses with "Already running". The auth token must be a string of length 10 or more. If `server.start` fails, `start` returns the error at once. If the lock file fails, `start` stops the server and clears its state [init.lua:M.start] -- `start` requires the token returned by `lockfile.create` to equal the generated token. Otherwise it stops the server [init.lua:M.start] +- `start` requires the token returned by `lockfile.create` to equal the generated token. Otherwise it stops the server, but it does not remove the lock file that `create` wrote [init.lua:M.start] - `stop` returns "Not running" without a server. When `lockfile.remove` returns an error, `stop` only logs a warning. `lockfile.remove` returns `true` even when `os.remove` fails without raising (for example in a read-only directory), so that failure is silent and the lock file stays [init.lua:M.stop] [lockfile.lua:M.remove] +- `stop` removes the lock file, and disables selection tracking when `track_selection` is set, before it stops the server. When `server.stop` fails, `stop` returns the error and keeps `state.server`, although the lock file is gone [init.lua:M.stop] - The auth token is 32 lowercase hex characters from `vim.loop.random` or `/dev/urandom`. It never comes from `math.random` [lockfile.lua:get_random_bytes] -- The lock directory is `$CLAUDE_CONFIG_DIR/ide` when that variable is set, else `~/.claude/ide`. It is resolved once when the module loads [lockfile.lua:M.lock_dir] +- The lock directory is `$CLAUDE_CONFIG_DIR/ide` when that variable is set and not empty, else `~/.claude/ide`. It is resolved once when the module loads [lockfile.lua:M.lock_dir] +- `health.check` builds the lock-file path `lock_dir/.lock` itself, so a change to the lock-file name must change `health.lua` too [health.lua:check_server] - `lockfile.create` requires a port in 1..65535 and a caller token of 10..500 characters. It writes JSON `{ pid, workspaceFolders, ideName = "Neovim", transport = "ws", authToken }` [lockfile.lua:M.create] ### Configuration and terminal - `config.validate` asserts most top-level fields. It does not assert `disable_broadcast_debouncing` or `enable_broadcast_debouncing_in_tests`. Examples: `0 < port_range.min <= max <= 65535`, `log_level` in `trace|debug|info|warn|error`, `env` values are strings, `models` is a non-empty list of `{ name, value }`, and `diff_opts.layout` is `vertical|horizontal|unified`. For `terminal` it asserts only that it is a table and checks `provider_opts`. It does not assert `terminal.provider` or other `terminal.*` keys [config.lua:M.validate] -- `config.apply` deep-copies the defaults. It maps the legacy `vertical_split` option to `layout`, and legacy `open_in_current_tab` to `open_in_new_tab` [config.lua:M.apply] +- `config.validate` also asserts `visual_demotion_delay_ms >= 0`, `connection_wait_delay >= 0`, `connection_timeout > 0`, `queue_timeout > 0`, boolean `auto_start` and `track_selection`, and a nil or string `terminal_cmd`. It checks `focus_after_send` and each `diff_opts` key only when set. `external_terminal_cmd` must be a string or a function, and a non-empty string must contain `%s`. `types.lua` also allows a table there, which `validate` rejects [config.lua:M.validate] [types.lua:ClaudeCodeTerminalProviderOptions] +- `config.apply` deep-copies the defaults. It maps the legacy `vertical_split` option to `layout`, and legacy `open_in_current_tab` to `open_in_new_tab`. A legacy option set as a boolean overwrites the new option [config.lua:M.apply] +- `config.lua` requires no other module, so `apply` never loads `claudecode.terminal` [config.lua:M.defaults] - Provider selection: `auto` uses snacks if available, else native. `external` requires `external_terminal_cmd` as a function or as a string containing `%s`. Every other failure falls back to `native` [terminal.lua:get_provider] +- `get_provider` picks the provider again on every call. Only the provider modules are cached [terminal.lua:get_provider]. When a `snacks`, `external`, `none`, or custom table provider fails, each call logs a warning that it falls back to `native`. `terminal.setup` calls it too [terminal.lua:M.setup] +- `terminal.setup` changes `M.defaults` in place. It always sets `terminal_cmd` and `env` from its own arguments and ignores a `terminal_cmd` key in the terminal options. An invalid `provider` value keeps the previous value (default `auto`), although the warning says "Defaulting to 'native'" [terminal.lua:M.setup] +- `terminal.setup` warns through `vim.notify` directly, not through the logger. So its warnings show at once, and `log_level` does not filter them. It warns on a terminal config that is not a table, an invalid `terminal_cmd` or `env`, an invalid value for a known key, and an unknown key [terminal.lua:M.setup] +- A per-call `opts_override` changes only a key that has a validator (`split_side`, `split_width_percentage`, `snacks_win_opts`, `cwd`, `git_repo_cwd`, `cwd_provider`) and a non-nil current value. So a per-call `cwd` or `cwd_provider` is ignored unless `setup` set one. Providers get only `split_side`, `split_width_percentage`, `auto_close`, `auto_insert`, `snacks_win_opts`, and the resolved `cwd` [terminal.lua:build_config] - The Claude command is `terminal_cmd`, or `claude` when it is unset. `no_proxy` and `NO_PROXY` always include `localhost`, `127.0.0.1`, and `::1` [terminal.lua:get_claude_command_and_env] - Terminal cwd precedence: `cwd_provider`, then static `cwd`, then the git root when `git_repo_cwd` is set [terminal.lua:build_config] -- `send_to_terminal` never opens a terminal. It refuses the `none` and `external` providers. It wraps multi-line text in bracketed-paste markers [terminal.lua:M.send_to_terminal] +- `send_to_terminal` never starts a terminal: without a valid terminal buffer it returns `false`. With `focus = true` it calls `M.open()` after a successful send, which can show a hidden terminal. It refuses the `none` and `external` providers, because they have no buffer. It turns CRLF and CR into LF and wraps multi-line text in bracketed-paste markers. It returns `false` when the buffer has no job channel or `chansend` fails [terminal.lua:M.send_to_terminal] - `utils.parse_command` does no globbing or variable expansion, so model aliases such as `opus[1m]` stay intact [utils.lua:M.parse_command] ### @-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] -- 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] +- A mention sent while disconnected goes to the queue, and the terminal opens. The queue drops mentions older than `queue_timeout` [init.lua:M.process_mention_queue]. The first mention queued while disconnected starts a `connection_timeout` timer that clears the queue. Only a flush while connected resets that timer, so after one timeout no new timeout starts until Claude connects [init.lua:start_connection_timeout_if_needed] +- A queued mention keeps its raw path and lines: it gets no cwd-relative form and no directory `/`, and it fires no `ClaudeCodeSendComplete` [init.lua:M.send_at_mention] [init.lua:queue_mention] +- A connected send calls `terminal.open()` when `focus_after_send` is set, else `terminal.ensure_visible()`, then fires `ClaudeCodeSendComplete` with the formatted path. Success means the mention was accepted, not delivered [init.lua:M.send_at_mention] +- Connected mentions are debounced by 50 ms, unless `disable_broadcast_debouncing` is set, or busted runs without `enable_broadcast_debouncing_in_tests`. A flush sends one mention every 25 ms. After a new connection it waits `connection_wait_delay` and polls for the handshake every `max(50, floor(connection_wait_delay / 4))` ms [init.lua:M._broadcast_at_mention] [init.lua:M.process_mention_queue] +- `_format_path_for_at_mention` raises on an empty path. When busted is not loaded, it also raises on a path that is neither readable nor a directory. It makes a path under the cwd relative by cutting `#cwd + 1` characters, with no check that a path separator follows the cwd. So with cwd `/a/proj`, the path `/a/proj2/x` becomes `/x`. A directory gets a trailing `/`, and the cwd itself becomes `./` [init.lua:M._format_path_for_at_mention] +- Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms. Claude terminal buffers are skipped: the active terminal buffer, and any `term://` buffer whose name contains `claude` (any case) [selection.lua:M.update_selection] +- Tracking listens to `CursorMoved`, `CursorMovedI`, `BufEnter`, `ModeChanged`, and `TextChanged`, not `TextChangedI` [selection.lua:M._create_autocommands] +- After Visual mode ends, the visual selection stays the latest selection until the cursor leaves the position it had at the exit. Then, after `visual_demotion_delay_ms`, the cursor position replaces it. A move to the Claude terminal keeps the visual selection [selection.lua:M.handle_selection_demotion] [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. It runs `update_selection` first, so a changed selection goes out twice [selection.lua:M.send_current_selection] +- `has_selection_changed` compares only `filePath`, `text`, and the start and end positions [selection.lua:M.has_selection_changed] - 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] - A visual selection is flushed on visual exit. The flush is skipped if the buffer changed during visual mode [selection.lua:M.flush_visual_selection] - `selection_changed` positions are 0-based lines and 0-based UTF-8 byte offsets into the buffer line, not VS Code's UTF-16 code units. `end` is exclusive. A position inside a multibyte code point, such as a stale mark after an edit and `gv`, moves back to the code point's first byte [selection.lua:code_point_start]. A charwise or blockwise selection ends after the character that starts at its end position. That character is what `y` takes as one (`vim.fn.byteidx`): it includes its composing characters, and on Neovim 0.11+ the rest of its grapheme cluster. On Neovim 0.11+ a control, such as a TAB, U+0085 or U+200B, is a character of its own, without the composing characters after it. So is an invalid byte on every version, although before 0.11 the cursor can skip the composing characters after it. With `'selection'` set to `exclusive`, a selection of more than one position ends after the character before that position instead. Before 0.11, `y` can leave out composing characters that follow an invalid byte there, and the selection keeps them [selection.lua:selection_end_byte] - A charwise selection that includes a line break (`v$`) ends at the start of the next line, and its text ends with that line break, as Neovim yanks it. On the buffer's last line, for a blockwise selection, and with `'virtualedit'` containing `all` (where the end is a virtual space), the end stays at the end of the line. On an empty line, and for a block whose last row is empty, that end is `character` 1, because Claude Code counts an end at `character` 0 as the line before. With `all`, the text has none of the virtual spaces that Neovim's `y` adds. A blockwise selection sends the contiguous span between its first and last positions, not the rectangle [selection.lua:end_past_line] - A linewise selection ends on its last line at that line's byte length. When that line is empty and another line follows, it ends at the start of the next line, and its text ends with the empty line's line break, because Claude Code counts an end at `character` 0 as the line before. An empty last line of the buffer ends at `character` 1 [selection.lua:extract_linewise_text] - A mention sent without a range from a tracked selection that ends at `character` 0 of a line ends on the line before [selection.lua:M.send_at_mention_for_visual_selection] +- `send_at_mention_for_visual_selection` needs tracking enabled, a running server, a non-empty selection, and a selection in the current buffer. Otherwise it logs and returns `false` [selection.lua:M.send_at_mention_for_visual_selection] +- `get_range_selection(line1, line2)` does not follow the linewise end rules above. Its end is the byte length of the last line, so an empty last line ends at `character` 0, and its text is not repaired with `valid_utf8` [selection.lua:M.get_range_selection] - Selection text sent to Claude is always valid UTF-8. Each invalid byte sequence in the buffer becomes U+FFFD, so the text can be longer than the byte range of its positions [utils.lua:M.valid_utf8] ### Diffs - `open_diff_blocking` must run inside a non-main coroutine. Otherwise it raises `-32000` [diff.lua:M.open_diff_blocking] - `open_diff_blocking` makes `old_file_path` absolute against the cwd at the call, so a later `:cd` does not change the file a diff points at [diff.lua:M.open_diff_blocking] -- A diff opens in a new tab when `diff_opts.open_in_new_tab` is set, or when the current tab or the tab of the window it would use already has a diff window. `close_tab` then goes back to the tab the user was in. Otherwise it starts from a window that shows the old file or from the main editor window, neither of which is a diff window, the preview window, or a floating window. When neither exists, it splits the current window [diff.lua:M._setup_blocking_diff] [diff.lua:find_main_editor_window] +- A diff whose status is `saved` and whose `client_id` is set and differs from the caller's holds edits for another Claude client. `open_diff_blocking` then refuses the tab name with `-32000` "Diff tab in use", and `close_diff_by_tab_name` with a `client_id` keeps that diff and returns `false` [diff.lua:holds_edits_for_other_client] [diff.lua:M.close_diff_by_tab_name] +- A diff opens in a new tab when `diff_opts.open_in_new_tab` is set, or when the current tab or the tab of the window it would use already has a diff window. `close_tab` then goes back to the tab the user was in. Otherwise it starts from a window that shows the old file or from the main editor window, neither of which is a diff window, the preview window, or a floating window. The main editor window also never shows a `terminal` or `prompt` buffer or a file-explorer or outline sidebar. When neither exists, it splits the current window [diff.lua:M._setup_blocking_diff] [diff.lua:find_main_editor_window] - The current tab comes first, as VS Code opens a diff in the active editor group: the current tab's window that shows the old file, then the current tab's main editor window, then the same two in the other tabs. A `unified` diff skips the window that shows the old file and opens next to the main editor window, current tab first. It is not a Neovim diff, so a user's diff in the current tab does not give it a new tab. Only when the current tab has neither does the diff go to another tab, and Neovim then switches to that tab. For example, with only the preview window in the current tab and the Claude terminal next to an empty window in another tab, the diff opens in that other tab. A `vertical` or `horizontal` diff of an existing file that starts from a window with an empty, unnamed buffer opens the file there, and that window keeps showing the file after `close_tab` [diff.lua:windows_current_tab_first] [diff.lua:choose_original_window] -- A new diff with an existing tab name first rejects the old diff (if pending), then cleans it up [diff.lua:M.open_diff_blocking] +- In the current tab, a `unified` diff opens in a new split right of the main editor window. With no main editor window anywhere, it first splits the current window into an empty scratch window, so the diff adds two windows, and cleanup closes both. In a new tab, it reuses the empty window that `:tabnew` made while that window is untouched, so the tab holds no blank pane. It splits beside that window when a `TabNew` handler filled it or when the tab holds only the terminal [diff_inline.lua:M.setup_inline_diff] [diff_inline.lua:create_fallback_editor_window] [diff_inline.lua:shows_untouched_tabnew_buffer] +- A `unified` diff needs `vim.text.diff` or `vim.diff` (Neovim 0.9 or later) and raises `-32000` without one. Its proposal buffer, ` (unified diff)`, cannot be edited, so the accepted text is always the proposal [diff_inline.lua:M.setup_inline_diff] +- A new diff with an existing tab name first rejects the old diff (if pending), then cleans it up. This runs before the coroutine check, so a call outside a coroutine still removes the old diff [diff.lua:M.open_diff_blocking] +- Every `active_diffs` entry gets an `open_order` from `_register_diff_state`. The bulk closes sort by it [diff.lua:M._register_diff_state] [diff.lua:close_active_diffs] +- The native proposal buffer is an unlisted `acwrite` scratch buffer named ` (proposed)`, or ` (NEW FILE - proposed)`. A new file's original side is a read-only `nofile` placeholder named ` (NEW FILE)`. The proposal buffer carries `b:claudecode_diff_tab_name`, which `:ClaudeCodeDiffAccept` and `:ClaudeCodeDiffDeny` read [diff.lua:M._setup_blocking_diff] [diff.lua:load_original_buffer] [diff.lua:M.accept_current_diff] - A diff that resolves before `open_diff_blocking` yields, for example from a `ClaudeCodeDiffOpened` handler that runs `:ClaudeCodeDiffDeny`, is returned at once as the openDiff reply instead of a deferred one [diff.lua:M.open_diff_blocking] - A diff never splits the preview window while the Claude terminal is a tiled window in the same tab. It splits the terminal's window instead, because a split of the preview window keeps its `'previewheight'` lines. A `vertical` or `unified` diff then opens right of the terminal, so with the preview window left of the terminal, the terminal sits between the preview window and the diff. A `horizontal` diff opens below the terminal. With a floating or hidden Claude terminal, a diff in a tab with only the preview window splits the preview window. The legacy `open_diff` never opens the old file in the preview window either [diff.lua:leave_preview_window] [diff_inline.lua:create_fallback_editor_window] [diff.lua:M._open_native_diff] - A `unified` diff keeps the Claude terminal's width, with two exceptions: the terminal in a new tab, and a terminal whose window it splits (the old width includes the columns the diff took). It sizes those as the other layouts do: `terminal.diff_split_width_percentage` of the columns, else `split_width_percentage`, and not at all with `diff_opts.auto_resize_terminal = false` [diff_inline.lua:M.setup_inline_diff] [diff.lua:resize_terminal_for_diff] +- A width percentage counts only when it is a number between 0 and 1. Otherwise the diff uses 0.30. A floating terminal is never resized [diff.lua:resolve_split_width_percentage] [diff.lua:resize_terminal_for_diff] - Closing a diff gives the windows of the tab it split back the sizes they had before the diff, because the diff's `wincmd =` and the closing of its windows resize them. When two diffs split one tab, the sizes are those from before the first, and the last diff to close gives them back. Nothing is resized when the tab's window layout, or the space its windows fill (screen size, tab line), changed during the diff [diff.lua:restore_window_sizes] -- The restored sizes include a Claude terminal in that tab, so a width the user gave it stays. A diff never resizes a Claude terminal outside its own tab, so that one keeps its width too. Only when the sizes cannot come back does a `vertical` or `horizontal` diff set the Claude terminal, in any tab, to `split_width_percentage` of the columns, and not at all with `diff_opts.auto_resize_terminal = false`. A diff in its own tab sets no width when it closes: its terminal window closes with its tab, and a Claude terminal the close has to show again opens at `split_width_percentage` [diff.lua:M._cleanup_diff_state] +- The restored sizes include a Claude terminal in that tab, so a width the user gave it stays. A diff never resizes a Claude terminal outside its own tab, so that one keeps its width too. Only when the sizes cannot come back does a `vertical` or `horizontal` diff set the Claude terminal, in any tab, to `split_width_percentage` of the columns, and not at all with `diff_opts.auto_resize_terminal = false`. A diff in its own tab sets no width when it closes: its terminal window closes with its tab, and a Claude terminal the close has to show again opens at `split_width_percentage` [diff.lua:M._cleanup_diff_state]. A `unified` diff differs: its cleanup sets the Claude terminal back to the width it had before setup, also after it shows the terminal again for a diff in its own tab, and it does not check `auto_resize_terminal` [diff_inline.lua:M.cleanup_inline_diff] - When closing a diff closes the window the cursor is in, the cursor goes back to the window it was in when the diff opened, in another tab too. A cursor the user moved to another window during the diff stays there. A diff in its own tab goes back to the tab the user was in instead. With `diff_opts.keep_terminal_focus = true` the cursor is in the Claude terminal during the diff, so it stays there. When no Claude terminal is shown, the cursor stays in the diff, so it goes back to the window it was in, as without the option [diff.lua:restore_cursor] - When the target buffer is dirty, diff setup raises `-32000` with message "Diff setup failed". Its `data` holds the inner error, which contains "Cannot create diff: file has unsaved changes" [diff.lua:M._setup_blocking_diff] - A diff resolves only from status `pending`. Save returns `FILE_SAVED` and the content. Reject returns `DIFF_REJECTED` and the tab name [diff.lua:M._resolve_diff_as_saved] +- Rejecting a new-file diff that is not in its own tab cleans it up at once when `diff_opts.on_new_file_reject` is `"keep_empty"` (the default), before `close_tab`. A `unified` diff gets no such cleanup [diff.lua:M._resolve_diff_as_rejected] [diff_inline.lua:M.resolve_inline_as_rejected] - Saving the proposed buffer (`BufWriteCmd`) never writes the file. It only resolves the diff. Windows stay open until `close_tab` [diff.lua:register_diff_autocmds] -- `close_pending_diffs` and `close_diffs_for_client` close only `pending` diffs. `close_rejected_diffs` closes only `rejected` diffs. `close_all_diffs` closes every diff [diff.lua:M.close_pending_diffs] -- A bulk close lists its diffs first, and each close fires `ClaudeCodeDiffClosed`. Before each close it reads the entry again: a diff a handler accepted since the list was made stays open, and one a handler rejected closes as listed. It closes the newest diff first, not in `pairs()` order, so when each close puts the cursor back, the cursor ends where it was before the oldest of them opened. `:ClaudeCodeCloseAllDiffs` runs three bulk closes, for pending, then rejected, then accepted diffs, so it closes newest first only within each status. With diffs of more than one status, the cursor can end in another window [diff.lua:close_active_diffs] +- `BufDelete`, `BufUnload`, and `BufWipeout` on the proposed buffer reject a pending diff. `WinClosed` rejects it only when no other window in any tab shows the proposed buffer. The original file's buffer is not watched. A `unified` proposal buffer has `bufhidden=wipe`, so closing its window rejects a pending diff [diff.lua:register_diff_autocmds] [diff_inline.lua:M.setup_inline_diff] +- `close_pending_diffs` and `close_diffs_for_client` close only `pending` diffs. `close_rejected_diffs` closes only `rejected` diffs. `close_all_diffs` closes every diff, except a saved diff that another client owns when it gets a `client_id`. Only `close_all_diffs` passes a `client_id` to `close_diff_by_tab_name`. The other bulk closes do not, so the owner check never keeps one of their diffs [diff.lua:M.close_pending_diffs] [diff.lua:M.close_all_diffs] [diff.lua:close_active_diffs] +- A bulk close lists its diffs first, and each close fires `ClaudeCodeDiffClosed`. Before each close it reads the entry again: a diff a handler accepted since the list was made stays open when the close selects by status, and one a handler rejected closes as listed. `close_all_diffs` selects no status, so it also closes a diff accepted in the meantime. It closes the newest diff first, not in `pairs()` order, so when each close puts the cursor back, the cursor ends where it was before the oldest of them opened. `:ClaudeCodeCloseAllDiffs` runs three bulk closes, for pending, then rejected, then accepted diffs, so it closes newest first only within each status. With diffs of more than one status, the cursor can end in another window [diff.lua:close_active_diffs] - `:ClaudeCodeCloseAllDiffs` runs `close_pending_diffs`, `close_rejected_diffs`, then `close_saved_diffs` [init.lua:M._create_commands] -- `close_saved_diffs` closes a `saved` diff only once its file or the file's buffer holds the accepted text. When the file does not hold it, and the file's buffer has no unsaved changes and an empty `'buftype'`, it first loads the file into its buffer and sets the accepted lines there, unsaved. It never writes the file. The lines follow the buffer's format: in a `dos` buffer it drops a CR before each line break, so `:w` writes every line break as CRLF, LF-only lines of the proposal included. In a `'bomb'` buffer it drops a leading BOM. A text without a final line break turns `'fixeol'` off until the buffer's next write or read. Once the lines are set, it makes the buffer listed and durable: a `'bufhidden'` of `wipe`, `delete`, or `unload` becomes `hide`. When loading the file or setting the lines fails, it wipes a buffer it created, or unloads one it loaded. It returns the count, each file whose buffer got the text with the command that writes it (`:w`, `:checktime, then :w!` for a `'readonly'` buffer (the `!` also skips the changed-since-reading check, so `:checktime` runs first), or when the file's folder does not exist `:w ++p`, which Neovim 0.8 lacks, so there `:call mkdir(expand('%:p:h'), 'p') | w`) and a `'fileencoding'` that cannot hold the text, and each saved diff it kept with the reason (for `E325`, the next step: open the file with `:split `, answering the swap file prompt if Neovim shows one) [diff.lua:M.close_saved_diffs] +- `close_saved_diffs` closes a `saved` diff only once its file or the file's buffer holds the accepted text. When the file does not hold it, and the file's buffer has no unsaved changes and an empty `'buftype'`, it first loads the file into its buffer and sets the accepted lines there, unsaved. It never writes the file. The lines follow the buffer's format: in a `dos` buffer it drops a CR before each line break, so `:w` writes every line break as CRLF, LF-only lines of the proposal included. In a `'bomb'` buffer it drops a leading BOM. A text without a final line break turns `'fixeol'` off until the buffer's next write or read. Once the lines are set, it makes the buffer listed and durable: a `'bufhidden'` of `wipe`, `delete`, or `unload` becomes `hide`. When loading the file or setting the lines fails, it wipes a buffer it created, or unloads one it loaded. It checks the unsaved changes and `'buftype'` after it loads the buffer, so a buffer it then refuses stays loaded. It returns the count, each file whose buffer got the text with the command that writes it (`:w`, `:checktime, then :w!` for a `'readonly'` buffer (the `!` also skips the changed-since-reading check, so `:checktime` runs first), or when the file's folder does not exist `:w ++p`, which Neovim 0.8 lacks, so there `:call mkdir(expand('%:p:h'), 'p') | w`) and a `'fileencoding'` that cannot hold the text, and each saved diff it kept with the reason (for `E325`, the next step: open the file with `:split `, answering the swap file prompt if Neovim shows one) [diff.lua:M.close_saved_diffs] - `active_diffs` is module state. A reload that clears `package.loaded["claudecode.diff"]` forgets the open diffs, and the new module's `ClaudeCodeMCPDiff` augroup (created with `clear = true`) deletes their autocmds. Their windows and proposal buffers stay, so an accepted diff's text is then only in its proposal buffer [diff.lua:get_autocmd_group] ## Side Effects @@ -148,19 +187,23 @@ Module map (direct files): - `:ClaudeCodeCloseAllDiffs` can load an accepted diff's file into a listed buffer and replace its lines with the accepted text, leaving the buffer modified. For a text without a final line break it adds a buffer-local `BufWritePost`/`BufReadPost` autocmd that restores `'fixeol'` once [diff.lua:M.close_saved_diffs] - `start` binds a `127.0.0.1` TCP port and writes the lock file. It enables selection tracking only when `track_selection` is true. `stop` removes the lock file and stops the server [init.lua:M.start] - `lockfile.create` creates the lock directory with mode 0700. It then tries to chmod an existing directory to 0700 through `pcall` and ignores the result, so on platforms without `fs_chmod` the old mode stays. It writes a temp file opened `wx` with mode 0600, then renames it to `.lock` [lockfile.lua:M.create] -- The terminal env gets `ENABLE_IDE_INTEGRATION=true`, `FORCE_CODE_TERMINAL=true`, `CLAUDE_CODE_SSE_PORT=` only while the server runs, the user `env` (merged last, so it can override these), and the merged `no_proxy`/`NO_PROXY` [terminal.lua:get_claude_command_and_env] +- The terminal env gets `ENABLE_IDE_INTEGRATION=true`, `FORCE_CODE_TERMINAL=true`, `CLAUDE_CODE_SSE_PORT=` only while the server runs, then the user `env` (merged after these, so it can override them), then `no_proxy`/`NO_PROXY`. The last step merges the shell's and the user's `no_proxy` and `NO_PROXY` values, so a user value is merged, not used as it is [terminal.lua:get_claude_command_and_env] [terminal.lua:no_proxy_with_loopback] - `terminal.setup` can replace the global `vim.paste` through `paste_fix.apply` [terminal.lua:M.setup] - The plugin broadcasts `at_mentioned` (`filePath`, `lineStart`, `lineEnd`) [init.lua:M._broadcast_at_mention] and `selection_changed` [selection.lua:M.send_selection_update] to every connected client -- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs] +- `init.lua` feeds `` after a successful `:ClaudeCodeSend` that does not take the visual path, outside a tree buffer. It also feeds `` when `:ClaudeCode` or `:ClaudeCodeFocus` runs in Visual mode [init.lua:M._create_commands] +- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs, so Claude gets no `DIFF_REJECTED` for them +- Each diff that opens in a new tab adds a buffer-local `BufEnter` autocmd (`startinsert`) to the Claude terminal buffer in `ClaudeCodeMCPDiff`, unless `terminal.auto_insert` is `false`, `diff_opts.hide_terminal_in_new_tab` is `true`, or no valid Claude terminal buffer exists. Diff cleanup does not delete it, so these autocmds pile up [diff.lua:display_terminal_in_new_tab] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire `User` events. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] +- When a blocking diff resolves, `open_diff_blocking` sends the reply through the global `_G.claude_deferred_responses[tostring(co)]`, then sets that entry to nil. Without an entry, it logs an error [diff.lua:M.open_diff_blocking] - The native diff writes each temp file into its own session folder under `stdpath("cache")/claudecode_diffs`, or `claudecode_diffs_fallback` when it cannot create a directory in the first. The folder name includes the Neovim PID, so two running Neovim processes never share one. If both fail, `open_diff` returns `success = false` with an error that names both directories [diff.lua:create_temp_file]. A `BufDelete`/`BufWipeout` autocmd on the proposed buffer cleans up once, even when a wipe fires both events. It deletes the file and then its empty folder with `vim.fn.delete`, and warns once for each delete that fails. When the file delete fails, the folder is not empty, so its delete fails too and both warnings show [diff.lua:cleanup_temp_file]. `_cleanup_diff_state` does not [diff.lua:M._open_native_diff] - `diff_inline.lua` creates the `claudecode_inline_diff` namespace when the module loads. It defines the four `ClaudeCodeInlineDiff*` highlight groups (with `default = true`) [diff_inline.lua:setup_highlights] and sets extmarks [diff_inline.lua:M.render_diff_buffer] - The plugin runs `git rev-parse --show-toplevel` for the git root [cwd.lua:M.git_root], and `health.check` runs ` --version` [health.lua:check_cli] -- `logger` defers all output through `vim.schedule`. ERROR and WARN go to `vim.notify`. Other levels go to `nvim_echo` [logger.lua:log] -- Below log_level debug, `one_line` replaces each run of whitespace with one space. A line wider than `v:echospace` cells drops its component tag and puts `ERROR:` or `WARN:` in place of its level tag (an info line gets none), so the level shows without colors. It drops the `[ClaudeCode]` name too when that would take half the width. Then it is cut to the width, ending in `...` when `v:echospace` is at least 3. Below debug, only that line reaches `:messages`: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER", so the whole line shows only at log_level debug [logger.lua:M.one_line] -- `M.start` logs the reason a server did not start through `one_line`, for example `No free port in 10000-65535 (EADDRINUSE)`, because `auto_start` runs it while Neovim starts. That line fits at 80 columns. `M.start` returns `Server not started: ` [init.lua:M.start] +- `logger` defers each log line through `vim.schedule`. ERROR and WARN go to `vim.notify`. Other levels go to `nvim_echo` [logger.lua:log]. The level is INFO until `setup` runs. `setup` warns at once, not deferred, when `log_level` is missing or invalid, and uses INFO [logger.lua:M.setup] +- `one_line` accepts only `error`, `warn`, and `info`, and asserts on any other level [logger.lua:M.one_line] +- Below log_level debug, `one_line` replaces each run of whitespace with one space. A line wider than `v:echospace` cells drops its component tag and puts `ERROR:` or `WARN:` in place of its level tag (an info line gets none), so the level shows without colors. It drops the `[ClaudeCode]` name too when that would take more than half the width. Then it is cut to the width, ending in `...` when `v:echospace` is at least 3. Below debug, only that line reaches `:messages`: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER", so the whole line shows only at log_level debug [logger.lua:M.one_line] +- `M.start` logs the reason a server did not start through `one_line`, for example `No free port in 10000-65535 (EADDRINUSE)`, because `auto_start` runs it while Neovim starts. That line fits at 80 columns. `M.start` returns `Server not started: `. When the reason contains `auth`, the message ends with `(authentication related)` [init.lua:M.start] ## Verification - Run: `mise run test` -- Targeted: `busted -v tests/unit/init_spec.lua`, `busted -v tests/unit/config_spec.lua`, `busted -v tests/unit/diff_spec.lua`, `busted -v tests/unit/diff_inline_spec.lua`, `busted -v tests/unit/terminal_spec.lua`, `busted -v tests/unit/utils_spec.lua`, `busted -v tests/unit/health_spec.lua`, `busted -v tests/unit/logger_spec.lua`, `busted -v tests/integration/basic_spec.lua` +- Targeted: `busted -v tests/unit/init_spec.lua`, `busted -v tests/unit/config_spec.lua`, `busted -v tests/unit/diff_spec.lua`, `busted -v tests/unit/diff_inline_spec.lua`, `busted -v tests/unit/terminal_spec.lua`, `busted -v tests/unit/utils_spec.lua`, `busted -v tests/unit/health_spec.lua`, `busted -v tests/unit/logger_spec.lua`, `busted -v tests/unit/version_spec.lua`, `busted -v tests/unit/at_mention_spec.lua`, `busted -v tests/unit/claudecode_add_command_spec.lua`, `busted -v tests/unit/diff_inline_new_tab_spec.lua`, `busted -v tests/integration/basic_spec.lua`, `busted -v tests/integration/diff_window_choice_spec.lua` diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 9a13083b6..7a2031fd8 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -19,11 +19,12 @@ Layers, top to bottom: ### Lifecycle and messaging (`init.lua`) - `M.state`: `{ server, port, auth_token, handlers, ping_timer }` [server/init.lua:M.state] -- `M.start(config, auth_token) -> ok, port|err`: calls `tools.setup(M)`, registers handlers, creates the TCP server, and starts the ping timer [server/init.lua:M.start] +- `M.start(config, auth_token) -> ok, port|err`: registers handlers, calls `tools.setup(M)`, creates the TCP server, and starts the ping timer. It returns "Unknown server creation error" when `create_server` fails without a message [server/init.lua:M.start] - `M.stop() -> ok, err?`: stops the ping timer, closes pending diffs, stops the TCP server, and clears deferred responses [server/init.lua:M.stop] - `M.register_handlers()`: handles `initialize`, `notifications/initialized`, `prompts/list`, `tools/list`, and `tools/call` [server/init.lua:M.register_handlers] -- `initialize` returns `protocolVersion = "2024-11-05"` (`MCP_PROTOCOL_VERSION`), `capabilities` (logging, `prompts.listChanged`, `tools.listChanged`), and `serverInfo.name = "claudecode-neovim"` [server/init.lua:MCP_PROTOCOL_VERSION] -- `tools/call` delegates to `tools.handle_invoke` and returns a `_deferred` table for blocking tools [server/init.lua:M.register_handlers] +- `initialize` returns `protocolVersion = "2024-11-05"` (`MCP_PROTOCOL_VERSION`), `capabilities` (logging, `prompts.listChanged`, `tools.listChanged`), `serverInfo.name = "claudecode-neovim"`, and `serverInfo.version = require("claudecode").version:string()` [server/init.lua:MCP_PROTOCOL_VERSION] [server/init.lua:M.register_handlers] +- `tools/call` delegates to `tools.handle_invoke` and returns a `_deferred` table (`{ _deferred = true, coroutine, client, params }`) for blocking tools [server/init.lua:M.register_handlers] +- Handler contract: a request handler returns `result, error_data`. A non-nil `error_data` becomes the error reply [server/init.lua:M._handle_request] - `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] @@ -33,17 +34,22 @@ Layers, top to bottom: ### TCP server (`tcp.lua`) - `M.create_server(config, callbacks, auth_token) -> server|nil, err`: tries ports in the configured range until one binds and listens [server/tcp.lua:M.create_server] -- `M.find_available_port(min_port, max_port)`: probe-only port check [server/tcp.lua:M.find_available_port] +- `M.find_available_port(min_port, max_port)`: probe-only port check. Nothing in `lua/` calls it [server/tcp.lua:M.find_available_port] - `M.send_to_client`, `M.broadcast`, `M.close_client`, `M.get_client_count`, and `M.get_clients_info` [server/tcp.lua:M.send_to_client] -- `M.stop_server(server)`: sends a Close frame with code 1001 "Server shutting down" to clients that finished the handshake or whose 101 reply is still being written, closes the TCP handle of the others, then closes the listener [server/tcp.lua:M.stop_server] [server/client.lua:M.close_client] +- `M.stop_server(server)`: sends a Close frame with code 1001 "Server shutting down" to clients that finished the handshake or whose 101 reply is still being written, closes the TCP handle of the others, empties `server.clients`, then closes the listener. It calls no `on_disconnect`. It skips a client that is already `closing` or `closed`, or whose socket is already closing: that client gets no Close frame. A client that sent its own Close frame is `closing` until its scheduled `on_close` runs. If `stop_server` runs first, that client's socket stays open. The echoed Close frame has no write callback, and `_disconnect_client` ignores a client that is no longer in `server.clients`, so neither the scheduled `on_close` nor a later EOF closes the socket [server/tcp.lua:M.stop_server] [server/client.lua:M.close_client] [server/client.lua:M.process_data] [server/tcp.lua:M._disconnect_client] +- `M.close_client(server, client_id, code, reason)` closes one client but does not remove it from `server.clients` or call `on_disconnect`. Nothing in `lua/` calls it [server/tcp.lua:M.close_client] - `M.start_ping_timer(server, interval)`: default interval is 30000 ms [server/tcp.lua:M.start_ping_timer] - Internal: `M._bind_and_listen`, `M._handle_new_connection`, `M._disconnect_client`, and `M._remove_client` [server/tcp.lua:M._bind_and_listen] ### Connection, handshake, and frames - `client.create_client(tcp_handle)`, `process_data(...)`, `send_message`, `send_ping`, `close_client`, `is_client_alive(client, timeout=30000)`, and `get_client_info` [server/client.lua:M.process_data] +- Client states: `connecting`, then `upgrading` (101 reply being written), then `connected`. `close_client` moves an `upgrading` or `connected` client to `closing`, then to `closed` when its Close frame write completes. It moves a `connecting` client, or one whose socket is already closing, straight to `closed`. A rejected handshake and a received Close frame set `closing`, and the state never becomes `closed` [server/client.lua:M.create_client] [server/client.lua:M.close_client] [server/client.lua:M.process_data] +- `client.get_client_info(client) -> { id, state, handshake_complete, buffer_size, last_ping, last_pong }`. `buffer_size` counts the unparsed bytes and the reads of an unfinished frame. `tcp.get_clients_info` returns these tables [server/client.lua:M.get_client_info] +- `client.send_message` calls its callback with "Client not connected" and writes nothing unless the state is `connected`. `send_ping` does nothing unless the state is `connected` [server/client.lua:M.send_message] [server/client.lua:M.send_ping] - `handshake.process_handshake(request, expected_auth_token) -> ok, response, headers?` [server/handshake.lua:M.process_handshake] - `handshake.validate_upgrade_request`, `create_handshake_response`, `parse_request_line`, `is_websocket_endpoint`, `create_error_response`, and `extract_http_request` [server/handshake.lua:M.validate_upgrade_request] +- `handshake.create_error_response(code, message)`: a plain-text HTTP response with `Connection: close`, for 400, 404, 426, 500, or any other code [server/handshake.lua:M.create_error_response] - `frame.OPCODE`: `CONTINUATION 0x0`, `TEXT 0x1`, `BINARY 0x2`, `CLOSE 0x8`, `PING 0x9`, `PONG 0xA` [server/frame.lua:M.OPCODE] - `frame.MAX_PAYLOAD_LENGTH`: 100 MiB, the largest frame payload and the largest message [server/frame.lua:M.MAX_PAYLOAD_LENGTH] - `frame.parse_frame(data, start?) -> frame|nil, consumed, close_code?, detail?`: parses the frame that starts at byte `start` (default 1). `nil, 0` means incomplete, and `nil, 0, nil, length` gives the whole frame's length once its header arrived. `nil, 0, code, problem` means fatal, with the broken rule in `problem` [server/frame.lua:M.parse_frame] @@ -63,23 +69,33 @@ Layers, top to bottom: - The server listens only on `127.0.0.1` with backlog 128. It closes the handle if bind or listen fails [server/tcp.lua:M._bind_and_listen] - The port iterator yields each port in the range exactly once, starting at a random offset. `create_server` returns "No available ports in range min-max" for an empty range, and "No free port in min-max ()", for example `(EADDRINUSE)`, when every port fails [server/tcp.lua:M.create_server] - `M.start` refuses with "Server already running" when `M.state.server` is set. `M.stop` returns "Server not running" when it is not set. `send`, `send_response`, and `broadcast` return `false` without a server [server/init.lua:M.start] +- While the server runs, `send` and `send_response` return `true` even for an unknown or unconnected client: they pass no callback, so `tcp.send_to_client` drops the message silently [server/init.lua:M.send_response] [server/tcp.lua:M.send_to_client] +- A failed `M.start` leaves `M.state.auth_token`, the handlers, and `tools.setup(M)` in place, with `M.state.server` still `nil`, so a retry is allowed. `M.start` returns `true` even when the ping timer could not be created. Only `on_error` logs that failure [server/init.lua:M.start] [server/tcp.lua:M.start_ping_timer] +- `on_connect` fires when the TCP connection is accepted, before the WebSocket handshake and the auth check. So `process_mention_queue(true)` is scheduled for every TCP connection, also one the handshake rejects, and `client_count` includes clients that have not finished the handshake [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] - When an auth token is set, the upgrade request must carry `x-claude-code-ide-authorization`. The header length must be 10..500. The token is compared with `utils.constant_time_compare` [server/handshake.lua:M.validate_upgrade_request] - The upgrade request must have `Upgrade: websocket`, `Connection` containing `upgrade`, `Sec-WebSocket-Version: 13`, and a 24-character `Sec-WebSocket-Key` [server/handshake.lua:M.validate_upgrade_request] -- A non-GET or non-HTTP/1.1 request gets 404. A failed validation gets 400 "Bad WebSocket upgrade request: " [server/handshake.lua:M.process_handshake] +- A non-GET or non-HTTP/1.1 request gets 404. A failed validation gets 400 "Bad WebSocket upgrade request: ". A failed accept key gets 500 "Failed to generate WebSocket handshake response" [server/handshake.lua:M.process_handshake] +- `is_websocket_endpoint` accepts any path. `Sec-WebSocket-Key` is checked for its length only, not for base64. The 101 reply echoes the client's `Sec-WebSocket-Protocol` value verbatim [server/handshake.lua:M.is_websocket_endpoint] [server/handshake.lua:M.validate_upgrade_request] [server/handshake.lua:M.create_handshake_response] +- `parse_http_headers` lower-cases header names. A later duplicate header wins. A value keeps its trailing whitespace, and a header with an empty value is dropped, so `Upgrade: websocket ` with a trailing space fails the upgrade check [server/utils.lua:M.parse_http_headers] +- No timer closes a client that never finishes the handshake. The ping timer checks only `connected` clients, and `client.buffer` grows until the request's `\r\n\r\n` arrives. Only EOF, a read error, or `stop_server` removes such a client [server/tcp.lua:M.start_ping_timer] [server/client.lua:M.process_data] - Frames are dispatched only after `handshake_complete` is true. The state becomes `connected` only after a successful handshake. While its 101 reply is written, a client is `upgrading`: the bytes it sends then wait in its buffer for the write callback, so its request is answered once and its first frames are kept. If the client hung up or the server stopped before the write callback, the callback drops those bytes, and the client never becomes `connected` [server/client.lua:M.process_data] - A whole message runs through `on_message` only if, when the main loop gets to it, the client is still `connected` and its socket is open. A request from a client that hung up or sent its Close frame meanwhile does not run [server/client.lua:M.process_data] - A text message can arrive in fragments (a TEXT frame with FIN clear, then CONTINUATION frames up to one with FIN set), with control frames between them. `process_data` joins the fragments and calls `on_message` once with the whole text. Only the whole message must be well-formed UTF-8, and one that is not closes with 1007. A binary message closes with 1003, and nothing runs it. A CONTINUATION frame with no message to continue, or a TEXT or BINARY frame inside a fragmented message, closes with 1002. A message whose fragments add up to more than `frame.MAX_PAYLOAD_LENGTH` (100 MiB) closes with 1009. An unknown opcode closes with 1002. Each Close frame carries the RFC 6455 name of its status as the reason: `Protocol error` (1002), `Unsupported data` (1003), `Invalid frame payload data` (1007) or `Message too big` (1009) [server/client.lua:add_data_frame] [server/client.lua:M.process_data] - `process_data` parses every frame of one read from an offset into its buffer and keeps the unparsed rest once at the end, so many small frames in one read cost linear time. The reads of a frame longer than one read wait in `client.pending` until the whole frame arrived, and are joined once, so a large frame costs linear time too [server/client.lua:M.process_data] -- `parse_frame` rejects set RSV bits, invalid opcodes, fragmented control frames, and control payloads over 125 bytes with 1002. It rejects a payload over `MAX_PAYLOAD_LENGTH` (100 MiB) with 1009, and a close reason that is not well-formed UTF-8 with 1007. It names the broken rule (for example `Reserved opcode 0x3`), and that name is the client's disconnect reason. It does not check the UTF-8 of a text frame, because a fragment can end inside a code point. `is_valid_utf8` uses `utils.utf8_sequence_length`, the decoder behind the outbound `utils.valid_utf8`, so it also rejects overlong forms, UTF-16 surrogates, code points above U+10FFFF, and the bytes C0, C1 and F5 to FF [server/frame.lua:M.parse_frame] [server/utils.lua:M.is_valid_utf8] -- `parse_frame` does not reject unmasked client frames. Server frames are unmasked by default (`masked=false`) [server/frame.lua:M.create_frame] +- `parse_frame` rejects set RSV bits, invalid opcodes, fragmented control frames, and control payloads over 125 bytes with 1002. It rejects a payload over `MAX_PAYLOAD_LENGTH` (100 MiB) with 1009, and a close reason that is not well-formed UTF-8 with 1007. It rejects a Close payload of 1 byte with 1002. It names the broken rule (for example `Reserved opcode 0x3`). `process_data` passes that name to `on_error`, so the client's disconnect reason is `Client error: ` with code 1006, while the Close frame carries the RFC 6455 status name. It does not check the UTF-8 of a text frame, because a fragment can end inside a code point. `is_valid_utf8` uses `utils.utf8_sequence_length`, the decoder behind the outbound `utils.valid_utf8`, so it also rejects overlong forms, UTF-16 surrogates, code points above U+10FFFF, and the bytes C0, C1 and F5 to FF [server/frame.lua:M.parse_frame] [server/utils.lua:M.is_valid_utf8] +- `parse_frame` does not reject unmasked client frames, non-minimal length encodings, or any Close status code. Server frames are unmasked by default (`masked=false`) [server/frame.lua:M.parse_frame] [server/frame.lua:M.create_frame] +- `create_frame` and `create_close_frame` do not check the payload size, so a Close reason over 123 bytes makes a control frame that breaks RFC 6455 [server/frame.lua:M.create_close_frame] +- A received Close frame with a payload under 2 bytes counts as code 1000 with an empty reason. A `connected` client gets its code and reason echoed back, the state becomes `closing`, `on_close` is scheduled, and later bytes are dropped. A PING gets a PONG with the same payload. Any PONG sets `last_pong` [server/client.lua:M.process_data] - Every text frame the server sends is valid UTF-8. `send_message` replaces each ill-formed sequence with U+FFFD, so a buffer name or path with invalid bytes reaches the client as U+FFFD and does not drop the connection [server/client.lua:M.send_message] - JSON-RPC errors: -32700 for a parse error and -32600 unless `jsonrpc == "2.0"` [server/init.lua:M._handle_message]. -32601 for an unknown method and -32603 for a handler that raises [server/init.lua:M._handle_request] - When the JSON decodes to a non-table value (for example a number or a boolean), `_handle_message` replies -32600 with `"id": null`. It does not raise [server/init.lua:M._handle_message] - A message with a truthy `id` is a request. Any other message is a notification. A valid notification never gets a response. The -32700 and -32600 checks run before the `id` check, so a malformed notification gets an error reply [server/init.lua:M._handle_message] -- A deferred response is keyed by `tostring(coroutine)` in `_G.claude_deferred_responses`. A result with neither `content` nor `error` becomes -32603 [server/init.lua:M._setup_deferred_response] +- Notification handlers run in `pcall`. Their errors and return values are discarded, and an unknown notification is ignored without a reply [server/init.lua:M._handle_notification] +- A `tools/call` without a string or number `name` raises in `tools.handle_invoke`, so `_handle_request` replies -32603 [server/init.lua:M.register_handlers] [server/init.lua:M._handle_request] +- A deferred response is keyed by `tostring(coroutine)` in `_G.claude_deferred_responses`. It replies to `result.client` with the original request id. A result with `content` is sent as `result`, one with `error` is sent as `error`, and a result with neither becomes -32603 [server/init.lua:M._setup_deferred_response] - The ping timer closes a connected client when no pong arrived within `interval * 2`: its Close frame carries 1011 "Keepalive timeout" (RFC 6455 reserves 1006, so no Close frame carries it), `on_disconnect` gets 1006 "Connection timeout", and the timer logs `Closed client_: no reply for s` at info level. A peer that stopped reading does not get that Close frame. Once its receive window is full, every write to it (replies, pings, and the Close frame) waits in libuv's queue, and closing the socket drops the queue. Until the timeout, the queued replies stay in memory. A tick gap above `interval * 1.5` counts as a wake from sleep and resets `last_pong` [server/tcp.lua:M.start_ping_timer] - `_disconnect_client` is idempotent: `on_disconnect` fires once per client id [server/tcp.lua:M._disconnect_client] -- A client's read error or protocol error reaches the server only as its `on_disconnect` reason (`Client read error: …` or `Client error: …`), which `init.lua` logs at debug level. `on_error` carries only the server's own failures (listen, accept, and creating a client handle or the ping timer), which `init.lua` logs at error level [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] +- A client's EOF, read error, or protocol error reaches the server only as its `on_disconnect` code 1006 and reason (`EOF`, `Client read error: …`, or `Client error: …`), which `init.lua` logs at debug level. `on_error` carries only the server's own failures (listen, accept, and creating a client handle or the ping timer), which `init.lua` logs at error level [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] - A rejected handshake logs `Rejected client_: ` at warn level, with the reason from the body of the error response. Once the error response is written, `on_error` removes the client from the server and closes its socket [server/client.lua:M.process_data] - Once a client is closing, `process_data` drops the bytes it sends, and `close_client` frees the unparsed bytes and the reads of an unfinished frame [server/client.lua:M.process_data] [server/client.lua:M.close_client] - The server logs each error, warning, and info line from a libuv callback through `logger.one_line`, so the line never stops Neovim at "Press ENTER" [server/init.lua:M.start] [server/client.lua:M.process_data] [server/tcp.lua:M.start_ping_timer] @@ -96,8 +112,8 @@ Layers, top to bottom: - `M.start` logs only the auth token length, not the token [server/init.lua:M.start] - `tools/call` debug lines name the tool and list the argument and result keys with their sizes (string bytes, table entry counts), never their values, so file contents and selected text stay out of `:messages`. An error result logs its code and message [server/init.lua:M.register_handlers] - `client.create_client` increments `_G.claudecode_clients_created` [server/client.lua:M.create_client] -- `tcp.lua` seeds `math.randomseed` once per process. `init.lua` calls `math.random` at load. Both change the global PRNG [server/tcp.lua:ensure_rng_seeded] -- `client.process_data` writes handshake, PONG, and Close frames to the socket. It runs `on_message` and `on_close` through `vim.schedule`. It calls `on_error` directly after a failed handshake write, and after it writes the Close frame for a protocol error [server/client.lua:M.process_data] [server/client.lua:M.close_client] +- `tcp.lua` seeds `math.randomseed` once per load of the module, on the first `port_iterator` call for a non-empty range, so a reload of `tcp.lua` seeds again. `init.lua` calls `math.random` at load. Both change the global PRNG [server/tcp.lua:ensure_rng_seeded] [server/tcp.lua:port_iterator] +- `client.process_data` writes handshake, PONG, and Close frames to the socket. It runs `on_message` and `on_close` through `vim.schedule`. It calls `on_error` directly after a failed handshake write, and from the write callback of the Close frame for a protocol error. When `close_client` writes no Close frame, it schedules `on_error` instead [server/client.lua:M.process_data] [server/client.lua:M.close_client] - The ping timer writes ping frames and closes timed-out clients [server/tcp.lua:M.start_ping_timer] - `handshake.lua`, `frame.lua`, and `utils.lua` do no I/O [server/handshake.lua:M] - `mock.lua` opens no sockets and keeps its clients and messages in its own `M.state` tables. Its `mcp.tool.invoke` handler calls the real `tools.handle_invoke`, so a simulated tool call has that tool's real side effects (for example `saveDocument` writes the file) [server/mock.lua:M.register_handlers] [server/mock.lua:M.state] @@ -105,4 +121,4 @@ Layers, top to bottom: ## Verification - Run: `mise run test` -- Targeted: `busted -v tests/unit/server/frame_spec.lua`, `busted -v tests/unit/server/handshake_spec.lua`, `busted -v tests/unit/server/tcp_spec.lua`, `busted -v tests/unit/server_spec.lua` +- Targeted: `busted -v tests/unit/server/frame_spec.lua`, `busted -v tests/unit/server/handshake_spec.lua`, `busted -v tests/unit/server/tcp_spec.lua`, `busted -v tests/unit/server/message_spec.lua`, `busted -v tests/unit/server/gone_client_spec.lua`, `busted -v tests/unit/server/client_id_spec.lua`, `busted -v tests/unit/server/log_lines_spec.lua`, `busted -v tests/unit/server/send_utf8_spec.lua`, `busted -v tests/unit/server_spec.lua`, `busted -v tests/unit/server_tool_call_log_spec.lua` diff --git a/lua/claudecode/terminal/README.md b/lua/claudecode/terminal/README.md index 6d6adb145..539f853aa 100644 --- a/lua/claudecode/terminal/README.md +++ b/lua/claudecode/terminal/README.md @@ -23,11 +23,12 @@ The dispatcher requires these 7 callable fields on a custom table provider [term - `simple_toggle(cmd_string, env_table, effective_config)` [terminal/native.lua:M.simple_toggle] - `focus_toggle(cmd_string, env_table, effective_config)` [terminal/native.lua:M.focus_toggle] - `get_active_bufnr() -> bufnr|nil`. `external` and `none` always return `nil` [terminal/external.lua:M.get_active_bufnr] -- `is_available() -> boolean`. `native`, `external`, and `none` always return `true` [terminal/snacks.lua:M.is_available] +- `is_available() -> boolean`. `native`, `external`, and `none` always return `true` [terminal/native.lua:M.is_available] [terminal/external.lua:M.is_available] [terminal/none.lua:M.is_available]. `snacks` tries to load snacks.nvim once, when the module loads. It checks `Snacks.terminal` again on every call [terminal/snacks.lua:M.is_available] [terminal/snacks.lua:is_available] Optional fields (the dispatcher does not require them): -- `toggle(...)`: a legacy alias of `simple_toggle`. All four providers define it. The dispatcher adds a default when it is missing [terminal/native.lua:M.toggle] +- `toggle(...)`: a legacy alias of `simple_toggle`. All four providers define it. `validate_and_enhance_provider` adds a default that calls `simple_toggle` when it is missing, but only for a custom table provider [terminal/native.lua:M.toggle] [terminal.lua:validate_and_enhance_provider] +- `validate_and_enhance_provider` changes the custom table in place. A field counts as callable when it is a function or a table with `__call`. It returns `nil` and a message such as "Custom provider missing required function: X" for a bad table [terminal.lua:validate_and_enhance_provider] - `ensure_visible()`: defined as a no-op only in `external` and `none`. The dispatcher calls it only when present [terminal/external.lua:M.ensure_visible] - `_get_terminal_for_test()`: defined in `snacks`, `external`, and `none`. `native` does not define it. `validate_and_enhance_provider` adds a default that returns `nil`, but only for a custom table provider. For a built-in provider, `M._get_managed_terminal_for_test` checks that the field exists and returns `nil` when it is missing [terminal/snacks.lua:M._get_terminal_for_test] [terminal.lua:M._get_managed_terminal_for_test] - The provider type is declared as `ClaudeCodeTerminalProvider`. It declares `toggle` and `_get_terminal_for_test` as required (no `?`), so the type and the dispatcher disagree [types.lua:ClaudeCodeTerminalProvider] @@ -35,46 +36,55 @@ Optional fields (the dispatcher does not require them): ### `paste_fix.lua` - `is_affected_version() -> boolean`: true for Neovim before 0.12.2, including a 0.12.2 prerelease [terminal/paste_fix.lua:M.is_affected_version] -- `should_enable(opt) -> boolean`: maps `true`, `false`, or `"auto"`/`nil` [terminal/paste_fix.lua:M.should_enable] +- `should_enable(opt) -> boolean`: `true` and `false` map to themselves. Any other value (`"auto"`, `nil`, or another string) uses `is_affected_version()` [terminal/paste_fix.lua:M.should_enable] - `install()`: installs the `vim.paste` wrapper once (idempotent) [terminal/paste_fix.lua:M.install] -- `apply(opt)`: sets the enabled flag and installs when enabled [terminal/paste_fix.lua:M.apply] -- Test helpers: `_accumulate(acc, lines)`, `_is_installed()`, and `_is_enabled()` [terminal/paste_fix.lua:M._accumulate] +- `apply(opt)`: sets the enabled flag and installs when enabled. It never uninstalls, so `apply(false)` after an install leaves the wrapper in place as a pass-through [terminal/paste_fix.lua:M.apply] +- `_accumulate(acc, lines)`: appends one streamed chunk to `acc` in place and joins `lines[1]` onto the last element, the mid-line seam. The installed wrapper calls it for each buffered phase [terminal/paste_fix.lua:M._accumulate] [terminal/paste_fix.lua:M.install] +- Test helpers: `_is_installed()` and `_is_enabled()` [terminal/paste_fix.lua:M._is_installed] ## Invariants - `external` tracks one job. It counts as running only when `jobid > 0`. `open` does nothing while a job is running [terminal/external.lua:is_valid] - `external.open` refuses (ERROR notify) when `provider_opts.external_terminal_cmd` is unset. A string command must not be empty and must contain 1 or 2 `%s` placeholders. A function command must return a string or a table [terminal/external.lua:M.open] - `external.open` runs the job in `vim.fn.getcwd()`. It calls `jobstart` through `pcall`. When `jobstart` raises (for example `E475` for a command that is not executable) or returns `0` or `-1`, it sends one ERROR notification, which includes the error text when `jobstart` raised, and clears `jobid` [terminal/external.lua:M.open] +- `external` reads `external_terminal_cmd` from the config that `setup` stored, not from the effective config. A string with 1 `%s` gets the command. A string with 2 gets the cwd, then the command. `external` formats it with `string.format` outside `pcall`, so another `%` sequence such as `%d` raises a Lua error instead of sending an ERROR notification. It splits a string with `utils.parse_command`. A function gets `(cmd_string, env_table)`, and a table it returns is used as the argv list [terminal/external.lua:M.open] +- `external`'s `on_exit` clears the state, scheduled, only when the job that exited is still the tracked one. `simple_toggle`, `focus_toggle`, and `toggle` stop a running job instead of hiding it [terminal/external.lua:M.open] [terminal/external.lua:M.simple_toggle] - `native` clears `bufnr`, `winid`, and `jobid` when its buffer is invalid. It recovers `winid` by scanning windows that show the buffer [terminal/native.lua:is_valid] +- `native` passes an argv list from `utils.parse_command` to `termopen`, so no shell runs. It opens the split with `topleft` when `split_side` is `"left"`, else `botright`, `floor(columns * split_width_percentage)` columns wide and the full editor height. A hidden terminal comes back in the same kind of split [terminal/native.lua:open_terminal] [terminal/native.lua:show_hidden_terminal] +- `native` uses the effective config of each call for `auto_insert` and `auto_close` of a terminal it starts. It uses the config from `setup` for `focus_terminal`'s `auto_insert`, for the `auto_close` of an adopted terminal, and for `show_native_term_exit_tip` [terminal/native.lua:focus_terminal] [terminal/native.lua:adopt_existing_terminal] +- `native`'s `simple_toggle` and `focus_toggle` show a hidden terminal focused. `focus_toggle` hides the terminal only when the cursor is in its window, and focuses it when it is visible elsewhere [terminal/native.lua:M.simple_toggle] [terminal/native.lua:M.focus_toggle] +- `native`'s `hide_terminal` closes the window without the last-window check that the exit path uses. The comment on `is_last_window` says `nvim_win_close` raises E444 for the only window of the only tab page [terminal/native.lua:hide_terminal] [terminal/native.lua:is_last_window] - `native` sets `bufhidden=hide`, so the Claude job survives when its window closes [terminal/native.lua:open_terminal]. Hide closes the window without force, and the buffer stays alive [terminal/native.lua:hide_terminal] - `native` sets `filetype=claudecode` on the terminal buffer once, after `termopen` started the job, so a `FileType claudecode` autocmd runs once per terminal with `buftype=terminal`. A failed start sets none, and a hide or show of the same buffer sets none again [terminal/native.lua:open_terminal] - `native` calls `termopen` through `pcall`. Neovim raises `E475` for a command that is not executable, and returns `0` or `-1` instead when errors are silenced. When an autocmd deletes the new buffer before `termopen` runs, `native` skips `termopen` and treats it as a failure. When a `TermOpen` autocmd wipes the buffer after `termopen` started the job, the wipe also stops the job, and `native` treats it as a failure. On any of these failures `native` sends one ERROR notification, which includes the error text when `termopen` raised. Its callers (`M.open`, `M.simple_toggle`, and `M.focus_toggle`) send none. `native` also closes the new window, restores the original window, deletes the buffer it created, and clears its state. A buffer that an autocmd swapped into the split stays [terminal/native.lua:open_terminal] -- `native` shows the exit tip once per session when `show_native_term_exit_tip` is set [terminal/native.lua:open_terminal] -- `native` marks each terminal buffer it starts with `b:claudecode_native_terminal`, before `termopen` runs. It clears the mark when the job exits [terminal/native.lua:open_terminal]. While it tracks no terminal, `open`, `simple_toggle`, and `focus_toggle` adopt a marked buffer that a window displays and whose job has not ended, and take its job id from the buffer's `'channel'` option. A buffer whose job has not started yet counts as not ended. They never adopt an unmarked terminal, even when its name contains `claude`. The job's `on_exit` belongs to the module copy that started it, for example the one before a reload, so adoption adds a buffer-local `TermClose` handler that runs the same exit handling. The handler sits in the `ClaudeCodeNativeAdopted` augroup, and each adoption replaces the buffer's earlier one, so a buffer keeps one handler however many reloads adopt it [terminal/native.lua:adopt_existing_terminal] -- `snacks.is_available` requires snacks.nvim to load and `Snacks.terminal` to exist [terminal/snacks.lua:M.is_available] +- `native` shows the exit tip once per load of the module when `show_native_term_exit_tip` is set. The flag that limits it is module-local, so a reload of the module resets it [terminal/native.lua:open_terminal] +- `native` marks each terminal buffer it starts with `b:claudecode_native_terminal`, before `termopen` runs. It clears the mark when the job exits [terminal/native.lua:open_terminal]. While it tracks no terminal, `open`, `simple_toggle`, and `focus_toggle` adopt a marked buffer that a window displays and whose job has not ended, and take its job id from the buffer's `'channel'` option. A buffer whose job has not started yet counts as not ended. They never adopt an unmarked terminal, even when its name contains `claude`. The job's `on_exit` belongs to the module copy that started it, for example the one before a reload, so adoption adds a buffer-local `TermClose` handler that runs the same exit handling. The handler sits in the `ClaudeCodeNativeAdopted` augroup, and each adoption replaces the buffer's earlier one, so a buffer keeps one handler however many reloads adopt it [terminal/native.lua:adopt_existing_terminal]. `close` and `get_active_bufnr` never adopt, so after a reload `close` does nothing and `get_active_bufnr` returns `nil` until an `open` or a toggle adopts the terminal [terminal/native.lua:M.close] [terminal/native.lua:M.get_active_bufnr] +- `snacks.is_available` requires snacks.nvim to load and `Snacks.terminal` to exist. It tries to load snacks.nvim once, when the module loads. It checks `Snacks.terminal` again on every call. Without Snacks, `open`, `simple_toggle`, and `focus_toggle` send an ERROR notification, and `close` returns without one [terminal/snacks.lua:is_available] [terminal/snacks.lua:M.open] [terminal/snacks.lua:M.close] +- `snacks.focus_toggle` shows and focuses a hidden terminal, hides a focused one, and focuses a visible one that does not have the cursor. A terminal is visible only when its window is valid, not hidden through its config, and shows the terminal buffer [terminal/snacks.lua:M.focus_toggle] [terminal/snacks.lua:cc_is_visible] - `snacks` reuses the tracked instance while `terminal:buf_valid()` is true. It passes an argv list from `utils.parse_command`, so no shell expands bracketed model aliases [terminal/snacks.lua:M.open] - `snacks` calls `Snacks.terminal.open` through `pcall`. Snacks shows its window before it calls `termopen`, so `snacks` wraps `on_buf` to capture the instance and then calls any `on_buf` from `snacks_win_opts`. A start fails when `termopen` raises (for example `E475` for a command that is not executable) or when the buffer is not a terminal afterwards (`termopen` returns `-1` when errors are silenced). On a failed start `snacks` closes the Snacks window, deletes its scratch buffer, clears its state, and sends one ERROR notification with the first line of the error. The notification shows the command but not the env table. The DEBUG log of the failed start lists the env keys but not their values [terminal/snacks.lua:M.open] - `snacks` forces `auto_close = false` on the Snacks instance. It sets `start_insert` and `auto_insert` only when focusing and `auto_insert ~= false` [terminal/snacks.lua:build_opts] -- `snacks` gives the Snacks window the default `bo = { filetype = "claudecode" }`. It replaces the `snacks_terminal` filetype of the Snacks terminal style, so a `FileType snacks_terminal` autocmd no longer matches the Claude buffer. `snacks_win_opts` is deep-merged over the default, so `snacks_win_opts.bo.filetype` wins, `"snacks_terminal"` restores the Snacks filetype, and other `bo` keys keep the default filetype. The default also wins over a `bo.filetype` in the user's global snacks.nvim config (`styles.terminal`, `terminal.win`) or in a style named by `snacks_win_opts`, because Snacks ranks the options passed to `Snacks.terminal.open` above them. Snacks applies `bo` when it creates the buffer, before `termopen` (`buftype` is still `nofile`) and before it opens the window, so a window option set in a `FileType` handler does not reach the Claude window. Snacks applies `bo` again only when it re-creates a window itself, which `snacks` allows for a floating window that was closed. A split is re-created with `vsplit`, so `FileType` does not fire again for it [terminal/snacks.lua:build_opts] [terminal/snacks.lua:cc_show] +- `snacks` gives the Snacks window the default `bo = { filetype = "claudecode" }`. It replaces the `snacks_terminal` filetype of the Snacks terminal style, so a `FileType snacks_terminal` autocmd no longer matches the Claude buffer. `snacks_win_opts` is deep-merged over the default, so `snacks_win_opts.bo.filetype` wins, `"snacks_terminal"` restores the Snacks filetype, and other `bo` keys keep the default filetype. The default also wins over a `bo.filetype` in the user's global snacks.nvim config (`styles.terminal`, `terminal.win`) or in a style named by `snacks_win_opts`, because Snacks ranks the options passed to `Snacks.terminal.open` above them. Snacks applies `bo` when it creates the buffer, before `termopen` (`buftype` is still `nofile`) and before it opens the window, so a window option set in a `FileType` handler does not reach the Claude window. Snacks applies `bo` again only when it re-creates a window itself. `snacks` lets it do that when `snacks_win_opts.position` is not `left`, `right`, `top`, or `bottom` (for example `float`), and for a float that `snacks` hid whose window is gone. On Neovim before 0.10, hiding a float closes its window. A split is re-created with `split` (top or bottom) or `vsplit` (left or right), so `FileType` does not fire again for it [terminal/snacks.lua:build_opts] [terminal/snacks.lua:cc_show] [terminal/snacks.lua:cc_hide] - `external` and `none` create no Neovim buffer, so they set no filetype [terminal/external.lua:M.open] [terminal/none.lua:M.open] -- `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc` [terminal/snacks.lua:patch_instance] -- `snacks` hides floats with `nvim_win_set_config({hide=true})` only on Neovim 0.10 and later. It closes split windows and re-creates them on show [terminal/snacks.lua:cc_hide] -- `paste_fix` decides at phase 1 whether a streamed paste targets the managed terminal. It replays the whole paste once as phase -1, inside the captured window if focus moved [terminal/paste_fix.lua:M.install] +- `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc`. The patched `show` and `toggle` always focus and use the config captured at open [terminal/snacks.lua:patch_instance] +- `snacks` hides floats with `nvim_win_set_config({hide=true})` only on Neovim 0.10 and later. It also hides the Snacks backdrop window, and show un-hides both. When the cursor is in the float it hides, it runs `wincmd p` to leave it. It closes split windows and re-creates them on show. It closes a split through `pcall`, so hiding the last window does nothing [terminal/snacks.lua:cc_hide] [terminal/snacks.lua:set_backdrop_hidden] +- A top or bottom split that `snacks` re-creates takes its height from `snacks_win_opts.height`, else 30% of the editor lines. A left or right split takes its width from `snacks_win_opts.width`, else `split_width_percentage` of the editor columns, and gets the full editor height [terminal/snacks.lua:cc_show] [terminal/snacks.lua:resolve_split_size] +- `paste_fix` decides at phase 1 whether a streamed paste targets the managed terminal. It replays the whole paste once as phase -1, inside the captured window if focus moved. When that window is no longer valid, it replays in the current window [terminal/paste_fix.lua:M.install] - `paste_fix` passes other pastes, and all pastes while disabled, to the original `vim.paste` unchanged. `is_managed_terminal` reads `vim.bo[bufnr].buftype` outside `pcall`, so it throws for an invalid `bufnr`. Its only caller passes the current buffer [terminal/paste_fix.lua:is_managed_terminal] ## Side Effects - `native` runs `vsplit`, shows a new listed buffer from `nvim_create_buf` in the split, then runs `vim.fn.termopen` in that buffer with the command, env, and cwd. This spawns the Claude process. When an autocmd swapped another buffer into the split, `native` shows its terminal buffer there after the start. It runs `startinsert` when focused and `auto_insert` is not `false` [terminal/native.lua:open_terminal] - `native` on exit clears its state, for its own terminal and for an adopted one. With `auto_close` it closes the tracked window only if the window still shows the terminal buffer. When the buffer is invalid, it closes the tracked window without that check. It never closes the last window (the only non-floating window of the only tab page), which keeps showing the exited terminal [terminal/native.lua:forget_exited_job] -- `native.close` clears its state and the buffer's mark, so an `open` that runs during the wait starts a new terminal. It calls `jobstop`, and when `jobstop` reports a running job, it waits up to 1 s for the job to exit. A close from a `TermClose` autocmd does not wait, because the job already exited. It then force-deletes the terminal buffer. Neovim closes the other windows that show it, and the editor's last window stays and shows another buffer. This works for a visible, hidden, or adopted terminal [terminal/native.lua:close_terminal] +- `native.close` clears its state and the buffer's mark, so an `open` that runs during the wait starts a new terminal. It registers a one-shot, buffer-local `TermClose` autocmd that detects the exit. It calls `jobstop`, and when `jobstop` reports a running job, it waits up to 1 s for the job to exit. A close from a `TermClose` autocmd does not wait, because the job already exited. It then force-deletes the terminal buffer. Neovim closes the other windows that show it, and the editor's last window stays and shows another buffer. This works for a visible, hidden, or adopted terminal [terminal/native.lua:close_terminal] - `snacks` calls `Snacks.terminal.open`, which spawns the Claude process [terminal/snacks.lua:M.open] -- `snacks` always registers a buffer-local `BufWipeout` handler. It registers a buffer-local `TermClose` handler only when `auto_close` is true. `TermClose` runs `checktime` and logs an ERROR on a nonzero exit, except when `close` stopped the job. Both handlers stop tracking the instance only while it is still the tracked one [terminal/snacks.lua:setup_terminal_events] -- `snacks.close` stops tracking the instance first, so an `open` that runs during the wait starts a new terminal. It stops a running job and waits up to 1 s for it to exit, then closes the Snacks window, which wipes the terminal buffer. Deleting the buffer before the job exits can hang Neovim when text sent to the job is still pending (neovim/neovim#42508). An exited job is not waited for. Hide keeps the job running [terminal/snacks.lua:M.close] +- `snacks` always registers a buffer-local `BufWipeout` handler. It registers a buffer-local `TermClose` handler only when `auto_close` is true. `TermClose` logs an ERROR on a nonzero exit, except when `close` stopped the job, then schedules `close({ buf = true })` on the instance and `checktime`. Both handlers stop tracking the instance only while it is still the tracked one [terminal/snacks.lua:setup_terminal_events] +- `snacks.close` stops tracking the instance first, so an `open` that runs during the wait starts a new terminal. For a running job, it registers a one-shot, buffer-local `TermClose` autocmd that detects the exit. It stops the job and waits up to 1 s for it to exit. It then closes the Snacks window, which wipes the terminal buffer. Deleting the buffer before the job exits can hang Neovim when text sent to the job is still pending (neovim/neovim#42508). An exited job is not waited for. Hide keeps the job running [terminal/snacks.lua:M.close] - `snacks` maps terminal-mode `` to send a backslash and then a carriage return after 10 ms [terminal/snacks.lua:build_opts] - `snacks` sets window options and `vim.w[win].snacks_win` on re-created splits [terminal/snacks.lua:reapply_snacks_window_state] - `external` spawns a detached process with `vim.fn.jobstart`. `close` calls `vim.fn.jobstop` [terminal/external.lua:M.close] - `none` has no side effects [terminal/none.lua:M.open] -- `paste_fix.install` replaces the global `vim.paste` once. The dispatcher calls `paste_fix.apply(defaults.fix_streamed_paste)` during setup, and the default is `"auto"` [terminal.lua:M.setup] +- `paste_fix.install` replaces the global `vim.paste` once. The dispatcher calls the provider's `setup(defaults)`, then `paste_fix.apply(defaults.fix_streamed_paste)`. The default is `"auto"`, and a value other than a boolean or `"auto"` gets a WARN and keeps the previous value [terminal.lua:M.setup] ## Verification diff --git a/lua/claudecode/tools/README.md b/lua/claudecode/tools/README.md index 9d09920a8..972ed0671 100644 --- a/lua/claudecode/tools/README.md +++ b/lua/claudecode/tools/README.md @@ -11,10 +11,10 @@ - `M.ERROR_CODES`: `PARSE_ERROR -32700`, `INVALID_REQUEST -32600`, `METHOD_NOT_FOUND -32601`, `INVALID_PARAMS -32602`, and `INTERNAL_ERROR -32000` [tools/init.lua:M.ERROR_CODES] - `M.tools`: a map from tool name to `{ handler, schema, requires_coroutine }` [tools/init.lua:M.tools] - `M.setup(server)`: stores the server and calls `register_all` [tools/init.lua:M.setup] -- `M.register(tool_module)`: requires `name` and `handler`. Otherwise it calls `vim.notify` at ERROR level and returns. It sends no MCP notification [tools/init.lua:M.register] +- `M.register(tool_module)`: requires `name` and `handler`. Otherwise it calls `vim.notify` at ERROR level and returns. It sends no MCP notification. A second module with the same name replaces the first without a warning [tools/init.lua:M.register] - `M.register_all()`: registers 10 exposed tools and the internal `close_tab` [tools/init.lua:M.register_all] -- `M.get_tool_list()`: returns `{ name, description, inputSchema }` only for tools that have a `schema` [tools/init.lua:M.get_tool_list] -- `M.handle_invoke(client, params)`: looks up `params.name` and runs the tool with `params.arguments` [tools/init.lua:M.handle_invoke] +- `M.get_tool_list()`: returns `{ name, description, inputSchema }` only for tools that have a `schema`. It walks `M.tools` with `pairs()`, so the order is not defined [tools/init.lua:M.get_tool_list] +- `M.handle_invoke(client, params)`: looks up `params.name` and calls the tool's `handler(params.arguments, client)` [tools/init.lua:M.handle_invoke] - `M.build_response(ok, value, err)`: maps a handler outcome, as `pcall` or `coroutine.resume` returns it, to `{ result }` or `{ error }`. `handle_invoke` uses it, and so does `diff.open_diff_blocking` when it resumes a deferred tool [tools/init.lua:M.build_response] ### Registered tools @@ -39,30 +39,47 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o - An unknown tool returns `{ error = { code = -32601, message = "Tool not found: " } }` [tools/init.lua:M.handle_invoke] - An error that is a `{ code, message }` table keeps its code, whether the handler raises it or returns `false, err`. This holds for tools with `requires_coroutine` too, also when the reply is sent after a deferred resume. Any other error maps to `-32000` [tools/init.lua:M.build_response] [diff.lua:M.open_diff_blocking] -- A successful handler returns `{ result = }` [tools/init.lua:M.handle_invoke]. Each tool handler builds the MCP shape `{ content = { { type = "text", text = ... } } }` itself [tools/open_file.lua:handler] +- A successful handler returns `{ result = }` [tools/init.lua:M.handle_invoke]. Each tool handler builds the MCP shape `{ content = { { type = "text", text = ... } } }` itself [tools/open_file.lua:handler]. `openDiff` returns the value of `diff.open_diff_blocking` unchanged, and `claudecode.diff` builds that shape [tools/open_diff.lua:handler] [diff.lua:M._resolve_diff_as_saved] +- The handlers read their parameters without checking that `arguments` is present. A call without `arguments` to a tool that reads one (for example `openFile`, `saveDocument`, or `getDiagnostics`, whose only parameter is optional) raises a Lua error, which `build_response` maps to `-32000`, not `-32602`. No handler enforces `additionalProperties = false` [tools/init.lua:M.handle_invoke] [tools/get_diagnostics.lua:handler] - Tools with `requires_coroutine` run in `coroutine.create`. If the coroutine yields, `handle_invoke` returns `{ _deferred = true, coroutine, client, params }` and sends no response yet. Only `openDiff` sets `requires_coroutine = true` [tools/init.lua:M.handle_invoke] -- `openDiff` raises `-32602` when a required parameter is missing. Outside a non-main coroutine it raises `-32000` with `message = "Internal server error"` and `data = "openDiff must run in coroutine context"` [tools/open_diff.lua:handler] +- `openDiff` raises `-32602` when a required parameter is missing. Outside a non-main coroutine it raises `-32000` with `message = "Internal server error"` and `data = "openDiff must run in coroutine context"`. It raises `-32000` with `data = "Failed to load diff module"` when `claudecode.diff` does not load [tools/open_diff.lua:handler] +- After a deferred `openDiff` yields, `diff.open_diff_blocking` maps the outcome with `build_response` and sends it through `_G.claude_deferred_responses[tostring(co)]`, then clears that entry [diff.lua:M.open_diff_blocking] - `openDiff` calls `diff.open_diff_blocking(old, new, contents, tab_name, client.id)`. Structured errors pass through. Other errors become `-32000` "Error opening blocking diff" [tools/open_diff.lua:handler] - `openFile`, `checkDocumentDirty`, and `saveDocument` raise `-32602` when `filePath` is missing [tools/open_file.lua:handler] - `openFile` raises `-32602` before it opens the file when `startLine` or `endLine` is given but is not an integer (`false` included), `startLine` is below 1 or past the last line, or `endLine` is below `startLine`. The last line comes from the loaded buffer that `:edit` would reuse, which `bufexists()` and `bufadd()` find by name or by the inode Neovim cached for it, so a symlink or hard link finds it. Without one it comes from the file on disk. For a loaded buffer without unsaved changes the file on disk counts too, because openFile loads the file again when it changed. When the buffer openFile then shows has fewer lines than `startLine`, openFile selects its last line. An `endLine` past the last line selects through the last line, and the answer names the lines it selected [tools/open_file.lua:handler] - `openFile` raises `-32000` with `message = "File operation error"` and `data = "File not found: "` when the file is not readable, and with `data = "No preview window shows the file"` when the preview window is closed or no longer has 'previewwindow' set after it gets the file, for example because an autocommand cleared it. It raises `-32000` and changes nothing while the command-line window is open, and when the user is in the middle of a command in the preview window, which openFile would reuse. Its defaults are `preview=false`, `makeFrontmost=true`, and `selectToEndOfLine=false` [tools/open_file.lua:handler] -- `checkDocumentDirty` and `saveDocument` return a `success=false` JSON result (not an error) when the file has no open buffer [tools/save_document.lua:handler] +- `openFile` expands only a leading `~` in `filePath` (`utils.expand_tilde`). When only `endLine` is given, `startLine` is 1. When only `startLine` is given, `endLine` equals it [tools/open_file.lua:handler] +- `openFile` finds `startText` and `endText` as plain text and takes the first match. It searches `endText` after the `startText` match, ignores an empty string, and applies `selectToEndOfLine` only when `endText` matches. A text selection replaces a line range. When `startText` is not found, the call succeeds with `Opened file, but text "" not found` [tools/open_file.lua:handler] +- With `makeFrontmost=false`, `openFile` answers with JSON text `{ success = true, filePath, languageId, lineCount }` for the buffer it shows [tools/open_file.lua:handler] +- The main editor window can be in another tab page, because `find_main_editor_window` scans `nvim_list_wins()`. With `makeFrontmost=true`, focusing it switches to that tab page [tools/open_file.lua:find_main_editor_window] +- `checkDocumentDirty` and `saveDocument` return a `success=false` JSON result (not an error) with `Document not open: ` when `vim.fn.bufnr(filePath)` is `-1`. They do not normalize the path, and a buffer that exists but is unloaded counts as open [tools/save_document.lua:handler] [tools/check_document_dirty.lua:handler] +- `saveDocument` reports a failed `:write` as a `success=false` result with `Failed to save file: `, not as an error [tools/save_document.lua:handler] - `getDiagnostics` raises `-32000` when `vim.lsp`, `vim.diagnostic`, or `vim.diagnostic.get` is missing. It raises `-32001` "File not open" when no buffer exists for `uri` (`vim.fn.bufnr(path) == -1`). An unloaded buffer does not raise [tools/get_diagnostics.lua:handler] -- `getLatestSelection` returns a `success=false` JSON result when no selection exists [tools/get_latest_selection.lua:handler] -- With no selection, `getCurrentSelection` returns `success=false` only when the active buffer has no name. When a named buffer is active, it returns `success=true` with an empty selection [tools/get_current_selection.lua:handler] -- `close_tab` returns its error tables as values, not raised errors. `handle_invoke` therefore wraps them in `result`. It answers `TAB_CLOSED` when it closes the diff, when it keeps a diff the user accepted for another client, and when no diff has that name [tools/close_tab.lua:handler] +- `getDiagnostics` returns a JSON array with one `{ uri, diagnostics }` entry per file. Each diagnostic has `message`, `severity` (`Error`, `Warning`, `Info`, or `Hint`), `range`, `source`, and `code` as a string. It drops a diagnostic that has no file path, so without `uri` the diagnostics of unnamed buffers are left out [tools/get_diagnostics.lua:append_diagnostic] [tools/get_diagnostics.lua:severity_name] +- `getLatestSelection` returns a `success=false` JSON result when no selection exists. Otherwise it returns the stored selection without a `success` field [tools/get_latest_selection.lua:handler] +- `getCurrentSelection` reads the same stored selection as `getLatestSelection` (`selection.get_latest_selection()`) and adds `success=true`. It does not check that the selection belongs to the active buffer [tools/get_current_selection.lua:handler] [selection.lua:M.get_latest_selection] +- With no selection, `getCurrentSelection` returns `success=false` only when the active buffer has no name. When a named buffer is active, it returns `success=true` with an empty selection at 0:0, not at the cursor [tools/get_current_selection.lua:handler] +- `getCurrentSelection` and `getLatestSelection` raise `-32000` with `message = "Internal server error"` and `data = "Failed to load selection module"` when `claudecode.selection` does not load [tools/get_current_selection.lua:handler] [tools/get_latest_selection.lua:handler] +- `getCurrentSelection` raises `-32000` with `message = "Internal server error"` and `data = "Failed to encode : "` when `vim.json.encode` fails [tools/get_current_selection.lua:safe_json_encode] +- `getOpenEditors` lists only buffers that are loaded, listed, and named. It gives every buffer the current tab page as its group, and puts the latest selection on the active buffer's entry without checking which file it came from [tools/get_open_editors.lua:handler] +- `getWorkspaceFolders` always returns one folder, built from `vim.fn.getcwd()`. It adds no LSP workspace folders [tools/get_workspace_folders.lua:handler] +- `close_tab` returns its error tables as values, not raised errors. `handle_invoke` therefore wraps them in `result`. It answers `TAB_CLOSED` when it closes the diff, when it keeps a diff the user accepted for another client, when no diff has that name, and when `claudecode.diff` does not load. Its value for a logger that does not load uses code `-32603`, which is not in `M.ERROR_CODES` [tools/close_tab.lua:handler] ## Side Effects -- `openFile` runs `edit` in the main editor window, the first window that is not floating, a terminal, prompt, or `nofile` buffer, a diff window, the preview window, or a file-explorer or outline sidebar. With preview it shows the file in the preview window of that tab page instead, and makes one as `pedit` does when the tab page has none. Like `pedit`, it shows the file there even when that window has 'winfixbuf' set. A buffer that is already loaded is not re-read, so its unsaved changes and the folds of other windows on it stay. When its file changed on disk, openFile loads a buffer without unsaved changes again, as `:edit` does, and keeps a buffer with unsaved changes as it is, without asking the user. For that it adds a FileChangedShell autocommand for the buffer that runs after the user's and sets `v:fcs_choice`, so 'autoread' and a user's `v:fcs_choice` do not change the result. 'eventignore' or, on Neovim 0.11 and later, 'eventignorewin' can make Neovim skip that autocommand. For a window that already shows the buffer, openFile reads them as Neovim does, where 'eventignorewin' counts for the current window when it shows the buffer and otherwise only when every window that shows the buffer ignores the event. When the event is ignored there, openFile checks the file only with 'autoread', which loads it without asking, so with 'noautoread' the buffer keeps the old text. When openFile shows the buffer in a window that did not show it, and 'eventignore', or 'eventignorewin' of that window or of a window that shows the buffer, ignores the event, openFile turns Neovim's own check off for that step and then checks the file as for a window that shows the buffer. Autocommands that run during that step see the buffer's 'buftype' set to `acwrite`, which keeps 'modified' as it is. A plugin that skips buffers whose 'buftype' is set skips this one in those autocommands, until a window enters it again, and an autocommand that sets 'buftype' to `acwrite` itself is undone. A write of an `acwrite` buffer skips Neovim's E13 and changed-file checks and goes to BufWriteCmd. So when a user's `++nested` BufEnter, BufWinEnter or BufLeave autocommand writes the buffer during that step, and a user's BufWriteCmd autocommand that matches the file (such as `BufWriteCmd *`) writes ``, that handler writes the buffer over the file. Without `++nested` no BufWriteCmd runs and the write fails with E676. Without such a handler it fails the same way. Outside that step, a write of a buffer with unsaved changes whose file Claude changed is a normal write. When a user's autocommand starts one while openFile switches buffers or windows, for example `autocmd BufLeave * silent! wall`, Neovim asks "The file has been changed since reading it!!! Do you really want to write to it (y/n)?", and openFile answers Claude only after the user does. `y` writes the unsaved text over Claude's file. `n` or Escape writes nothing, and the buffer keeps its unsaved text. CTRL-C writes nothing, leaves the focus where it was, and fails openFile with "Keyboard interrupt". Other keys repeat the question. After `y` the answer to Claude also waits for Enter at Neovim's hit-enter prompt, on 0.8 and 0.10, and on 0.12 with the default 'messagesopt'. A buffer whose 'buftype' is already set is not checked, as Neovim does not check it. A buffer opened before its file existed, for which Neovim fired BufNewFile, gets the same step, because Neovim's question for it, W13, runs no FileChangedShell. Without unsaved changes openFile reads the file into it with `:edit`, except during Visual or Select mode that CTRL-O started, which `:edit` would end, and except when an autocommand showed another buffer in the window. Then the buffer keeps its text. With unsaved changes it keeps them, and a later `:w` fails with E13 until `:w!`. openFile knows such buffers from the time the Claude Code server starts. A buffer loaded before then counts when its file was missing at that time. No Neovim API tells such a buffer apart, so W13 can still come for one whose file appeared before the server started, or that was made while 'eventignore' ignored BufNewFile. A write that runs no BufWritePost does not end the tracking: one from an autocommand without `++nested`, such as `autocmd BufLeave * update`, or one through a BufWriteCmd handler. openFile then keeps running its acwrite step for that buffer, and reads the file into it again with `:edit` when it has no unsaved changes. Neovim no longer asks W13 for that buffer, but it asks W12 when the buffer has unsaved text and its file changed. The acwrite step turns Neovim's check off, so no question comes, and unsaved text stays. Neovim can still ask when a user's FileChangedShell autocommand deletes openFile's: the user's autocommand then decides alone. An error in a user's autocommand fails openFile. Run from Lua, such an error during a reload stops the reload halfway and leaves the old text with empty lines added, while Neovim takes the file as read. So when the reload began, openFile reads the file again under `:silent!`, as a `:checktime` that the user types would finish, before it fails: the buffer gets the whole file, and that read ends Visual mode. After a FileChangedShell error no reload begins: the buffer keeps the old text, and a later `:w` warns that the file changed. When no suitable window exists, it can split or move windows. It splits instead of editing into a diff or preview window, splits the window below the preview window instead of the preview window itself, and with `makeFrontmost=false` gives the focus back to the window that had it. With preview it splits only to make the preview window when the tab page has none, so a terminal keeps one window. Then it works in the window that shows the file. For line or text ranges it searches that window's buffer and sets the visual marks and the cursor there. `makeFrontmost` focuses that window, and with `makeFrontmost=false` the JSON answer describes its buffer. Visual mode exists only in the current window, so with `makeFrontmost=false` and another window current, the marks are set but no selection stays on. With preview this holds even when the current window shows the same file, because the file goes to the preview window. With 'selection' set to inclusive, a range that ends on an empty line also covers that line's line break. When `endText` is not found, it only places the cursor on the `startText` match. It never changes the buffer. It ends any Visual or Select mode before it selects, it always selects charwise, and `gv` is the last key it sends, so with 'selectmode' containing `cmd` the selection is left in Select mode. When the user is in the middle of a command, it leaves the current window, its cursor, and the mode alone. That covers an operator such as `d` that waits for its motion, Insert mode, Replace mode, CTRL-O from them, and the command line. On Neovim 0.10 and later it also covers Visual or Select mode that CTRL-O started, which `state()` shows as `S`. On 0.8 and 0.9 that mode looks like plain Visual mode, so openFile ends it and selects as usual: after the call the mode is `v` with Claude's selection in the opened file, the user's next Visual command acts on that selection, and Insert mode then resumes in the opened file. It shows the file in the preview window, places the cursor there, selects nothing, does not focus it, and sends no keys, so the user's next keys finish the command where it started. When the command runs in the buffer of that file, the rules above can still load the file into it, and the user's next keys then act on the new text. The answer says so, except the `makeFrontmost=false` JSON. A register prefix such as `"a` looks like Normal mode, so openFile works as usual and the user's next command acts in the opened file. A count or a prefix such as `g` or CTRL-W blocks Neovim, which reads no request until the next key [tools/open_file.lua:handler] +- `openFile` runs `edit` in the main editor window, the first window that is not floating, a terminal, prompt, or `nofile` buffer, a diff window, the preview window, or a file-explorer or outline sidebar. With preview it shows the file in the preview window of that tab page instead, and makes one as `pedit` does when the tab page has none. Like `pedit`, it shows the file there even when that window has 'winfixbuf' set. A buffer that is already loaded is not re-read, so its unsaved changes and the folds of other windows on it stay. When its file changed on disk, openFile loads a buffer without unsaved changes again, as `:edit` does, and keeps a buffer with unsaved changes as it is, without asking the user. For that it adds a FileChangedShell autocommand for the buffer that runs after the user's and sets `v:fcs_choice`, so 'autoread' and a user's `v:fcs_choice` do not change the result. 'eventignore' or, on Neovim 0.11 and later, 'eventignorewin' can make Neovim skip that autocommand. For a window that already shows the buffer, openFile reads them as Neovim does, where 'eventignorewin' counts for the current window when it shows the buffer and otherwise only when every window that shows the buffer ignores the event. When the event is ignored there, openFile checks the file only with 'autoread', which loads it without asking, so with 'noautoread' the buffer keeps the old text. When openFile shows the buffer in a window that did not show it, and 'eventignore', or 'eventignorewin' of that window or of a window that shows the buffer, ignores the event, openFile turns Neovim's own check off for that step and then checks the file as for a window that shows the buffer. Autocommands that run during that step see the buffer's 'buftype' set to `acwrite`, which keeps 'modified' as it is. A plugin that skips buffers whose 'buftype' is set skips this one in those autocommands, until a window enters it again, and an autocommand that sets 'buftype' to `acwrite` itself is undone. A write of an `acwrite` buffer skips Neovim's E13 and changed-file checks and goes to BufWriteCmd. So when a user's `++nested` BufEnter, BufWinEnter or BufLeave autocommand writes the buffer during that step, and a user's BufWriteCmd autocommand that matches the file (such as `BufWriteCmd *`) writes ``, that handler writes the buffer over the file. Without `++nested` no BufWriteCmd runs and the write fails with E676. Without such a handler it fails the same way. Outside that step, a write of a buffer with unsaved changes whose file Claude changed is a normal write. When a user's autocommand starts one while openFile switches buffers or windows, for example `autocmd BufLeave * silent! wall`, Neovim asks "The file has been changed since reading it!!! Do you really want to write to it (y/n)?", and openFile answers Claude only after the user does. `y` writes the unsaved text over Claude's file. `n` or Escape writes nothing, and the buffer keeps its unsaved text. CTRL-C writes nothing, leaves the focus where it was, and fails openFile with "Keyboard interrupt". Other keys repeat the question. After `y` the answer to Claude also waits for Enter at Neovim's hit-enter prompt, on 0.8 and 0.10, and on 0.12 with the default 'messagesopt'. A buffer whose 'buftype' is already set is not checked, as Neovim does not check it. A buffer opened before its file existed, for which Neovim fired BufNewFile, gets the same step, because Neovim's question for it, W13, runs no FileChangedShell. Without unsaved changes openFile reads the file into it with `:edit`, except during Visual or Select mode that CTRL-O started, which `:edit` would end, and except when an autocommand showed another buffer in the window. Then the buffer keeps its text. With unsaved changes it keeps them, and a later `:w` fails with E13 until `:w!`. openFile knows such buffers from the time `open_file.lua` loads, which `tools.register_all` does when the Claude Code server starts. A buffer loaded before then counts when its file was missing at that time. No Neovim API tells such a buffer apart, so W13 can still come for one whose file appeared before the server started, or that was made while 'eventignore' ignored BufNewFile. A write that runs no BufWritePost does not end the tracking: one from an autocommand without `++nested`, such as `autocmd BufLeave * update`, or one through a BufWriteCmd handler. openFile then keeps running its acwrite step for that buffer, and reads the file into it again with `:edit` when it has no unsaved changes. Neovim no longer asks W13 for that buffer, but it asks W12 when the buffer has unsaved text and its file changed. The acwrite step turns Neovim's check off, so no question comes, and unsaved text stays. Neovim can still ask when a user's FileChangedShell autocommand deletes openFile's: the user's autocommand then decides alone. An error in a user's autocommand fails openFile. Run from Lua, such an error during a reload stops the reload halfway and leaves the old text with empty lines added, while Neovim takes the file as read. So when the reload began, openFile reads the file again under `:silent!`, as a `:checktime` that the user types would finish, before it fails: the buffer gets the whole file, and that read ends Visual mode. After a FileChangedShell error no reload begins: the buffer keeps the old text, and a later `:w` warns that the file changed. When no suitable window exists, it can split or move windows. It goes to the top-left window, then to the window right of it. When that is the preview window, it moves to the window below, because a split of the preview window keeps its 'previewheight' lines. It splits the window it reached instead of editing into it when that window shows a terminal or `nofile` buffer or is a diff or preview window, and turns 'diff' off in the new split. With `makeFrontmost=false` it gives the focus back to the window that had it. With preview it splits only to make the preview window when the tab page has none, so a terminal keeps one window. Then it works in the window that shows the file. For line or text ranges it searches that window's buffer and sets the visual marks and the cursor there. `makeFrontmost` focuses that window, and with `makeFrontmost=false` the JSON answer describes its buffer. Visual mode exists only in the current window, so with `makeFrontmost=false` and another window current, the marks are set but no selection stays on. With preview this holds even when the current window shows the same file, because the file goes to the preview window. With 'selection' set to inclusive, a range that ends on an empty line also covers that line's line break. When `endText` is not found, it only places the cursor on the `startText` match. After it places the cursor on a `startText` match, it scrolls that window so the match line is centered (`normal! zz`). Selecting never changes the buffer's text. It ends any Visual or Select mode before it selects, it always selects charwise, and `gv` is the last key it sends, so with 'selectmode' containing `cmd` the selection is left in Select mode. When the user is in the middle of a command, it leaves the current window, its cursor, and the mode alone. That covers an operator such as `d` that waits for its motion, Insert mode, Replace mode, CTRL-O from them, and the command line. On Neovim 0.10 and later it also covers Visual or Select mode that CTRL-O started, which `state()` shows as `S`. On 0.8 and 0.9 that mode looks like plain Visual mode, so openFile ends it and selects as usual: after the call the mode is `v` with Claude's selection in the opened file, the user's next Visual command acts on that selection, and Insert mode then resumes in the opened file. It shows the file in the preview window, places the cursor there, selects nothing, does not focus it, and sends no keys, so the user's next keys finish the command where it started. When the command runs in the buffer of that file, the rules above can still load the file into it, and the user's next keys then act on the new text. The answer says so, except the `makeFrontmost=false` JSON. A register prefix such as `"a` looks like Normal mode, so openFile works as usual and the user's next command acts in the opened file. A count or a prefix such as `g` or CTRL-W blocks Neovim, which reads no request until the next key [tools/open_file.lua:handler] - `openDiff` opens a blocking diff through `claudecode.diff`. The response stays deferred until the user accepts or rejects the diff [tools/open_diff.lua:handler]. A diff that resolves before `openDiff` yields, for example from a `ClaudeCodeDiffOpened` handler, is answered at once [diff.lua:M.open_diff_blocking] - `saveDocument` runs `write` in the matching buffer with `nvim_buf_call` [tools/save_document.lua:handler] -- `closeAllDiffTabs` calls `diff.close_all_diffs` and returns `CLOSED__DIFF_TABS` [tools/close_all_diff_tabs.lua:handler] +- `closeAllDiffTabs` calls `diff.close_all_diffs("closeAllDiffTabs tool", client.id)` and returns `CLOSED__DIFF_TABS`. `n` counts only the diffs it closed: a diff the user accepted for another client stays open and is not counted [tools/close_all_diff_tabs.lua:handler] [diff.lua:M.close_all_diffs] - `close_tab` calls `diff.close_diff_by_tab_name` with the tab name and the calling client. It closes or deletes no other buffer, whatever the name [tools/close_tab.lua:handler] -- `getCurrentSelection`, `getLatestSelection`, `getOpenEditors`, `getDiagnostics`, `getWorkspaceFolders`, and `checkDocumentDirty` only read editor state [tools/get_open_editors.lua:handler] -- `handle_invoke` writes debug log entries through `claudecode.logger` [tools/init.lua:M.handle_invoke] +- Both close tools resolve a pending diff as rejected first, which resumes the waiting `openDiff` with `DIFF_REJECTED`. An accepted diff reloads its file buffers 100 ms later [diff.lua:M.close_diff_by_tab_name] +- Loading `open_file.lua` creates the `ClaudeCodeOpenFileNewFiles` augroup (`BufNewFile`, `BufReadPost`/`BufUnload`, and `BufWritePost` autocmds) that tracks buffers opened before their file existed, and marks such buffers that are already loaded [tools/open_file.lua:new_file_buffers] +- `getCurrentSelection`, `getLatestSelection`, `getOpenEditors`, `getWorkspaceFolders`, and `checkDocumentDirty` only read editor state [tools/get_open_editors.lua:handler] +- `getDiagnostics` reads editor state and writes debug log entries through `claudecode.logger` [tools/get_diagnostics.lua:handler] +- `handle_invoke` writes debug log entries through `claudecode.logger` on the `requires_coroutine` path [tools/init.lua:M.handle_invoke] +- `close_tab` writes debug and error log entries through `claudecode.logger` [tools/close_tab.lua:handler] ## Verification - Run: `mise run test` -- Targeted: `busted -v tests/unit/tools_spec.lua`, `busted -v tests/unit/tools/open_file_spec.lua`, `busted -v tests/unit/tools/open_diff_mcp_spec.lua`, `busted -v tests/unit/tools/get_diagnostics_spec.lua`, `busted -v tests/unit/tools/save_document_spec.lua`, `busted -v tests/integration/mcp_tools_spec.lua` +- Targeted: `busted -v tests/unit/tools_spec.lua`, `busted -v tests/unit/tools/open_file_spec.lua`, `busted -v tests/unit/tools/open_diff_mcp_spec.lua`, `busted -v tests/unit/tools/get_diagnostics_spec.lua`, `busted -v tests/unit/tools/save_document_spec.lua`, `busted -v tests/unit/tools/close_tab_spec.lua`, `busted -v tests/unit/tools/get_current_selection_spec.lua`, `busted -v tests/unit/tools/get_open_editors_spec.lua`, `busted -v tests/integration/mcp_tools_spec.lua`, `busted -v tests/integration/open_file_selection_spec.lua` diff --git a/plugin/README.md b/plugin/README.md index 63f8634ed..47ad5821a 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -21,9 +21,11 @@ when `require("claudecode").setup()` runs. The main module lives in the value. Example from the source comment: `vim.g.claudecode_auto_setup = { auto_start = true }`. `[plugin/claudecode.lua:vim.g.claudecode_auto_setup]` -- `require("claudecode")`: loaded once with `pcall` at the end of the file. - The file does not call `setup` at this point. +- `require("claudecode")`: called in two places. At the end of the file it + runs once with `pcall`, and the file does not call `setup` at this point. + Inside the deferred auto-setup callback it runs without `pcall`. `[plugin/claudecode.lua:pcall(require, "claudecode")]` + `[plugin/claudecode.lua:vim.defer_fn]` - No Lua module table, functions, or user commands are exported from this directory. Commands come from `M._create_commands()`, which `M.setup` calls. `[lua/claudecode/init.lua:M._create_commands]` @@ -34,18 +36,35 @@ when `require("claudecode").setup()` runs. The main module lives in error and returns before it sets `vim.g.loaded_claudecode`. `[plugin/claudecode.lua:vim.fn.has]` - After the first successful load, sourcing the file again does nothing, - because `vim.g.loaded_claudecode` is already set. + because `vim.g.loaded_claudecode` is already set. No auto-setup is scheduled + and no `require` runs on the second source. + `[plugin/claudecode.lua:vim.g.loaded_claudecode]` +- The file sets `vim.g.loaded_claudecode` before it schedules auto-setup and + before it loads the main module. If the module fails to load, the guard stays + set, so sourcing the file again does not retry the load. `[plugin/claudecode.lua:vim.g.loaded_claudecode]` - Auto-setup is scheduled only when `vim.g.claudecode_auto_setup` is truthy. Without it, the user must call `require("claudecode").setup()` to get commands and the server. `[plugin/claudecode.lua:vim.g.claudecode_auto_setup]` +- The file reads `vim.g.claudecode_auto_setup` twice. The truthiness check runs + while the file is sourced. The callback reads the global again when it runs, + so `setup` gets the value the global holds at that moment. + `[plugin/claudecode.lua:vim.defer_fn]` - Order of execution: the version check, the load guard, the `vim.defer_fn` scheduling, and the `pcall(require, "claudecode")` run synchronously while the file is sourced. The deferred `setup` call runs later, after the current script finishes. `[plugin/claudecode.lua:vim.defer_fn]` -- The deferred `setup` call is not wrapped in `pcall`. An error raised inside - `M.setup` surfaces from the deferred callback. +- Commands from auto-setup do not exist while startup scripts run. In a + headless check on Neovim 0.12.3, `:ClaudeCode` was missing when the `-c` + startup commands ran, and present after the deferred callback ran. `[plugin/claudecode.lua:vim.defer_fn]` +- The deferred `require("claudecode").setup(...)` call is not wrapped in + `pcall`. An error from loading the module or from `M.setup` surfaces from the + deferred callback. `[plugin/claudecode.lua:vim.defer_fn]` +- This file does not check whether `setup` already ran. If the user's config also + calls `require("claudecode").setup()`, `setup` runs twice. `M.setup` does + not check `M.state.initialized` before it applies the configuration. + `[plugin/claudecode.lua:vim.defer_fn]` `[lua/claudecode/init.lua:M.setup]` ## Side Effects @@ -53,25 +72,50 @@ when `require("claudecode").setup()` runs. The main module lives in `[plugin/claudecode.lua:vim.g.loaded_claudecode]` - On Neovim older than 0.8.0, writes `Claude Code requires Neovim >= 0.8.0` with `vim.api.nvim_err_writeln`. - `[plugin/claudecode.lua:vim.api.nvim_err_writeln]` + This path does not use `vim.notify` and has no log level. Because the version + check runs before the load guard, every source on an old Neovim writes the + message again. `[plugin/claudecode.lua:vim.api.nvim_err_writeln]` - Loads the `claudecode` Lua module into `package.loaded` at startup. If the - load fails, shows an `ERROR` notification with `vim.notify`. - `[plugin/claudecode.lua:vim.notify]` -- When `vim.g.claudecode_auto_setup` is set, schedules + load fails, shows + `ClaudeCode: Failed to load main module. Plugin may not function correctly.` + with `vim.notify` at `vim.log.levels.ERROR`. A successful load shows no + message. `[plugin/claudecode.lua:vim.notify]` +- When `vim.g.claudecode_auto_setup` is truthy, schedules `require("claudecode").setup(vim.g.claudecode_auto_setup)` with `vim.defer_fn(..., 0)`. `[plugin/claudecode.lua:vim.defer_fn]` - The deferred `setup` call triggers the effects of `M.setup`: it applies the - configuration, sets up the logger, terminal, and diff modules, calls - `M.start(false)` when `auto_start` is true, registers user commands, and - creates the `ClaudeCodeShutdown` augroup with a `VimLeavePre` autocmd. - `[lua/claudecode/init.lua:M.setup]` + configuration, sets up the logger, terminal, and diff modules, warns when + `focus_after_send` cannot work with the configured terminal provider, calls + `M.start(false)` when `auto_start` is true, registers user commands, creates + the `ClaudeCodeShutdown` augroup with a `VimLeavePre` autocmd, and sets + `M.state.initialized = true`. `[lua/claudecode/init.lua:M.setup]` + `[lua/claudecode/init.lua:M._maybe_warn_unfocusable_provider]` ## Verification -- Run: `mise run test` -- No spec under `tests/` exercises `plugin/claudecode.lua` directly. The test - bootstrap `tests/minimal_init.lua` reads `vim.g.loaded_claudecode` but calls +- Run: `mise run test` (or `mise run all` for format, lint, and test). +- No spec under `tests/` exercises `plugin/claudecode.lua` directly. + `mise run test` runs Busted on `*_spec.lua` and `*_test.lua` files under + LuaJIT, not inside Neovim. +- `mise run check` runs the syntax check on `lua/` and `luacheck` on `lua/` and + `tests/`. Neither covers `plugin/claudecode.lua`. `mise run format` formats + it with StyLua. +- `tests/minimal_init.lua` is the `-u` init file for headless Neovim test runs + (`scripts/run_integration_tests_individually.sh`), not for `mise run test`. + It calls `require("claudecode").setup({ auto_start = false, log_level = "trace" })` - itself. + itself, and only when `vim.g.loaded_claudecode` is unset and `should_load` is + true (the command line names `command_args_spec` or `mcp_tools_spec`, or + `CLAUDECODE_INTEGRATION_TEST=true`). - Manual check: `fixtures/repro/init.lua` puts this repository on the - `runtimepath` so that `plugin/claudecode.lua` loads. + `runtimepath` so that `plugin/claudecode.lua` loads. It also calls `setup` + itself, so it does not exercise auto-setup. +- Headless auto-setup check, run from the repository root. It prints `2` when + the deferred `setup` has registered `:ClaudeCode`, and `0` without the + `--cmd 'let g:claudecode_auto_setup ...'` line: + + ```sh + nvim --headless -u NORC -i NONE --cmd "set rtp^=$PWD" \ + --cmd 'let g:claudecode_auto_setup = {"auto_start": v:false}' \ + -c 'lua vim.defer_fn(function() io.stdout:write(vim.fn.exists(":ClaudeCode") .. "\n") vim.cmd("qa!") end, 100)' + ``` diff --git a/scripts/README.md b/scripts/README.md index 8f1f10e61..78a440cd8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,121 +4,136 @@ `scripts/` holds developer and release tooling for claudecode.nvim. Nothing here is loaded by the plugin at runtime. The directory has five groups: -1. Shell libraries that find the plugin's WebSocket server through its lock file and speak JSON-RPC to it (`lib_claude.sh`, `lib_ws_persistent.sh`, `claude_shell_helpers.sh`). +1. Shell libraries that speak JSON-RPC to the plugin's WebSocket server (`lib_claude.sh`, `lib_ws_persistent.sh`, `claude_shell_helpers.sh`). `lib_claude.sh` finds the server through its lock file. `lib_ws_persistent.sh` takes the URL and the token from its caller. 2. Interactive and probe clients built on those libraries (`claude_interactive.sh`, `mcp_test.sh`, `research_messages.sh`, `websocat.sh`, `test_neovim_websocket.sh`). 3. Test harnesses (`run_integration_tests_individually.sh`, `test_opendiff.lua`, `test_opendiff_simple.sh`, `manual_test_helper.lua`). -4. Standalone reproductions of GitHub issues (`repro_issue_*.lua`, `repro_issue_*.sh`). -5. Release tooling: a release-please wrapper that writes changelog entries with Communique (`release-please-runner.js`, `run-release-please.sh`, `package.json`). `test-release-please-runner.js` holds the tests for `release-please-runner.js`. It also runs the pinned `communique` binary with the repo's `communique.toml` against a fake Anthropic API, to check that one release-notes submission fits the configured output budget. +4. Standalone reproductions of GitHub issues (`repro_issue_*.lua`, `repro_issue_*.sh`). `repro_issue_70_probe.lua` matches that pattern, but it is a helper that `repro_issue_70.sh` runs. +5. Release tooling: a release-please wrapper that writes changelog entries with Communique (`release-please-runner.js`, `run-release-please.sh`, `package.json`, `package-lock.json`). `test-release-please-runner.js` holds the tests for `release-please-runner.js`. It also runs the pinned `communique` binary with the repo's `communique.toml` against a fake Anthropic API, to check that one release-notes submission fits the configured output budget. CI runs four of these files: `.github/workflows/release-please.yaml` runs `./scripts/run-release-please.sh`, and `.github/workflows/test.yml` runs `./scripts/run_integration_tests_individually.sh` and `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"` on Neovim stable and v0.8.0, and `npm --prefix scripts run test:release-please-runner` (which runs `test-release-please-runner.js`). -`mise.toml` provisions `websocat` and `jq` for the WebSocket scripts. +`mise.toml` provisions `websocat` and `jq` for the WebSocket scripts, and `communique` for the release tooling. It does not provide `agent-tty`, which `repro_issue_70.sh`, `repro_issue_218.sh`, and `repro_issue_277.sh` need. `repro_issue_277.sh` also needs `git` and `perl`, and `repro_issue_70.sh` needs a logged-in `claude` CLI [repro_issue_70.sh:MISSING dependency] [repro_issue_218.sh:agent-tty] [repro_issue_277.sh:MISSING dependency]. ## Interface ### Shell libraries (source them) -- `lib_claude.sh` sets `CLAUDE_LOCKFILE_DIR` to `$CLAUDE_CONFIG_DIR/ide`, or to `$HOME/.claude/ide` when `CLAUDE_CONFIG_DIR` is unset. It also exports `CLAUDE_LOG_DIR=mcp_test_logs` and `CLAUDE_WS_TIMEOUT=10` [lib_claude.sh:CLAUDE_LOCKFILE_DIR]. +- `lib_claude.sh` sets `CLAUDE_LOCKFILE_DIR` to `$CLAUDE_CONFIG_DIR/ide`, or to `$HOME/.claude/ide` when `CLAUDE_CONFIG_DIR` is unset or empty. It also exports `CLAUDE_LOG_DIR=mcp_test_logs` and `CLAUDE_WS_TIMEOUT=10` each time you source it, so set either variable after the `source` line [lib_claude.sh:CLAUDE_LOCKFILE_DIR, CLAUDE_LOG_DIR, CLAUDE_WS_TIMEOUT]. - `find_claude_lockfile` prints the port of the newest `*.lock` file by mtime and returns 1 when none exists. It reads the mtime with GNU `stat -c "%Y"`. BSD `stat` on macOS rejects that option, so there the function finds no lock file [lib_claude.sh:find_claude_lockfile]. - `get_claude_ws_url` prints `ws://localhost:PORT` [lib_claude.sh:get_claude_ws_url]. - `get_claude_auth_token PORT` reads `authToken` from `PORT.lock`, with `jq` or a `grep`/`sed` fallback [lib_claude.sh:get_claude_auth_token]. - `create_message`, `create_notification`, and `create_init_message` print JSON-RPC requests, notifications, and an `initialize` request (protocolVersion `2025-03-26`) [lib_claude.sh:create_message, create_notification, create_init_message]. -- `send_claude_message MSG [WS_URL] [AUTH_TOKEN]` sends one message through `timeout` + `websocat` [lib_claude.sh:send_claude_message]. -- `list_claude_tools`, `get_current_selection`, `open_file PATH`, and `initialize_claude_session` wrap `tools/list`, `tools/call getCurrentSelection`, `tools/call openFile`, and the initialize handshake [lib_claude.sh:list_claude_tools, get_current_selection, open_file, initialize_claude_session]. +- `send_claude_message MSG [WS_URL] [AUTH_TOKEN]` sends one message through `timeout` + `websocat`. It always returns 0. When `timeout` exits nonzero, it prints a JSON-RPC error with code `-32000` after any response it got [lib_claude.sh:send_claude_message]. +- `list_claude_tools`, `get_current_selection`, `open_file PATH`, and `initialize_claude_session` wrap `tools/list`, `tools/call getCurrentSelection`, `tools/call openFile`, and the initialize handshake. `open_file` returns 0 even when the call fails [lib_claude.sh:list_claude_tools, get_current_selection, open_file, initialize_claude_session]. - `init_log_dir`, `log_message_and_response`, `claude_is_running`, `command_exists`, and `check_required_tools` are helpers for logging, liveness, and tool checks [lib_claude.sh:init_log_dir, log_message_and_response, claude_is_running, command_exists, check_required_tools]. -- `lib_ws_persistent.sh` keeps a WebSocket open across calls. Functions: `ws_connect URL [CONN_ID] [AUTH_TOKEN]`, `ws_is_connected`, `ws_disconnect`, `ws_request JSON [CONN_ID] [TIMEOUT]`, `ws_notify`, `ws_rpc_request`, `ws_create_message`, `ws_format_json`, `ws_cleanup_all`, `ws_setup_trap`, `ws_start_listener CONN_ID CALLBACK [METHOD]`, and `ws_stop_listener` [lib_ws_persistent.sh:ws_connect, ws_request, ws_start_listener]. +- `lib_ws_persistent.sh` keeps a WebSocket open across calls. Functions: `ws_connect URL [CONN_ID] [AUTH_TOKEN]`, `ws_is_connected`, `ws_disconnect`, `ws_request JSON [CONN_ID] [TIMEOUT]`, `ws_notify`, `ws_rpc_request`, `ws_create_message`, `ws_format_json`, `ws_cleanup_all`, `ws_setup_trap`, `ws_start_listener CONN_ID CALLBACK [METHOD]`, and `ws_stop_listener` [lib_ws_persistent.sh:ws_connect, ws_request, ws_start_listener]. It uses `declare -A`, so it needs bash 4 or later [lib_ws_persistent.sh:WS_CONNECTIONS]. - `ws_rpc_request` special-cases `mcp.connect`: it sends a notification and returns a fake success [lib_ws_persistent.sh:ws_rpc_request]. -- `claude_shell_helpers.sh` defines interactive shell commands: `claude_port`, `claude_ws_url`, `claude_running`, `claude_get_selection [-v]`, `claude_open_file`, `claude_list_tools [-v]`, `claude_send METHOD PARAMS [ID]`, `claude_interactive`, and `claude_help` [claude_shell_helpers.sh:claude_get_selection, claude_send, claude_help]. +- `claude_shell_helpers.sh` defines interactive shell commands: `claude_port`, `claude_ws_url`, `claude_running`, `claude_get_selection [-v|--verbose]`, `claude_open_file`, `claude_list_tools [-v|--verbose]`, `claude_send METHOD PARAMS [ID]`, `claude_interactive`, and `claude_help` [claude_shell_helpers.sh:claude_get_selection, claude_send, claude_help]. +- `claude_shell_helpers.sh` sets `CLAUDE_LIB_DIR` from `$0`, or to `$HOME/.claude/bin` when `$0` is `-bash` or `-zsh`, and sources `lib_claude.sh` from there. When you source it from bash, `$0` names the shell, not this file, so `lib_claude.sh` must be in the directory that `CLAUDE_LIB_DIR` resolves to [claude_shell_helpers.sh:CLAUDE_LIB_DIR]. +- `claude_get_selection` without `-v` or `--verbose` reads `.result.text`. The server returns the selection in `result.content[0].text`, so the command always prints `No text currently selected` [claude_shell_helpers.sh:claude_get_selection]. `claude_open_file` prints `Opened:` whatever the server answers [claude_shell_helpers.sh:claude_open_file]. ### WebSocket clients and probes -- `mcp_test.sh [-p PORT] [-l DIR] [-t SEC] [-c] [-h] [tests]` runs the MCP tests `connect`, `toolslist`, `toolinvoke`, `methods`, and `selection`. The default is `all`, and a comma-separated list is accepted. The port defaults to `find_claude_lockfile` [mcp_test.sh:should_run_test, WEBSOCKET_PORT]. -- `claude_interactive.sh` (no arguments) is a menu REPL: get selection, list tools, open file, custom JSON-RPC message, initialize, connection info, listen, reconnect, and exit [claude_interactive.sh:show_menu, handle_custom_message, handle_listen]. -- `research_messages.sh [-p PORT] [-t SEC] [-l LOGFILE]` listens for TIMEOUT seconds (default 30), probes several method names, and prints counts of methods, ids, and `selection_changed` notifications [research_messages.sh:send_test_message, TIMEOUT]. +- `mcp_test.sh [-p|--port PORT] [-l|--logs DIR] [-t|--timeout SEC] [-c|--compact] [-h|--help] [tests]` runs the MCP tests `connect`, `toolslist`, `toolinvoke`, `methods`, and `selection`. The default is `all`, and a comma-separated list is accepted. The port defaults to `find_claude_lockfile` [mcp_test.sh:should_run_test, WEBSOCKET_PORT]. `-t` sets the per-request timeout (default 5 s). The selection test always listens for 10 s. `-h` exits 1, and the script skips an unknown test name without a message [mcp_test.sh:TIMEOUT, listen_duration, usage, should_run_test]. +- In `mcp_test.sh`, `toolinvoke` and `methods` stop the whole run. `read -r` keeps the single quotes around each test case's params, `ws_format_json` (`jq -c`) rejects them with exit 5, and `set -e` exits. So `all` never reaches `methods` or `selection` [mcp_test.sh:test_tool_invoke, test_methods] [lib_ws_persistent.sh:ws_format_json]. +- `claude_interactive.sh` (no arguments) is a menu REPL: get selection, list tools, open file, custom JSON-RPC message, initialize, connection info, listen, reconnect, and exit [claude_interactive.sh:show_menu, handle_custom_message, handle_listen]. It exits 1 when the first `ws_connect` fails and exits 0 on the exit option [claude_interactive.sh:ws_connect]. +- `research_messages.sh [-p|--port PORT] [-t|--timeout SEC] [-l|--log LOGFILE]` listens for TIMEOUT seconds (default 30), probes several method names, and prints counts of methods, ids, `selection_changed` notifications, and tool responses [research_messages.sh:send_test_message, TIMEOUT]. `-l` moves only the JSONL log. `claude_messages_pretty.txt` always goes to the current directory [research_messages.sh:PRETTY_LOG]. +- In `research_messages.sh`, `grep -c` exits the script with 1 under `set -e` when the log holds no `selection_changed` notification, or no reply with id `2` or `3`. The probes go over separate one-shot `websocat -n1` connections whose replies never reach the log, so the script normally stops before its last count line [research_messages.sh:selection_changed_count, tool_responses, send_test_message]. - `websocat.sh` opens an interactive `websocat` session to `get_claude_ws_url` [websocat.sh:get_claude_ws_url]. -- `test_neovim_websocket.sh [-t SEC] [-l SEC] [-d DIR] [-h] [all|connect|toolslist|selection]` starts Neovim with the plugin, waits for a new lock file, and runs connection, `tools/list`, and selection-listen tests [test_neovim_websocket.sh:usage, run_tests, find_newest_lockfile]. +- `test_neovim_websocket.sh [-t|--timeout SEC] [-l|--listen SEC] [-d|--log-dir DIR] [-h|--help] [all|connect|toolslist|selection]` starts Neovim with the plugin, waits for a new lock file, and runs connection, `tools/list`, and selection-listen tests [test_neovim_websocket.sh:usage, run_tests, find_newest_lockfile]. It must run from the repo root, because its init runs `set rtp+=.`. It starts a full-screen `nvim` in the same terminal, not a headless one [test_neovim_websocket.sh:NVIM_INIT_FILE, NVIM_PID]. +- In `test_neovim_websocket.sh`, `((ELAPSED++))` returns status 1 when `ELAPSED` is 0, so `set -e` exits the script with 1 after the first one-second wait, unless the first check already found the new lock file. `((selection_count++))` ends the selection test the same way at its first notification [test_neovim_websocket.sh:ELAPSED, selection_count]. ### Test harnesses - `run_integration_tests_individually.sh` (no arguments) runs each `tests/integration/*_spec.lua` in its own headless Neovim under `timeout 30s`, skips `command_args_spec.lua`, and prints totals [run_integration_tests_individually.sh:run_test_file]. -- `test_opendiff.lua` is a raw-socket Lua client that sends `tools/call openDiff` with an edited `README.md` [test_opendiff.lua:test_opendiff]. -- `test_opendiff_simple.sh` sends the same `openDiff` call through `websocat` or `wscat` and reports exit 124 as "blocked" [test_opendiff_simple.sh:JSON_MESSAGE]. -- `manual_test_helper.lua` is run with `:luafile`. It calls the `claudecode.tools.open_diff` handler in a coroutine and reports whether it yields [manual_test_helper.lua:test_opendiff_directly]. +- `test_opendiff.lua` is a raw-socket Lua client that sends `tools/call openDiff` with an edited `README.md` [test_opendiff.lua:test_opendiff]. You run it with plain `lua` from the project root. It needs the `socket` (luasocket) and `json` Lua modules. `require("json")` raises an error when `json` is missing, so the `cjson` and `dkjson` fallbacks never run [test_opendiff.lua:json]. It exits 1 when setup or the handshake fails, and 0 otherwise [test_opendiff.lua:os.exit]. +- `test_opendiff_simple.sh` sends the same `openDiff` call through `websocat` or `wscat` and reports exit 124 as "blocked" [test_opendiff_simple.sh:JSON_MESSAGE]. It must run from the project root, and it needs `jq` and `timeout`. It exits 0 even after it prints `❌ Connection failed` [test_opendiff_simple.sh:ABS_PATH, NEW_CONTENT]. +- `manual_test_helper.lua` is run with `:luafile` in a Neovim that has the plugin on its `runtimepath`. It calls the `claudecode.tools.open_diff` handler in a coroutine and reports whether it yields [manual_test_helper.lua:test_opendiff_directly]. ### Issue reproductions The Lua repros run from the repository root with `nvim --headless -u NONE -l scripts/`, which needs Neovim 0.9 or later. They prepend the repo root to `runtimepath` [repro_issue_208.lua:rtp:prepend]. `repro_issue_70_probe.lua` is the exception: it prepends `/lua` to `package.path` instead [repro_issue_70_probe.lua:package.path]. `repro_issue_238.lua` runs with the `:luafile` command in its entry below, which also works on Neovim 0.8. -- `repro_issue_70.sh` (#70) checks whether the `claude` CLI connects back to the plugin when an HTTP proxy has no localhost exclusion. It prints `PASS issue #70 reproduced` (exit 0) or `INCONCLUSIVE` (exit 1). It drives `repro_issue_70_probe.lua` [repro_issue_70.sh:run_scenario]. -- `repro_issue_70_probe.lua ` is a helper, not a standalone repro. It starts the real server with `terminal.provider = "none"` and writes `phase`/`connected` JSON to the status file. Exit 0 means a connection was seen, 2 means none, and 1 means no port [repro_issue_70_probe.lua:write_status]. +- `repro_issue_70.sh` (#70) checks whether the `claude` CLI connects back to the plugin when an HTTP proxy has no localhost exclusion. It prints `PASS issue #70 reproduced` (exit 0) or `INCONCLUSIVE` (exit 1). It drives `repro_issue_70_probe.lua` [repro_issue_70.sh:run_scenario]. It needs `nvim`, `claude`, `agent-tty`, and `jq`, and exits 3 when one is missing [repro_issue_70.sh:MISSING dependency]. +- `repro_issue_70_probe.lua [wait_ms]` is a helper, not a standalone repro. `wait_ms` defaults to 120000 [repro_issue_70_probe.lua:wait_ms]. It starts the real server with `terminal.provider = "none"` and writes `phase`/`connected` JSON to the status file. Exit 0 means a connection was seen, 2 means none, and 1 means no port [repro_issue_70_probe.lua:write_status]. - `repro_issue_208.lua` (#208) checks for a leftover `[No Name]` tab or buffer after a diff resolves with `open_in_new_tab=true` and `provider=none`. Exit 0 means fixed, 1 means reproduced [repro_issue_208.lua:verdict]. -- `repro_issue_218.sh [--no-render-markdown]` (#218) checks for a Neovim crash when a new-file markdown diff is accepted in a new tab with render-markdown.nvim. `NVIM_BIN` selects the binary. Exit 0 means the expected outcome happened [repro_issue_218.sh:WANT_RM, NVIM_BIN]. +- `repro_issue_218.sh [--no-render-markdown]` (#218) checks for a Neovim crash when a new-file markdown diff is accepted in a new tab with render-markdown.nvim. `NVIM_BIN` selects the binary. Exit 0 means the expected outcome happened. Exit 1 also covers a missing `agent-tty` or `nvim` and a failed `agent-tty` session. It uses `jq` but does not check for it [repro_issue_218.sh:WANT_RM, NVIM_BIN, SID]. - `repro_issue_231.lua` (#231) checks that a terminal-only layout does not fail with "No suitable editor window found". Exit 0 means fixed, 1 means reproduced [repro_issue_231.lua:verdict]. -- `repro_issue_232.lua` (#232) checks for a `BufEnter` auto-insert autocmd on the terminal buffer. Env: `CLAUDECODE_PROVIDER=snacks|native`, `CLAUDECODE_AUTO_INSERT=true|false`. Exit 1 means reproduced, 0 means not reproduced or skipped, and 2 means a setup error [repro_issue_232.lua:reproduced]. -- `repro_issue_238.lua` (#238) checks that `:q` on the proposed window rejects the diff. CI runs it with `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, which also works on Neovim 0.8. Exit 0 means fixed, 1 means reproduced, and 2 means the script raised an error before its verdict [repro_issue_238.lua:verdict]. -- `repro_issue_238.sh --file PATH [--lock-dir DIR] [--out FILE] [--hold SEC] [--tab NAME]` (#238) is an external MCP client. It opens one `openDiff` and holds the socket while you run `:q` in Neovim. It prints a `RESULT:` line, but its exit code does not reflect the result [repro_issue_238.sh:RESULT]. +- `repro_issue_232.lua` (#232) checks for a `BufEnter` auto-insert autocmd on the terminal buffer. Env: `CLAUDECODE_PROVIDER=snacks|native`, `CLAUDECODE_AUTO_INSERT=true|false`. Exit 1 means reproduced, 0 means not reproduced or skipped, and 2 means a setup error [repro_issue_232.lua:reproduced]. `CLAUDECODE_PROVIDER` defaults to `snacks`. The script looks for snacks.nvim only at `~/.local/share/nvim/lazy/snacks.nvim` and `~/.local/share/*/lazy/snacks.nvim`, and prints `SKIP` (exit 0) when it cannot load it [repro_issue_232.lua:provider, candidates]. +- `repro_issue_238.lua` (#238) checks that `:q` on the proposed window rejects the diff. CI runs it with `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, which also works on Neovim 0.8. Exit 0 means fixed, 1 means reproduced, and 2 means the script raised an error before its verdict [repro_issue_238.lua:verdict]. It runs `open_in_new_tab=false`, `open_in_new_tab=true`, a split proposed window, and a new file. It finds the repo root from its own path, which `luafile` passes as a relative path, so it must run from the repo root [repro_issue_238.lua:run_scenario, run_multiwindow_scenario, run_newfile_scenario, repo_root]. +- `repro_issue_238.sh --file PATH [--lock-dir DIR] [--out FILE] [--hold SEC] [--tab NAME]` (#238) is an external MCP client. It opens one `openDiff` and holds the socket while you run `:q` in Neovim. It prints a `RESULT:` line, but its exit code does not reflect the result [repro_issue_238.sh:RESULT]. `-h` prints the usage header. The lock directory is `CLAUDE_LOCKFILE_DIR`, else `$CLAUDE_CONFIG_DIR/ide`, else `$HOME/.claude/ide`, and `WEBSOCAT` overrides the binary. Exit 2 means a bad or missing argument, and exit 1 means a missing tool, lock file, or token. The proposed contents replace the text `line three` in `--file`, so a file without that text gets a diff with no change [repro_issue_238.sh:LOCK_DIR, WEBSOCAT, contents]. - `repro_issue_246.lua` (#246) checks that single-line and fast visual selections reach Claude. Exit 0 means all checks pass, 1 means a check failed [repro_issue_246.lua:check]. -- `repro_issue_248.sh [-n N] [--cleanup]` (#248) opens N diffs and disconnects without `close_tab`. It prints no verdict: run `:DiffState` in Neovim and read the counts [repro_issue_248.sh:NUM_DIFFS, CLEANUP]. +- `repro_issue_248.sh [-n|--num N] [--cleanup]` (#248) opens N diffs and disconnects without `close_tab`. It prints no verdict: run `:DiffState` in Neovim and read the counts [repro_issue_248.sh:NUM_DIFFS, CLEANUP]. `:DiffState` comes from the `fixtures/remote-diff` config. N is a cap: the script diffs the first N sorted `*.txt`, `*.lua`, and `*.md` files at the top level of `workspaceFolders[0]`. `-h` or `--help` prints the usage header, and an unknown argument exits 2. It exits 1 for a missing tool, a missing lock file, token, or workspace, or no file to diff. It exits 0 otherwise, because it ignores a `websocat` failure. It uses `mapfile`, so it needs bash 4 or later [repro_issue_248.sh:LOCK_DIR, FILES]. - `repro_issue_262.lua` (#262) checks that a setup error after `:tabnew` does not strand a tab. Exit 0 means fixed, 1 means reproduced [repro_issue_262.lua:verdict]. -- `repro_issue_277.sh` (#277) runs three phases. Phases 1 and 3 check whether `closeAllDiffTabs` also closes diffview.nvim and user vimdiff windows. Phase 2 checks whether `openFile` runs `:edit` into a foreign diff window. `ISSUE277_ARTIFACTS` sets the artifact directory. The exit code covers all three phases: exit 1 means a phase was not reproduced, 2 means a setup failure, and 3 means a missing tool [repro_issue_277.sh:FAIL, WEBSOCAT]. -- `repro_issue_283.lua` (#283) checks the port probe. `REPRO283_MODE` selects `mechanism` (default), `port`, or `serve`. In mechanism mode, exit 1 means reproduced [repro_issue_283.lua:mode]. -- `repro_issue_283.sh` (#283) runs `repro_issue_283.lua` in all three modes. `NVIM` overrides the binary. Exit 1 means all three parts reproduced. Exit 0 means "not fully reproduced", not "pass" [repro_issue_283.sh:VERDICT]. +- `repro_issue_277.sh` (#277) runs three phases. Phases 1 and 3 check whether `closeAllDiffTabs` also closes diffview.nvim and user vimdiff windows. Phase 2 checks whether `openFile` runs `:edit` into a foreign diff window. `ISSUE277_ARTIFACTS` sets the artifact directory. The exit code covers all three phases: exit 0 means all three phases reproduced, 1 means a phase was not reproduced, 2 means a setup failure, and 3 means a missing tool [repro_issue_277.sh:PASS, FAIL, WEBSOCAT]. When `websocat --version` fails, the script searches `${MISE_DATA_DIR:-$HOME/.local/share/mise}/installs/websocat` for a working `websocat` binary. It exits 3 when that search finds no working binary [repro_issue_277.sh:WEBSOCAT]. +- `repro_issue_283.lua` (#283) checks the port probe. `REPRO283_MODE` selects `mechanism` (the default, also used for an unknown value), `port`, or `serve`. Serve mode reads `REPRO283_LABEL` and `REPRO283_WAIT_MS` (default 6000) [repro_issue_283.lua:mode, label, wait_ms]. Mechanism mode rebuilds the probe with raw `vim.loop` handles and calls no plugin code. Its exit 1 means libuv accepted a bind on a busy port and failed later at `listen`. That result does not change when the plugin is fixed [repro_issue_283.lua:reproduced]. +- `repro_issue_283.sh` (#283) runs `repro_issue_283.lua` in all three modes. `NVIM` overrides the binary, and the script exits 2 when it cannot find it. Exit 1 means all three parts reproduced. Exit 0 means "not fully reproduced", not "pass" [repro_issue_283.sh:VERDICT]. Part 3 looks for the text `Failed to listen on port .*EADDRINUSE`. The current `create_server` moves on to the next port and never returns that text, so on current code the script exits 0 [repro_issue_283.sh:E2E] [lua/claudecode/server/tcp.lua:create_server]. - `repro_issue_285.lua` (#285) checks that a path with a `$post` segment is accepted by `:ClaudeCodeAdd` and the `openFile` handler, and that `:ClaudeCodeAdd %` still works. Exit 0 means fixed, 1 means reproduced or a regression [repro_issue_285.lua:verdict]. -- `repro_issue_289.lua` (#289) checks that `ClaudeCodeSend` does not treat a Lua buffer as a file-tree buffer because its path contains `neo-tree` or `NvimTree`. Exit 1 means reproduced [repro_issue_289.lua:affected_misrouted]. +- `repro_issue_289.lua` (#289) checks that `ClaudeCodeSend` does not treat a Lua buffer as a file-tree buffer because its path contains `neo-tree`. It tests a `_neo-tree_.lua` path against a control path. It does not test an `NvimTree` path. Exit 1 means reproduced [repro_issue_289.lua:AFFECTED, affected_misrouted]. ### Release tooling -- `run-release-please.sh [args]` changes to the repo root and runs `node release-please-runner.js`. It uses `scripts/node_modules/release-please` when present, else the `release-please` on `PATH` through `NODE_PATH` [run-release-please.sh:release_please_bin]. -- `node release-please-runner.js [--dry-run]` registers Communique-generated changelog notes and the `simple` release type, then creates releases and release PRs. With `--dry-run` it prints JSON with `releases` and `pullRequests` (including `changelogPreview`) and writes nothing to GitHub [release-please-runner.js:main]. +- `run-release-please.sh [args]` changes to the repo root and runs `node release-please-runner.js`. It uses `scripts/node_modules/release-please` when present, else the `release-please` on `PATH` through `NODE_PATH`. When it finds neither, it exits 1 with no message [run-release-please.sh:release_please_bin]. +- `node release-please-runner.js [--dry-run]` registers Communique-generated changelog notes and the `simple` release type, then creates releases and release PRs. Without `--dry-run` it prints `release-please: releases=[...] prs=[...]`. With `--dry-run` it prints JSON with `releases` and `pullRequests` (including `changelogPreview`) and writes nothing to GitHub. `--dry-run` still needs `GITHUB_TOKEN` and still reads from the GitHub API. Any error sets exit code 1 [release-please-runner.js:main]. +- `buildCommuniqueArgs` builds `communique generate HEAD [PREVIOUS_TAG] --concise --repo OWNER/REPO --output /notes.md [--model COMMUNIQUE_MODEL]` [release-please-runner.js:buildCommuniqueArgs]. - Environment for `release-please-runner.js`: `GITHUB_TOKEN` and `GITHUB_REPOSITORY` (required), `RELEASE_PLEASE_TARGET_BRANCH`, `GITHUB_OUTPUT`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `COMMUNIQUE_MODEL`, and `PRETTIER_BIN` [release-please-runner.js:requireEnv, assertLlmCredentials, resolvePrettierBin]. - `release-please-runner.js` exports `CommuniqueSimpleStrategy`, `UnreleasedAwareChangelog`, `assertLlmCredentials`, `buildCommuniqueArgs`, `createCommuniqueChangelogNotes`, `findLineOutsideFences`, `formatChangelogMarkdown`, `formatChangelogSection`, `formatPullRequestOutputs`, `formatReleaseOutputs`, `normalizeCommuniqueBody`, `resolvePrettierBin`, `releasePleaseNotesAreEmpty`, and `todayIsoDate` [release-please-runner.js:module.exports]. - GitHub Actions outputs: `releases_created`, `release_tags`, `prs_created`, `pr_branches`, and `pr_metadata` [release-please-runner.js:formatReleaseOutputs, formatPullRequestOutputs]. -- `package.json` is the private package `claudecode.nvim-release-tools`. It pins `prettier` 3.8.4 and `release-please` 17.9.0 and defines the `test:release-please-runner` script [package.json:scripts]. +- `package.json` is the private package `claudecode.nvim-release-tools`. It pins `prettier` 3.8.4 and `release-please` 17.9.0 and defines the `test:release-please-runner` script [package.json:scripts]. `package-lock.json` locks the full dependency tree of `package.json` [package-lock.json:packages]. Both CI workflows install from it with `npm ci --prefix scripts` [.github/workflows/test.yml:npm ci] [.github/workflows/release-please.yaml:npm ci]. +- `test-release-please-runner.js` takes no arguments and prints nothing on success. It needs `git`, `prettier`, the `release-please` package, `communique` on `PATH`, and the repo's `communique.toml`. Its Communique check asserts that one release-notes submission is between 4096 and 8192 tokens and gets through in one request [test-release-please-runner.js:testCommuniqueConfigFitsReleaseNotes]. ## Invariants -- Clients find the server port from the lock file name (`PORT.lock`) in `CLAUDE_LOCKFILE_DIR`. `send_claude_message`, the first `ws_connect` in `claude_interactive.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` send the lock file's `authToken` in the `x-claude-code-ide-authorization` header. `repro_issue_277.sh` sends the same header, but reads the port and the token from the plugin state through nvim RPC [lib_claude.sh:get_claude_auth_token, send_claude_message] [lib_ws_persistent.sh:ws_connect]. `mcp_test.sh`, `test_neovim_websocket.sh`, `research_messages.sh`, `websocat.sh`, `test_opendiff.lua`, and `test_opendiff_simple.sh` send no token. The plugin server rejects their upgrade request when it has an auth token, which `start` always sets [mcp_test.sh:ws_connect] [websocat.sh:websocat]. -- `lib_claude.sh` checks only `websocat` and `jq`. Sourcing it runs `check_required_tools`, which returns 1 when one is missing but does not call `exit`. A caller that sourced it under `set -e` (`mcp_test.sh`, `research_messages.sh`) therefore exits. `send_claude_message` also needs `timeout` [lib_claude.sh:check_required_tools, send_claude_message]. +- Most clients find the server port from the lock file name (`PORT.lock`) in a lock directory. `mcp_test.sh` and `research_messages.sh` skip that lookup when you pass `-p`. `send_claude_message`, the first `ws_connect` in `claude_interactive.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` send the lock file's `authToken` in the `x-claude-code-ide-authorization` header. `repro_issue_277.sh` sends the same header, but reads the port and the token from the plugin state through nvim RPC [lib_claude.sh:get_claude_auth_token, send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:TOKEN] [repro_issue_248.sh:TOKEN]. `mcp_test.sh`, `test_neovim_websocket.sh`, `research_messages.sh`, `websocat.sh`, `test_opendiff.lua`, and `test_opendiff_simple.sh` send no token. The plugin server rejects their upgrade request when it has an auth token, which `start` always sets, so these six clients cannot talk to the current plugin. `mcp_test.sh` exits 1 at its connect step [mcp_test.sh:ws_connect, test_connection] [websocat.sh:websocat] [lua/claudecode/server/handshake.lua:validate_upgrade_request]. +- `lib_claude.sh` checks only `websocat` and `jq`. Sourcing it runs `check_required_tools`, which returns 1 when one is missing but does not call `exit`. A caller that sourced it under `set -e` (`mcp_test.sh`, `research_messages.sh`, `test_neovim_websocket.sh`) therefore exits. `send_claude_message` also needs `timeout` [lib_claude.sh:check_required_tools, send_claude_message]. - `send_claude_message` builds a single-quoted `bash -c` string, so a single quote in the message or token breaks the command [lib_claude.sh:send_claude_message]. - `lib_ws_persistent.sh` assumes single-line compact JSON. `ws_request` needs a non-empty `.id`, and `ws_start_listener` matches the exact text `"method":"NAME"` [lib_ws_persistent.sh:ws_request, ws_start_listener]. - `ws_request` reads only response bytes written after the request starts. On timeout it prints the last received line when any bytes arrived, else a JSON-RPC error with code `-32000`. It still returns 0 [lib_ws_persistent.sh:ws_request]. - `ws_connect` disconnects an existing connection with the same `CONN_ID` first, and returns 1 unless the process is alive after 0.5 s [lib_ws_persistent.sh:ws_connect]. - `claude_interactive.sh` exits 1 when `claude_is_running` fails [claude_interactive.sh:claude_is_running]. `claude_running`, `claude_get_selection`, `claude_open_file`, `claude_list_tools`, and `claude_send` return 1 when it fails. `claude_port`, `claude_ws_url`, `claude_interactive`, and `claude_help` do not call it [claude_shell_helpers.sh:claude_send]. -- `claude_interactive.sh` `handle_reconnect` calls `ws_connect` without the auth token [claude_interactive.sh:handle_reconnect]. -- `run_integration_tests_individually.sh` must run from the repo root. It adds a file to `FAILED_FILES` when it parses failures or errors, or when nvim exits nonzero with a code other than 124 or 143 (timeouts). This includes an nvim crash that prints no `Failed :` line. It exits 1 when `FAILED_FILES` is non-empty or the parsed failure or error totals are non-zero, else 0 [run_integration_tests_individually.sh:FAILED_FILES, TOTAL_FAILED]. -- `test_opendiff.lua`, `test_opendiff_simple.sh`, and `repro_issue_238.sh` take the first lock file found, not the newest [test_opendiff.lua:discover_port] [test_opendiff_simple.sh:LOCK_FILE] [repro_issue_238.sh:LOCK_FILE]. +- `claude_interactive.sh` `handle_reconnect` calls `ws_connect` without the auth token, so the server rejects the reconnect [claude_interactive.sh:handle_reconnect] [lua/claudecode/server/handshake.lua:validate_upgrade_request]. +- `run_integration_tests_individually.sh` must run from the repo root. It adds a file to `FAILED_FILES` when it parses failures or errors, or when nvim exits nonzero with a code other than 124 or 143 (timeouts). This includes an nvim crash that prints no `Failed :` line. A spec that times out without a parsed failure or error counts as passed. The script exits 1 when `FAILED_FILES` is non-empty, else 0 [run_integration_tests_individually.sh:FAILED_FILES, EXIT_CODE]. +- `test_opendiff.lua`, `test_opendiff_simple.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` take the first lock file found, not the newest [test_opendiff.lua:discover_port] [test_opendiff_simple.sh:LOCK_FILE] [repro_issue_238.sh:LOCK_FILE] [repro_issue_248.sh:LOCK_FILE]. +- `repro_issue_238.sh` and `repro_issue_248.sh` read `CLAUDE_LOCKFILE_DIR` from the environment instead of sourcing `lib_claude.sh`. `repro_issue_248.sh` falls back to `$HOME/.claude/ide` and ignores `CLAUDE_CONFIG_DIR` [repro_issue_238.sh:LOCK_DIR] [repro_issue_248.sh:LOCK_DIR]. - `test_opendiff.lua` and `test_opendiff_simple.sh` hardcode `~/.claude/ide` and ignore `CLAUDE_CONFIG_DIR` [test_opendiff.lua:discover_port] [test_opendiff_simple.sh:LOCK_DIR]. - `manual_test_helper.lua` hardcodes an absolute path to `README.md` in one developer's checkout, and returns early when that file is not readable [manual_test_helper.lua:readme_path]. - `repro_issue_70_probe.lua` resolves the lock directory from `CLAUDE_CONFIG_DIR/ide`, else `$HOME/.claude/ide` [repro_issue_70_probe.lua:config_dir]. +- The Lua repros call private plugin functions, for example `diff._setup_blocking_diff`, `diff._cleanup_all_active_diffs`, `selection._cancel_demotion_timer`, and `claudecode._create_commands`. A rename of one of these breaks the repro that calls it [repro_issue_208.lua:_setup_blocking_diff] [repro_issue_246.lua:_cancel_demotion_timer] [repro_issue_285.lua:_create_commands]. +- Some Lua repros exit 0 ("fixed") when the run did not prove a fix. `repro_issue_208.lua` prints a setup error but does not count it as a leak. `repro_issue_231.lua` reports fixed when scenario A fails with any error other than "No suitable editor window found". A failed control scenario only prints a warning in `repro_issue_262.lua` and `repro_issue_285.lua`. In `repro_issue_289.lua` it forces exit 0 ("fixed"), even when the affected buffer was misrouted [repro_issue_208.lua:setup_ok] [repro_issue_231.lua:bug_reproduced] [repro_issue_262.lua:control] [repro_issue_285.lua:WARNING, regressed] [repro_issue_289.lua:control_ok]. - `release-please-runner.js` requires `GITHUB_TOKEN` and `GITHUB_REPOSITORY` in `owner/repo` form [release-please-runner.js:requireEnv, main]. - `assertLlmCredentials` throws when both `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are empty, or when `ANTHROPIC_API_KEY` is empty and `COMMUNIQUE_MODEL` is unset [release-please-runner.js:assertLlmCredentials]. - When default release-please notes have one line or fewer, the runner returns them without calling Communique [release-please-runner.js:releasePleaseNotesAreEmpty]. -- `UnreleasedAwareChangelog` keeps the top `Unreleased` section and inserts new entries below it. Headings inside code fences are ignored [release-please-runner.js:UnreleasedAwareChangelog, findLineOutsideFences]. +- `UnreleasedAwareChangelog` keeps the first `## [Unreleased]` or `### [Unreleased]` heading and inserts the new entry below it. It drops the draft lines under that heading, up to the next `#` or `##` heading. When the heading is missing, it adds `## [Unreleased]` below the `# ` title. Headings inside code fences are ignored [release-please-runner.js:UnreleasedAwareChangelog, findLineOutsideFences, NEXT_SECTION_PATTERN]. +- release-please reads its config and manifest from the remote target branch (`RELEASE_PLEASE_TARGET_BRANCH`, else the default branch), not from the local checkout. Local edits to `release-please-config.json` take effect only after you push them [release-please-runner.js:main]. +- The runner imports internal modules from `release-please/build/src/`, so it depends on the file layout of the pinned `release-please` version [release-please-runner.js:Simple, Changelog]. - `formatChangelogSection` asserts a non-empty version and a `YYYY-MM-DD` date, and writes `## [VERSION] - DATE` without a `v` prefix [release-please-runner.js:formatChangelogSection]. - `resolvePrettierBin` uses `PRETTIER_BIN`, then `scripts/node_modules/.bin/prettier`, then `prettier` on `PATH` [release-please-runner.js:resolvePrettierBin]. - `main` creates releases before release PRs [release-please-runner.js:main]. ## Side Effects -- Sourcing `lib_ws_persistent.sh` creates `${TMPDIR:-/tmp}/ws_logs`. `ws_connect` writes `pid`, `log.txt`, `request.json`, and `response.json` under `ws_logs/CONN_ID/` and starts a background `tail -f | websocat | tee` pipeline [lib_ws_persistent.sh:WS_LOG_DIR, ws_connect]. -- `ws_disconnect` sends SIGTERM to the saved PID only. It sends SIGKILL only when the SIGTERM send fails, not when the process ignores SIGTERM. `ws_setup_trap` runs `ws_cleanup_all` on EXIT, INT, and TERM, and sourcing the library installs that trap [lib_ws_persistent.sh:ws_disconnect, ws_setup_trap]. -- `ws_start_listener` starts a background polling loop (0.5 s) and writes `listener.pid` [lib_ws_persistent.sh:ws_start_listener]. -- Log directories are created relative to the current directory: `mcp_test_logs` [mcp_test.sh:LOG_DIR], `mcp_interactive_logs` [claude_interactive.sh:CLAUDE_LOG_DIR], `test_neovim_ws_logs` [test_neovim_websocket.sh:LOG_DIR], and `claude_messages.jsonl` / `claude_messages_pretty.txt` [research_messages.sh:LOG_FILE]. Each run truncates its log files, except `claude_interactive.sh`, which only creates its log directory. +- Sourcing `lib_ws_persistent.sh` creates `${TMPDIR:-/tmp}/ws_logs`. `ws_connect` writes `pid`, `log.txt`, `request.json`, and `response.json` under `ws_logs/CONN_ID/` and starts a background `tail -f | websocat | tee` pipeline [lib_ws_persistent.sh:WS_LOG_DIR, ws_connect]. `ws_request` appends every request to `ws_logs/CONN_ID/debug.log`, which nothing truncates. No function deletes `ws_logs/CONN_ID/` [lib_ws_persistent.sh:ws_request]. +- `ws_disconnect` sends SIGTERM to the saved PID only. That PID is `$!` of the pipeline, which is the `tee` process, not `tail -f` or `websocat`. It sends SIGKILL only when the SIGTERM send fails, not when the process ignores SIGTERM. `ws_setup_trap` runs `ws_cleanup_all` on EXIT, INT, and TERM, and sourcing the library installs that trap. The trap does not call `exit`, so after Ctrl+C the caller closes its connections and keeps running [lib_ws_persistent.sh:ws_connect, ws_disconnect, ws_setup_trap]. +- `ws_start_listener` starts a background polling loop (0.5 s) and writes `listener.pid` [lib_ws_persistent.sh:ws_start_listener]. The loop has no exit condition. Only `ws_stop_listener` kills it, and it reads `listener.pid`, which each `ws_start_listener` call overwrites. `ws_disconnect`, `ws_cleanup_all`, and the trap leave listeners running. `mcp_test.sh` starts up to five listeners and stops at most the last one, so the others keep polling after the script exits [lib_ws_persistent.sh:ws_stop_listener, ws_cleanup_all] [mcp_test.sh:ws_start_listener, ws_stop_listener]. +- CAUTION: Do not run the token-sending clients on a shared machine, and do not share the output of `claude_interactive.sh` option 6. The scripts pass the auth token on the `websocat` command line (`--header`), where other users can read it with `ps`. Option 6 prints the whole lock file, including `authToken` [lib_claude.sh:send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:WEBSOCAT] [repro_issue_248.sh:TOKEN] [repro_issue_277.sh:TOKEN] [claude_interactive.sh:handle_connection_info]. +- Log directories are created relative to the current directory: `mcp_test_logs` [mcp_test.sh:LOG_DIR], `mcp_interactive_logs` [claude_interactive.sh:CLAUDE_LOG_DIR], `test_neovim_ws_logs` [test_neovim_websocket.sh:LOG_DIR], and `claude_messages.jsonl` / `claude_messages_pretty.txt` [research_messages.sh:LOG_FILE]. Each run truncates its log files, except `claude_interactive.sh`, which only creates its log directory. `claude_interactive.sh` keeps its connection files in `${TMPDIR:-/tmp}/ws_logs/claude_interactive/` [claude_interactive.sh:CONN_ID]. - Sourcing `claude_shell_helpers.sh` sets `CLAUDE_LOG_DIR=$HOME/.claude/logs` and creates that directory [claude_shell_helpers.sh:CLAUDE_LOG_DIR]. -- The WebSocket clients connect to the plugin server on `127.0.0.1` or `localhost` at the lock-file port [mcp_test.sh:test_connection] [lib_claude.sh:send_claude_message]. +- The WebSocket clients connect to the plugin server on `127.0.0.1` or `localhost` at the lock-file port [mcp_test.sh:test_connection] [lib_claude.sh:get_claude_ws_url, send_claude_message]. - `open_file` and `claude_open_file` make the connected Neovim open a file [lib_claude.sh:open_file] [claude_shell_helpers.sh:claude_open_file]. - `test_neovim_websocket.sh` writes `/tmp/sample_file.lua` and `/tmp/test_claudecode_init.lua`, starts `nvim` in the background, and on exit kills it and deletes those fixed paths and `/tmp/test_claudecode_buffer.txt` [test_neovim_websocket.sh:SAMPLE_FILE, NVIM_BUFFER_FILE, cleanup]. -- `run_integration_tests_individually.sh` creates one `mktemp` file per test and deletes it [run_integration_tests_individually.sh:temp_output]. +- `run_integration_tests_individually.sh` creates one `mktemp` file per test and deletes it [run_integration_tests_individually.sh:temp_output]. Each nvim it starts loads `tests/minimal_init.lua`, which runs `git clone` of plenary.nvim into `stdpath("data")` when plenary is missing [run_integration_tests_individually.sh:run_test_file]. - `test_opendiff.lua` can loop forever when no response arrives after its 30 s receive timeout [test_opendiff.lua:sock:receive]. - The Lua repros create temp files or directories, stub plugin functions (for example `logger`, `vim.notify`, `diff._create_diff_view_from_window`), and rearrange tabs and windows inside their own headless Neovim [repro_issue_285.lua:logger] [repro_issue_289.lua:vim.notify] [repro_issue_262.lua:_create_diff_view_from_window]. - `repro_issue_231.lua` deletes `/__issue_231_repro__.md` [repro_issue_231.lua:try_open_diff]. -- `repro_issue_70.sh` and `repro_issue_277.sh` create `agent-tty` sessions and temp directories, and remove both on exit. They create the temp directory before they install the `EXIT` trap, so an exit 3 for a missing tool leaves that directory behind. `repro_issue_70.sh` launches the real `claude` CLI and sets proxy variables to `http://127.0.0.1:1` in its scenarios [repro_issue_70.sh:run_scenario] [repro_issue_277.sh:cleanup]. +- `repro_issue_70.sh` and `repro_issue_277.sh` create `agent-tty` sessions and temp directories, and remove both on exit. They create the temp directory before they install the `EXIT` trap, so an exit 3 for a missing tool leaves that directory behind. `repro_issue_70.sh` launches the real `claude` CLI and sets proxy variables to `http://127.0.0.1:1` in its scenarios [repro_issue_70.sh:run_scenario] [repro_issue_277.sh:cleanup]. Its cleanup runs `pkill -f "repro_issue_70_probe.lua $REPO_ROOT"`, which also kills the probe of another run in the same checkout [repro_issue_70.sh:cleanup]. +- `repro_issue_70_probe.lua` starts the real server with `auto_start = true`, so the plugin writes a real `PORT.lock` into the user's lock directory. Other Claude CLIs can see that lock file until `claudecode.stop()` removes it at the end [repro_issue_70_probe.lua:claudecode.setup, claudecode.stop]. - `repro_issue_277.sh` clones diffview.nvim into `${XDG_DATA_HOME:-$HOME/.local/share}/issue-277/diffview.nvim` when it is absent. It copies artifacts to `${TMPDIR:-/tmp}/issue277-artifacts-latest` when `ISSUE277_ARTIFACTS` is unset [repro_issue_277.sh:DIFFVIEW_DIR]. -- `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. It does not remove its temp directory [repro_issue_218.sh:cleanup]. +- `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. The fixture clones lazy.nvim and the plugins into the `stdpath("data")` of `NVIM_APPNAME` `issue-218` or `issue-218norm`, outside the temp directory. The script does not remove its temp directory [repro_issue_218.sh:APPNAME, cleanup]. +- When `--out` is omitted, `repro_issue_238.sh` writes the frames to a `mktemp` file `repro238.frames.*` and does not delete it. Its `EXIT` trap removes only the request file [repro_issue_238.sh:OUT, REQ]. - Without `--cleanup`, `repro_issue_248.sh` disconnects without `close_tab`. The server then rejects and closes that client's pending diffs, so `:DiffState` shows `active_diffs=0`. With `--cleanup` it calls `closeAllDiffTabs` first [repro_issue_248.sh:CLEANUP]. - `repro_issue_283.lua` binds TCP sockets on `127.0.0.1`. `repro_issue_283.sh` starts a background Neovim and kills it on exit [repro_issue_283.lua:listener:bind] [repro_issue_283.sh:cleanup]. - `release-please-runner.js` without `--dry-run` creates GitHub tags, releases, and pull requests through the GitHub API [release-please-runner.js:main]. -- `release-please-runner.js` spawns `communique generate` with the full environment (API keys included) in a temp directory `communique-notes-*` that it removes afterward. It runs `prettier` on each changelog update and appends outputs to `GITHUB_OUTPUT`, or prints them when that variable is unset [release-please-runner.js:createCommuniqueChangelogNotes, formatChangelogMarkdown, writeGithubOutputs]. +- `release-please-runner.js` spawns `communique generate` with the full environment (API keys included) in the current directory, which is the repo root when `run-release-please.sh` starts it. Communique writes `notes.md` to a temp directory `communique-notes-*`, which the runner removes afterward. It runs `prettier` on each changelog update and appends outputs to `GITHUB_OUTPUT`, or prints them when that variable is unset [release-please-runner.js:createCommuniqueChangelogNotes, formatChangelogMarkdown, writeGithubOutputs]. - `test-release-please-runner.js` starts an HTTP server on a random `127.0.0.1` port and runs `git` and `communique` in a temp directory `communique-config-*` that it removes afterward. It passes `communique` only a placeholder API key [test-release-please-runner.js:testCommuniqueConfigFitsReleaseNotes]. ## Verification From 67bda79169d474dce8483a22bb057af494f7b4c5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:42:44 +0000 Subject: [PATCH 02/13] docs: correct README claims that runtime probes disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran each claim that the drift pass could not check from source in real Neovim 0.8 to 0.12, against the live server, Snacks at the pinned commit, and the scripts, then corrected the claims the runs did not support. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- README.md | 9 +++++++-- lua/claudecode/README.md | 11 ++++++----- lua/claudecode/server/README.md | 6 +++--- lua/claudecode/terminal/README.md | 10 +++++----- lua/claudecode/tools/README.md | 7 ++++--- scripts/README.md | 20 ++++++++++---------- 6 files changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 470865b60..b955572de 100644 --- a/README.md +++ b/README.md @@ -1064,9 +1064,14 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ - Run: `mise run all` (format, lint, test) - Run: `mise run test` (Busted suite with coverage, in a random order whose seed it prints; `BUSTED_SEED= mise run test` repeats that order, and a test registered inside a `pairs()` loop fails the run) or one file with `busted -v tests/unit/_spec.lua` -- Run: `mise run check` (Lua syntax and `luacheck` on `lua/` and `tests/`) +- `tests/lockfile_test.lua` writes lock files to the directory that `lockfile.lua` resolves, which is + `$CLAUDE_CONFIG_DIR/ide` when `CLAUDE_CONFIG_DIR` is set [lua/claudecode/lockfile.lua:get_lock_dir]. When that + directory does not exist, five of its tests fail. When it exists, the tests pass and leave five fake lock files + (`12345.lock` to `12349.lock`) in it. Unset `CLAUDE_CONFIG_DIR` before `mise run test`. +- Run: `mise run check` (a Lua syntax check on `lua/`, then `luacheck` on `lua/` and `tests/`) - Run: `npm --prefix scripts run test:release-please-runner` (release runner and the `communique.toml` output budget, - after `npm --prefix scripts ci`, with `communique` on `PATH`) + after `npm --prefix scripts ci`, with a real `communique` binary on `PATH`, for example under `mise exec --`, not a + mise shim. See the [scripts README](./scripts/README.md).) ## Contributing diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 44b5b3d6c..679bbf41c 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -144,7 +144,7 @@ Module map (direct files): - `has_selection_changed` compares only `filePath`, `text`, and the start and end positions [selection.lua:M.has_selection_changed] - 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] - A visual selection is flushed on visual exit. The flush is skipped if the buffer changed during visual mode [selection.lua:M.flush_visual_selection] -- `selection_changed` positions are 0-based lines and 0-based UTF-8 byte offsets into the buffer line, not VS Code's UTF-16 code units. `end` is exclusive. A position inside a multibyte code point, such as a stale mark after an edit and `gv`, moves back to the code point's first byte [selection.lua:code_point_start]. A charwise or blockwise selection ends after the character that starts at its end position. That character is what `y` takes as one (`vim.fn.byteidx`): it includes its composing characters, and on Neovim 0.11+ the rest of its grapheme cluster. On Neovim 0.11+ a control, such as a TAB, U+0085 or U+200B, is a character of its own, without the composing characters after it. So is an invalid byte on every version, although before 0.11 the cursor can skip the composing characters after it. With `'selection'` set to `exclusive`, a selection of more than one position ends after the character before that position instead. Before 0.11, `y` can leave out composing characters that follow an invalid byte there, and the selection keeps them [selection.lua:selection_end_byte] +- `selection_changed` positions are 0-based lines and 0-based UTF-8 byte offsets into the buffer line, not VS Code's UTF-16 code units. `end` is exclusive. A position inside a multibyte code point, such as a stale mark after an edit and `gv`, moves back to the code point's first byte [selection.lua:code_point_start]. A charwise or blockwise selection ends after the character that starts at its end position. That character is what `y` takes as one (`vim.fn.byteidx`): it includes its composing characters, and on Neovim 0.11+ the rest of its grapheme cluster. On Neovim 0.11+ a control, such as a TAB, U+0085 or U+200B, is a character of its own, without the composing characters after it. So is an invalid byte on every version, although before 0.11 the cursor can skip the composing characters after it. With `'selection'` set to `exclusive`, a selection of more than one position ends after the character before that position instead. Before 0.11, with `'selection'` set to `exclusive`, `y` can leave out composing characters that follow an invalid byte before that position, and the selection keeps them [selection.lua:selection_end_byte] - A charwise selection that includes a line break (`v$`) ends at the start of the next line, and its text ends with that line break, as Neovim yanks it. On the buffer's last line, for a blockwise selection, and with `'virtualedit'` containing `all` (where the end is a virtual space), the end stays at the end of the line. On an empty line, and for a block whose last row is empty, that end is `character` 1, because Claude Code counts an end at `character` 0 as the line before. With `all`, the text has none of the virtual spaces that Neovim's `y` adds. A blockwise selection sends the contiguous span between its first and last positions, not the rectangle [selection.lua:end_past_line] - A linewise selection ends on its last line at that line's byte length. When that line is empty and another line follows, it ends at the start of the next line, and its text ends with the empty line's line break, because Claude Code counts an end at `character` 0 as the line before. An empty last line of the buffer ends at `character` 1 [selection.lua:extract_linewise_text] - A mention sent without a range from a tracked selection that ends at `character` 0 of a line ends on the line before [selection.lua:M.send_at_mention_for_visual_selection] @@ -165,7 +165,7 @@ Module map (direct files): - Every `active_diffs` entry gets an `open_order` from `_register_diff_state`. The bulk closes sort by it [diff.lua:M._register_diff_state] [diff.lua:close_active_diffs] - The native proposal buffer is an unlisted `acwrite` scratch buffer named ` (proposed)`, or ` (NEW FILE - proposed)`. A new file's original side is a read-only `nofile` placeholder named ` (NEW FILE)`. The proposal buffer carries `b:claudecode_diff_tab_name`, which `:ClaudeCodeDiffAccept` and `:ClaudeCodeDiffDeny` read [diff.lua:M._setup_blocking_diff] [diff.lua:load_original_buffer] [diff.lua:M.accept_current_diff] - A diff that resolves before `open_diff_blocking` yields, for example from a `ClaudeCodeDiffOpened` handler that runs `:ClaudeCodeDiffDeny`, is returned at once as the openDiff reply instead of a deferred one [diff.lua:M.open_diff_blocking] -- A diff never splits the preview window while the Claude terminal is a tiled window in the same tab. It splits the terminal's window instead, because a split of the preview window keeps its `'previewheight'` lines. A `vertical` or `unified` diff then opens right of the terminal, so with the preview window left of the terminal, the terminal sits between the preview window and the diff. A `horizontal` diff opens below the terminal. With a floating or hidden Claude terminal, a diff in a tab with only the preview window splits the preview window. The legacy `open_diff` never opens the old file in the preview window either [diff.lua:leave_preview_window] [diff_inline.lua:create_fallback_editor_window] [diff.lua:M._open_native_diff] +- A diff never splits the preview window while the Claude terminal is a tiled window in the same tab. It splits the terminal's window instead, because the preview window keeps its `'previewheight'` lines when it is split, and a vertical split gives the new window that height too. A `vertical` or `unified` diff then opens right of the terminal, so with the preview window left of the terminal, the terminal sits between the preview window and the diff. A `horizontal` diff opens below the terminal. With a floating or hidden Claude terminal, a diff in a tab with only the preview window splits the preview window. The legacy `open_diff` never opens the old file in the preview window either [diff.lua:leave_preview_window] [diff_inline.lua:create_fallback_editor_window] [diff.lua:M._open_native_diff] - A `unified` diff keeps the Claude terminal's width, with two exceptions: the terminal in a new tab, and a terminal whose window it splits (the old width includes the columns the diff took). It sizes those as the other layouts do: `terminal.diff_split_width_percentage` of the columns, else `split_width_percentage`, and not at all with `diff_opts.auto_resize_terminal = false` [diff_inline.lua:M.setup_inline_diff] [diff.lua:resize_terminal_for_diff] - A width percentage counts only when it is a number between 0 and 1. Otherwise the diff uses 0.30. A floating terminal is never resized [diff.lua:resolve_split_width_percentage] [diff.lua:resize_terminal_for_diff] - Closing a diff gives the windows of the tab it split back the sizes they had before the diff, because the diff's `wincmd =` and the closing of its windows resize them. When two diffs split one tab, the sizes are those from before the first, and the last diff to close gives them back. Nothing is resized when the tab's window layout, or the space its windows fill (screen size, tab line), changed during the diff [diff.lua:restore_window_sizes] @@ -200,10 +200,11 @@ Module map (direct files): - The plugin runs `git rev-parse --show-toplevel` for the git root [cwd.lua:M.git_root], and `health.check` runs ` --version` [health.lua:check_cli] - `logger` defers each log line through `vim.schedule`. ERROR and WARN go to `vim.notify`. Other levels go to `nvim_echo` [logger.lua:log]. The level is INFO until `setup` runs. `setup` warns at once, not deferred, when `log_level` is missing or invalid, and uses INFO [logger.lua:M.setup] - `one_line` accepts only `error`, `warn`, and `info`, and asserts on any other level [logger.lua:M.one_line] -- Below log_level debug, `one_line` replaces each run of whitespace with one space. A line wider than `v:echospace` cells drops its component tag and puts `ERROR:` or `WARN:` in place of its level tag (an info line gets none), so the level shows without colors. It drops the `[ClaudeCode]` name too when that would take more than half the width. Then it is cut to the width, ending in `...` when `v:echospace` is at least 3. Below debug, only that line reaches `:messages`: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER", so the whole line shows only at log_level debug [logger.lua:M.one_line] -- `M.start` logs the reason a server did not start through `one_line`, for example `No free port in 10000-65535 (EADDRINUSE)`, because `auto_start` runs it while Neovim starts. That line fits at 80 columns. `M.start` returns `Server not started: `. When the reason contains `auth`, the message ends with `(authentication related)` [init.lua:M.start] +- Below log_level debug, `one_line` replaces each run of whitespace with one space. A line wider than `v:echospace` cells drops its component tag and puts `ERROR:` or `WARN:` in place of its level tag (an info line gets none), so the level shows without colors. It drops the `[ClaudeCode]` name too when that would take more than half the width. Then it is cut to the width, ending in `...` when `v:echospace` is at least 3. Below debug, only that line reaches `:messages`: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER" (on Neovim 0.11 and later, while `'messagesopt'` has its default `hit-enter`), so the whole line shows only at log_level debug [logger.lua:M.one_line] +- `M.start` logs the reason a server did not start through `one_line`, for example `No free port in 10000-65535 (EADDRINUSE)`, because `auto_start` runs it while Neovim starts. That line, `[ClaudeCode] [init] [ERROR] No free port in 10000-65535 (EADDRINUSE)`, is 68 cells wide. At 80 columns it fits with Neovim's default `'laststatus'` of 2, and with `'ruler'` and `'showcmd'` off. With `'laststatus'` set to 0 or 1 and `'ruler'` and `'showcmd'` on, `v:echospace` is 51, and `one_line` shows `[ClaudeCode] ERROR: No free port in 10000-65535 ...` [logger.lua:fit_command_line]. `M.start` returns `Server not started: `. When the reason contains `auth`, the message ends with `(authentication related)` [init.lua:M.start] ## Verification - Run: `mise run test` -- Targeted: `busted -v tests/unit/init_spec.lua`, `busted -v tests/unit/config_spec.lua`, `busted -v tests/unit/diff_spec.lua`, `busted -v tests/unit/diff_inline_spec.lua`, `busted -v tests/unit/terminal_spec.lua`, `busted -v tests/unit/utils_spec.lua`, `busted -v tests/unit/health_spec.lua`, `busted -v tests/unit/logger_spec.lua`, `busted -v tests/unit/version_spec.lua`, `busted -v tests/unit/at_mention_spec.lua`, `busted -v tests/unit/claudecode_add_command_spec.lua`, `busted -v tests/unit/diff_inline_new_tab_spec.lua`, `busted -v tests/integration/basic_spec.lua`, `busted -v tests/integration/diff_window_choice_spec.lua` +- Targeted: `busted -v tests/unit/init_spec.lua`, `busted -v tests/unit/config_spec.lua`, `busted -v tests/unit/diff_spec.lua`, `busted -v tests/unit/diff_inline_spec.lua`, `busted -v tests/unit/terminal_spec.lua`, `busted -v tests/unit/utils_spec.lua`, `busted -v tests/unit/health_spec.lua`, `busted -v tests/unit/logger_spec.lua`, `busted -v tests/unit/version_spec.lua`, `busted -v tests/unit/at_mention_spec.lua`, `busted -v tests/unit/claudecode_add_command_spec.lua`, `busted -v tests/unit/diff_inline_new_tab_spec.lua`, `busted -v tests/integration/basic_spec.lua` +- Real Neovim: `scripts/run_integration_tests_individually.sh` runs each `tests/integration/*_spec.lua` file in headless Neovim with plenary.nvim and skips `command_args_spec.lua`. It runs `tests/integration/diff_window_choice_spec.lua`, which `busted` only marks as pending diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 7a2031fd8..d43ea4749 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -78,7 +78,7 @@ Layers, top to bottom: - `is_websocket_endpoint` accepts any path. `Sec-WebSocket-Key` is checked for its length only, not for base64. The 101 reply echoes the client's `Sec-WebSocket-Protocol` value verbatim [server/handshake.lua:M.is_websocket_endpoint] [server/handshake.lua:M.validate_upgrade_request] [server/handshake.lua:M.create_handshake_response] - `parse_http_headers` lower-cases header names. A later duplicate header wins. A value keeps its trailing whitespace, and a header with an empty value is dropped, so `Upgrade: websocket ` with a trailing space fails the upgrade check [server/utils.lua:M.parse_http_headers] - No timer closes a client that never finishes the handshake. The ping timer checks only `connected` clients, and `client.buffer` grows until the request's `\r\n\r\n` arrives. Only EOF, a read error, or `stop_server` removes such a client [server/tcp.lua:M.start_ping_timer] [server/client.lua:M.process_data] -- Frames are dispatched only after `handshake_complete` is true. The state becomes `connected` only after a successful handshake. While its 101 reply is written, a client is `upgrading`: the bytes it sends then wait in its buffer for the write callback, so its request is answered once and its first frames are kept. If the client hung up or the server stopped before the write callback, the callback drops those bytes, and the client never becomes `connected` [server/client.lua:M.process_data] +- Frames are dispatched only after `handshake_complete` is true. The state becomes `connected` only after a successful handshake. While its 101 reply is written, a client is `upgrading`: the bytes it sends then wait in its buffer for the write callback, so its request is answered once and its first frames are kept. libuv runs the write callback before the server reads a later EOF. So a client that hung up after its request is still open at the callback, unless the server read its EOF in the same read as the request. The callback then sets `connected` and parses the buffered frames, and the next rule decides whether each request runs. Only when the socket is already closing does the callback drop the buffered bytes and leave the client `upgrading`. When the write fails, for example with `ECONNRESET`, the callback calls `on_error`, which removes the client and closes its socket. When the server stops first, `close_client` clears the buffer and sets `closing`, and the callback returns without a state change [server/client.lua:M.process_data] [server/client.lua:M.close_client] - A whole message runs through `on_message` only if, when the main loop gets to it, the client is still `connected` and its socket is open. A request from a client that hung up or sent its Close frame meanwhile does not run [server/client.lua:M.process_data] - A text message can arrive in fragments (a TEXT frame with FIN clear, then CONTINUATION frames up to one with FIN set), with control frames between them. `process_data` joins the fragments and calls `on_message` once with the whole text. Only the whole message must be well-formed UTF-8, and one that is not closes with 1007. A binary message closes with 1003, and nothing runs it. A CONTINUATION frame with no message to continue, or a TEXT or BINARY frame inside a fragmented message, closes with 1002. A message whose fragments add up to more than `frame.MAX_PAYLOAD_LENGTH` (100 MiB) closes with 1009. An unknown opcode closes with 1002. Each Close frame carries the RFC 6455 name of its status as the reason: `Protocol error` (1002), `Unsupported data` (1003), `Invalid frame payload data` (1007) or `Message too big` (1009) [server/client.lua:add_data_frame] [server/client.lua:M.process_data] - `process_data` parses every frame of one read from an offset into its buffer and keeps the unparsed rest once at the end, so many small frames in one read cost linear time. The reads of a frame longer than one read wait in `client.pending` until the whole frame arrived, and are joined once, so a large frame costs linear time too [server/client.lua:M.process_data] @@ -93,7 +93,7 @@ Layers, top to bottom: - Notification handlers run in `pcall`. Their errors and return values are discarded, and an unknown notification is ignored without a reply [server/init.lua:M._handle_notification] - A `tools/call` without a string or number `name` raises in `tools.handle_invoke`, so `_handle_request` replies -32603 [server/init.lua:M.register_handlers] [server/init.lua:M._handle_request] - A deferred response is keyed by `tostring(coroutine)` in `_G.claude_deferred_responses`. It replies to `result.client` with the original request id. A result with `content` is sent as `result`, one with `error` is sent as `error`, and a result with neither becomes -32603 [server/init.lua:M._setup_deferred_response] -- The ping timer closes a connected client when no pong arrived within `interval * 2`: its Close frame carries 1011 "Keepalive timeout" (RFC 6455 reserves 1006, so no Close frame carries it), `on_disconnect` gets 1006 "Connection timeout", and the timer logs `Closed client_: no reply for s` at info level. A peer that stopped reading does not get that Close frame. Once its receive window is full, every write to it (replies, pings, and the Close frame) waits in libuv's queue, and closing the socket drops the queue. Until the timeout, the queued replies stay in memory. A tick gap above `interval * 1.5` counts as a wake from sleep and resets `last_pong` [server/tcp.lua:M.start_ping_timer] +- The ping timer closes a connected client when no pong arrived within `interval * 2`: its Close frame carries 1011 "Keepalive timeout" (RFC 6455 reserves 1006, so no Close frame carries it), `on_disconnect` gets 1006 "Connection timeout", and the timer logs `Closed client_: no reply for s` at info level. A peer that stopped reading still gets that Close frame while the kernel can hold what the server wrote to it: the peer's receive buffer plus the server's send buffer. The size of these buffers depends on the system's TCP settings. Once both are full, every write to that peer (replies, pings, and the Close frame) waits in libuv's queue. Closing the socket drops the queue, so the peer reads the bytes the kernel held, then EOF, and never sees the Close frame. Until the timeout, the queued replies stay in memory. A peer that never reads but keeps sending PONG frames is never timed out, so its queue keeps growing [server/client.lua:M.send_message]. A tick gap above `interval * 1.5` counts as a wake from sleep and resets `last_pong` [server/tcp.lua:M.start_ping_timer] - `_disconnect_client` is idempotent: `on_disconnect` fires once per client id [server/tcp.lua:M._disconnect_client] - A client's EOF, read error, or protocol error reaches the server only as its `on_disconnect` code 1006 and reason (`EOF`, `Client read error: …`, or `Client error: …`), which `init.lua` logs at debug level. `on_error` carries only the server's own failures (listen, accept, and creating a client handle or the ping timer), which `init.lua` logs at error level [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] - A rejected handshake logs `Rejected client_: ` at warn level, with the reason from the body of the error response. Once the error response is written, `on_error` removes the client from the server and closes its socket [server/client.lua:M.process_data] @@ -116,7 +116,7 @@ Layers, top to bottom: - `client.process_data` writes handshake, PONG, and Close frames to the socket. It runs `on_message` and `on_close` through `vim.schedule`. It calls `on_error` directly after a failed handshake write, and from the write callback of the Close frame for a protocol error. When `close_client` writes no Close frame, it schedules `on_error` instead [server/client.lua:M.process_data] [server/client.lua:M.close_client] - The ping timer writes ping frames and closes timed-out clients [server/tcp.lua:M.start_ping_timer] - `handshake.lua`, `frame.lua`, and `utils.lua` do no I/O [server/handshake.lua:M] -- `mock.lua` opens no sockets and keeps its clients and messages in its own `M.state` tables. Its `mcp.tool.invoke` handler calls the real `tools.handle_invoke`, so a simulated tool call has that tool's real side effects (for example `saveDocument` writes the file) [server/mock.lua:M.register_handlers] [server/mock.lua:M.state] +- `mock.lua` opens no sockets and keeps its clients and messages in its own `M.state` tables. Its `mcp.tool.invoke` handler calls the real `tools.handle_invoke`, which looks the tool up in the global registry. `mock.start` does not fill that registry. So in a fresh Neovim a simulated tool call returns `-32601` "Tool not found" and has no side effects. After `tools.setup` or the real server's `M.start` fills the registry, the call has that tool's real side effects (for example `saveDocument` writes the file) [server/mock.lua:M.register_handlers] [server/mock.lua:M.state] [tools/init.lua:M.handle_invoke] [tools/init.lua:M.setup] ## Verification diff --git a/lua/claudecode/terminal/README.md b/lua/claudecode/terminal/README.md index 539f853aa..6d6f62381 100644 --- a/lua/claudecode/terminal/README.md +++ b/lua/claudecode/terminal/README.md @@ -56,15 +56,15 @@ Optional fields (the dispatcher does not require them): - `native`'s `hide_terminal` closes the window without the last-window check that the exit path uses. The comment on `is_last_window` says `nvim_win_close` raises E444 for the only window of the only tab page [terminal/native.lua:hide_terminal] [terminal/native.lua:is_last_window] - `native` sets `bufhidden=hide`, so the Claude job survives when its window closes [terminal/native.lua:open_terminal]. Hide closes the window without force, and the buffer stays alive [terminal/native.lua:hide_terminal] - `native` sets `filetype=claudecode` on the terminal buffer once, after `termopen` started the job, so a `FileType claudecode` autocmd runs once per terminal with `buftype=terminal`. A failed start sets none, and a hide or show of the same buffer sets none again [terminal/native.lua:open_terminal] -- `native` calls `termopen` through `pcall`. Neovim raises `E475` for a command that is not executable, and returns `0` or `-1` instead when errors are silenced. When an autocmd deletes the new buffer before `termopen` runs, `native` skips `termopen` and treats it as a failure. When a `TermOpen` autocmd wipes the buffer after `termopen` started the job, the wipe also stops the job, and `native` treats it as a failure. On any of these failures `native` sends one ERROR notification, which includes the error text when `termopen` raised. Its callers (`M.open`, `M.simple_toggle`, and `M.focus_toggle`) send none. `native` also closes the new window, restores the original window, deletes the buffer it created, and clears its state. A buffer that an autocmd swapped into the split stays [terminal/native.lua:open_terminal] +- `native` calls `termopen` through `pcall`. Neovim raises `E475` for a command that is not executable. When `:silent!` is in effect, `termopen` returns `-1` instead, also inside `pcall`. A plain `:silent` or `pcall` alone does not silence the error. `termopen` returns `0` only for invalid arguments, such as an empty argv (`E474`), and only under `:silent!`. When an autocmd deletes the new buffer before `termopen` runs, `native` skips `termopen` and treats it as a failure. When a `TermOpen` autocmd wipes the buffer after `termopen` started the job, the wipe also stops the job, and `native` treats it as a failure. On any of these failures `native` sends one ERROR notification, which includes the error text when `termopen` raised. Its callers (`M.open`, `M.simple_toggle`, and `M.focus_toggle`) send none. `native` also closes the new window, restores the original window, deletes the buffer it created, and clears its state. A buffer that an autocmd swapped into the split stays [terminal/native.lua:open_terminal] - `native` shows the exit tip once per load of the module when `show_native_term_exit_tip` is set. The flag that limits it is module-local, so a reload of the module resets it [terminal/native.lua:open_terminal] - `native` marks each terminal buffer it starts with `b:claudecode_native_terminal`, before `termopen` runs. It clears the mark when the job exits [terminal/native.lua:open_terminal]. While it tracks no terminal, `open`, `simple_toggle`, and `focus_toggle` adopt a marked buffer that a window displays and whose job has not ended, and take its job id from the buffer's `'channel'` option. A buffer whose job has not started yet counts as not ended. They never adopt an unmarked terminal, even when its name contains `claude`. The job's `on_exit` belongs to the module copy that started it, for example the one before a reload, so adoption adds a buffer-local `TermClose` handler that runs the same exit handling. The handler sits in the `ClaudeCodeNativeAdopted` augroup, and each adoption replaces the buffer's earlier one, so a buffer keeps one handler however many reloads adopt it [terminal/native.lua:adopt_existing_terminal]. `close` and `get_active_bufnr` never adopt, so after a reload `close` does nothing and `get_active_bufnr` returns `nil` until an `open` or a toggle adopts the terminal [terminal/native.lua:M.close] [terminal/native.lua:M.get_active_bufnr] - `snacks.is_available` requires snacks.nvim to load and `Snacks.terminal` to exist. It tries to load snacks.nvim once, when the module loads. It checks `Snacks.terminal` again on every call. Without Snacks, `open`, `simple_toggle`, and `focus_toggle` send an ERROR notification, and `close` returns without one [terminal/snacks.lua:is_available] [terminal/snacks.lua:M.open] [terminal/snacks.lua:M.close] - `snacks.focus_toggle` shows and focuses a hidden terminal, hides a focused one, and focuses a visible one that does not have the cursor. A terminal is visible only when its window is valid, not hidden through its config, and shows the terminal buffer [terminal/snacks.lua:M.focus_toggle] [terminal/snacks.lua:cc_is_visible] - `snacks` reuses the tracked instance while `terminal:buf_valid()` is true. It passes an argv list from `utils.parse_command`, so no shell expands bracketed model aliases [terminal/snacks.lua:M.open] -- `snacks` calls `Snacks.terminal.open` through `pcall`. Snacks shows its window before it calls `termopen`, so `snacks` wraps `on_buf` to capture the instance and then calls any `on_buf` from `snacks_win_opts`. A start fails when `termopen` raises (for example `E475` for a command that is not executable) or when the buffer is not a terminal afterwards (`termopen` returns `-1` when errors are silenced). On a failed start `snacks` closes the Snacks window, deletes its scratch buffer, clears its state, and sends one ERROR notification with the first line of the error. The notification shows the command but not the env table. The DEBUG log of the failed start lists the env keys but not their values [terminal/snacks.lua:M.open] +- `snacks` calls `Snacks.terminal.open` through `pcall`. Snacks shows its window before it calls `termopen`, so `snacks` wraps `on_buf` to capture the instance and then calls any `on_buf` from `snacks_win_opts`. A start fails when `termopen` raises (for example `E475` for a command that is not executable) or when the buffer is not a terminal afterwards (`termopen` returns `-1` when `:silent!` is in effect). On a failed start `snacks` closes the Snacks window, deletes its scratch buffer, clears its state, and sends one ERROR notification with the first line of the error. The notification shows the command but not the env table. The DEBUG log of the failed start lists the env keys but not their values [terminal/snacks.lua:M.open] - `snacks` forces `auto_close = false` on the Snacks instance. It sets `start_insert` and `auto_insert` only when focusing and `auto_insert ~= false` [terminal/snacks.lua:build_opts] -- `snacks` gives the Snacks window the default `bo = { filetype = "claudecode" }`. It replaces the `snacks_terminal` filetype of the Snacks terminal style, so a `FileType snacks_terminal` autocmd no longer matches the Claude buffer. `snacks_win_opts` is deep-merged over the default, so `snacks_win_opts.bo.filetype` wins, `"snacks_terminal"` restores the Snacks filetype, and other `bo` keys keep the default filetype. The default also wins over a `bo.filetype` in the user's global snacks.nvim config (`styles.terminal`, `terminal.win`) or in a style named by `snacks_win_opts`, because Snacks ranks the options passed to `Snacks.terminal.open` above them. Snacks applies `bo` when it creates the buffer, before `termopen` (`buftype` is still `nofile`) and before it opens the window, so a window option set in a `FileType` handler does not reach the Claude window. Snacks applies `bo` again only when it re-creates a window itself. `snacks` lets it do that when `snacks_win_opts.position` is not `left`, `right`, `top`, or `bottom` (for example `float`), and for a float that `snacks` hid whose window is gone. On Neovim before 0.10, hiding a float closes its window. A split is re-created with `split` (top or bottom) or `vsplit` (left or right), so `FileType` does not fire again for it [terminal/snacks.lua:build_opts] [terminal/snacks.lua:cc_show] [terminal/snacks.lua:cc_hide] +- `snacks` gives the Snacks window the default `bo = { filetype = "claudecode" }`. It replaces the `snacks_terminal` filetype of the Snacks terminal style, so a `FileType snacks_terminal` autocmd no longer matches the Claude buffer. `snacks_win_opts` is deep-merged over the default, so `snacks_win_opts.bo.filetype` wins, `"snacks_terminal"` restores the Snacks filetype, and other `bo` keys keep the default filetype. The default also wins over a `bo.filetype` in the user's global snacks.nvim config (`styles.terminal`, `terminal.win`) or in a style named by `snacks_win_opts`, because Snacks ranks the options passed to `Snacks.terminal.open` above them. Snacks applies `bo` when it creates the buffer, before `termopen` (`buftype` is still `nofile`) and before it opens the window, so a window option set in a `FileType` handler does not reach the Claude window when it opens. Snacks applies `bo` again on a `VimResized` event, because snacks.nvim's `Win:on_resize` calls `Win:update`. With the Claude window open, a resize of the editor runs `FileType claudecode` again, and a window option set in that handler then reaches the Claude window. Snacks also applies `bo` again when it re-creates a window itself. `snacks` lets it do that when `snacks_win_opts.position` is not `left`, `right`, `top`, or `bottom` (for example `float`), and for a float that `snacks` hid whose window is gone. On Neovim before 0.10, hiding a float closes its window. A split is re-created with `split` (top or bottom) or `vsplit` (left or right), so `FileType` does not fire again for it [terminal/snacks.lua:build_opts] [terminal/snacks.lua:cc_show] [terminal/snacks.lua:cc_hide] - `external` and `none` create no Neovim buffer, so they set no filetype [terminal/external.lua:M.open] [terminal/none.lua:M.open] - `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc`. The patched `show` and `toggle` always focus and use the config captured at open [terminal/snacks.lua:patch_instance] - `snacks` hides floats with `nvim_win_set_config({hide=true})` only on Neovim 0.10 and later. It also hides the Snacks backdrop window, and show un-hides both. When the cursor is in the float it hides, it runs `wincmd p` to leave it. It closes split windows and re-creates them on show. It closes a split through `pcall`, so hiding the last window does nothing [terminal/snacks.lua:cc_hide] [terminal/snacks.lua:set_backdrop_hidden] @@ -76,10 +76,10 @@ Optional fields (the dispatcher does not require them): - `native` runs `vsplit`, shows a new listed buffer from `nvim_create_buf` in the split, then runs `vim.fn.termopen` in that buffer with the command, env, and cwd. This spawns the Claude process. When an autocmd swapped another buffer into the split, `native` shows its terminal buffer there after the start. It runs `startinsert` when focused and `auto_insert` is not `false` [terminal/native.lua:open_terminal] - `native` on exit clears its state, for its own terminal and for an adopted one. With `auto_close` it closes the tracked window only if the window still shows the terminal buffer. When the buffer is invalid, it closes the tracked window without that check. It never closes the last window (the only non-floating window of the only tab page), which keeps showing the exited terminal [terminal/native.lua:forget_exited_job] -- `native.close` clears its state and the buffer's mark, so an `open` that runs during the wait starts a new terminal. It registers a one-shot, buffer-local `TermClose` autocmd that detects the exit. It calls `jobstop`, and when `jobstop` reports a running job, it waits up to 1 s for the job to exit. A close from a `TermClose` autocmd does not wait, because the job already exited. It then force-deletes the terminal buffer. Neovim closes the other windows that show it, and the editor's last window stays and shows another buffer. This works for a visible, hidden, or adopted terminal [terminal/native.lua:close_terminal] +- `native.close` clears its state and the buffer's mark, so an `open` that runs during the wait starts a new terminal. It registers a one-shot, buffer-local `TermClose` autocmd that detects the exit. It calls `jobstop`, and when `jobstop` reports a running job, it waits up to 1 s for the job to exit. A close from a `TermClose` autocmd does not wait, because the job already exited. It then force-deletes the terminal buffer. Neovim closes the other windows that show it, and the editor's last window stays and shows another buffer. This works for a visible, hidden, or adopted terminal. Deleting the buffer before the job exits can hang Neovim when text sent to the job is still pending (neovim/neovim#42508). The wait does not prevent that hang for a job that stopped reading, for example `yes`, or `cat` after a large multi-line paste [terminal/native.lua:close_terminal] - `snacks` calls `Snacks.terminal.open`, which spawns the Claude process [terminal/snacks.lua:M.open] - `snacks` always registers a buffer-local `BufWipeout` handler. It registers a buffer-local `TermClose` handler only when `auto_close` is true. `TermClose` logs an ERROR on a nonzero exit, except when `close` stopped the job, then schedules `close({ buf = true })` on the instance and `checktime`. Both handlers stop tracking the instance only while it is still the tracked one [terminal/snacks.lua:setup_terminal_events] -- `snacks.close` stops tracking the instance first, so an `open` that runs during the wait starts a new terminal. For a running job, it registers a one-shot, buffer-local `TermClose` autocmd that detects the exit. It stops the job and waits up to 1 s for it to exit. It then closes the Snacks window, which wipes the terminal buffer. Deleting the buffer before the job exits can hang Neovim when text sent to the job is still pending (neovim/neovim#42508). An exited job is not waited for. Hide keeps the job running [terminal/snacks.lua:M.close] +- `snacks.close` stops tracking the instance first, so an `open` that runs during the wait starts a new terminal. For a running job, it registers a one-shot, buffer-local `TermClose` autocmd that detects the exit. It stops the job and waits up to 1 s for it to exit. It then closes the Snacks window, which wipes the terminal buffer. Deleting the buffer before the job exits can hang Neovim when text sent to the job is still pending (neovim/neovim#42508). The wait does not prevent that hang for a job that stopped reading, for example `yes`, or `cat` after a large multi-line paste. An exited job is not waited for. Hide keeps the job running [terminal/snacks.lua:M.close] - `snacks` maps terminal-mode `` to send a backslash and then a carriage return after 10 ms [terminal/snacks.lua:build_opts] - `snacks` sets window options and `vim.w[win].snacks_win` on re-created splits [terminal/snacks.lua:reapply_snacks_window_state] - `external` spawns a detached process with `vim.fn.jobstart`. `close` calls `vim.fn.jobstop` [terminal/external.lua:M.close] diff --git a/lua/claudecode/tools/README.md b/lua/claudecode/tools/README.md index 972ed0671..42a0a08ce 100644 --- a/lua/claudecode/tools/README.md +++ b/lua/claudecode/tools/README.md @@ -46,7 +46,7 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o - After a deferred `openDiff` yields, `diff.open_diff_blocking` maps the outcome with `build_response` and sends it through `_G.claude_deferred_responses[tostring(co)]`, then clears that entry [diff.lua:M.open_diff_blocking] - `openDiff` calls `diff.open_diff_blocking(old, new, contents, tab_name, client.id)`. Structured errors pass through. Other errors become `-32000` "Error opening blocking diff" [tools/open_diff.lua:handler] - `openFile`, `checkDocumentDirty`, and `saveDocument` raise `-32602` when `filePath` is missing [tools/open_file.lua:handler] -- `openFile` raises `-32602` before it opens the file when `startLine` or `endLine` is given but is not an integer (`false` included), `startLine` is below 1 or past the last line, or `endLine` is below `startLine`. The last line comes from the loaded buffer that `:edit` would reuse, which `bufexists()` and `bufadd()` find by name or by the inode Neovim cached for it, so a symlink or hard link finds it. Without one it comes from the file on disk. For a loaded buffer without unsaved changes the file on disk counts too, because openFile loads the file again when it changed. When the buffer openFile then shows has fewer lines than `startLine`, openFile selects its last line. An `endLine` past the last line selects through the last line, and the answer names the lines it selected [tools/open_file.lua:handler] +- `openFile` raises `-32602` before it opens the file when `startLine` or `endLine` is given but is not an integer (`false` included), `startLine` is below 1 or past the last line, or `endLine` is below `startLine`. The last line comes from the loaded buffer that `:edit` would reuse, which `bufexists()` and `bufadd()` find by name or by the inode Neovim cached for it, so a symlink or hard link finds it. A buffer made before its file existed has no cached inode when another program creates the file, so a symlink to that file does not find it. Without one it comes from the file on disk. For a loaded buffer without unsaved changes the file on disk counts too, because openFile loads the file again when it changed. When the buffer openFile then shows has fewer lines than `startLine`, openFile selects its last line. An `endLine` past the last line selects through the last line, and the answer names the lines it selected [tools/open_file.lua:handler] - `openFile` raises `-32000` with `message = "File operation error"` and `data = "File not found: "` when the file is not readable, and with `data = "No preview window shows the file"` when the preview window is closed or no longer has 'previewwindow' set after it gets the file, for example because an autocommand cleared it. It raises `-32000` and changes nothing while the command-line window is open, and when the user is in the middle of a command in the preview window, which openFile would reuse. Its defaults are `preview=false`, `makeFrontmost=true`, and `selectToEndOfLine=false` [tools/open_file.lua:handler] - `openFile` expands only a leading `~` in `filePath` (`utils.expand_tilde`). When only `endLine` is given, `startLine` is 1. When only `startLine` is given, `endLine` equals it [tools/open_file.lua:handler] - `openFile` finds `startText` and `endText` as plain text and takes the first match. It searches `endText` after the `startText` match, ignores an empty string, and applies `selectToEndOfLine` only when `endText` matches. A text selection replaces a line range. When `startText` is not found, the call succeeds with `Opened file, but text "" not found` [tools/open_file.lua:handler] @@ -67,7 +67,7 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o ## Side Effects -- `openFile` runs `edit` in the main editor window, the first window that is not floating, a terminal, prompt, or `nofile` buffer, a diff window, the preview window, or a file-explorer or outline sidebar. With preview it shows the file in the preview window of that tab page instead, and makes one as `pedit` does when the tab page has none. Like `pedit`, it shows the file there even when that window has 'winfixbuf' set. A buffer that is already loaded is not re-read, so its unsaved changes and the folds of other windows on it stay. When its file changed on disk, openFile loads a buffer without unsaved changes again, as `:edit` does, and keeps a buffer with unsaved changes as it is, without asking the user. For that it adds a FileChangedShell autocommand for the buffer that runs after the user's and sets `v:fcs_choice`, so 'autoread' and a user's `v:fcs_choice` do not change the result. 'eventignore' or, on Neovim 0.11 and later, 'eventignorewin' can make Neovim skip that autocommand. For a window that already shows the buffer, openFile reads them as Neovim does, where 'eventignorewin' counts for the current window when it shows the buffer and otherwise only when every window that shows the buffer ignores the event. When the event is ignored there, openFile checks the file only with 'autoread', which loads it without asking, so with 'noautoread' the buffer keeps the old text. When openFile shows the buffer in a window that did not show it, and 'eventignore', or 'eventignorewin' of that window or of a window that shows the buffer, ignores the event, openFile turns Neovim's own check off for that step and then checks the file as for a window that shows the buffer. Autocommands that run during that step see the buffer's 'buftype' set to `acwrite`, which keeps 'modified' as it is. A plugin that skips buffers whose 'buftype' is set skips this one in those autocommands, until a window enters it again, and an autocommand that sets 'buftype' to `acwrite` itself is undone. A write of an `acwrite` buffer skips Neovim's E13 and changed-file checks and goes to BufWriteCmd. So when a user's `++nested` BufEnter, BufWinEnter or BufLeave autocommand writes the buffer during that step, and a user's BufWriteCmd autocommand that matches the file (such as `BufWriteCmd *`) writes ``, that handler writes the buffer over the file. Without `++nested` no BufWriteCmd runs and the write fails with E676. Without such a handler it fails the same way. Outside that step, a write of a buffer with unsaved changes whose file Claude changed is a normal write. When a user's autocommand starts one while openFile switches buffers or windows, for example `autocmd BufLeave * silent! wall`, Neovim asks "The file has been changed since reading it!!! Do you really want to write to it (y/n)?", and openFile answers Claude only after the user does. `y` writes the unsaved text over Claude's file. `n` or Escape writes nothing, and the buffer keeps its unsaved text. CTRL-C writes nothing, leaves the focus where it was, and fails openFile with "Keyboard interrupt". Other keys repeat the question. After `y` the answer to Claude also waits for Enter at Neovim's hit-enter prompt, on 0.8 and 0.10, and on 0.12 with the default 'messagesopt'. A buffer whose 'buftype' is already set is not checked, as Neovim does not check it. A buffer opened before its file existed, for which Neovim fired BufNewFile, gets the same step, because Neovim's question for it, W13, runs no FileChangedShell. Without unsaved changes openFile reads the file into it with `:edit`, except during Visual or Select mode that CTRL-O started, which `:edit` would end, and except when an autocommand showed another buffer in the window. Then the buffer keeps its text. With unsaved changes it keeps them, and a later `:w` fails with E13 until `:w!`. openFile knows such buffers from the time `open_file.lua` loads, which `tools.register_all` does when the Claude Code server starts. A buffer loaded before then counts when its file was missing at that time. No Neovim API tells such a buffer apart, so W13 can still come for one whose file appeared before the server started, or that was made while 'eventignore' ignored BufNewFile. A write that runs no BufWritePost does not end the tracking: one from an autocommand without `++nested`, such as `autocmd BufLeave * update`, or one through a BufWriteCmd handler. openFile then keeps running its acwrite step for that buffer, and reads the file into it again with `:edit` when it has no unsaved changes. Neovim no longer asks W13 for that buffer, but it asks W12 when the buffer has unsaved text and its file changed. The acwrite step turns Neovim's check off, so no question comes, and unsaved text stays. Neovim can still ask when a user's FileChangedShell autocommand deletes openFile's: the user's autocommand then decides alone. An error in a user's autocommand fails openFile. Run from Lua, such an error during a reload stops the reload halfway and leaves the old text with empty lines added, while Neovim takes the file as read. So when the reload began, openFile reads the file again under `:silent!`, as a `:checktime` that the user types would finish, before it fails: the buffer gets the whole file, and that read ends Visual mode. After a FileChangedShell error no reload begins: the buffer keeps the old text, and a later `:w` warns that the file changed. When no suitable window exists, it can split or move windows. It goes to the top-left window, then to the window right of it. When that is the preview window, it moves to the window below, because a split of the preview window keeps its 'previewheight' lines. It splits the window it reached instead of editing into it when that window shows a terminal or `nofile` buffer or is a diff or preview window, and turns 'diff' off in the new split. With `makeFrontmost=false` it gives the focus back to the window that had it. With preview it splits only to make the preview window when the tab page has none, so a terminal keeps one window. Then it works in the window that shows the file. For line or text ranges it searches that window's buffer and sets the visual marks and the cursor there. `makeFrontmost` focuses that window, and with `makeFrontmost=false` the JSON answer describes its buffer. Visual mode exists only in the current window, so with `makeFrontmost=false` and another window current, the marks are set but no selection stays on. With preview this holds even when the current window shows the same file, because the file goes to the preview window. With 'selection' set to inclusive, a range that ends on an empty line also covers that line's line break. When `endText` is not found, it only places the cursor on the `startText` match. After it places the cursor on a `startText` match, it scrolls that window so the match line is centered (`normal! zz`). Selecting never changes the buffer's text. It ends any Visual or Select mode before it selects, it always selects charwise, and `gv` is the last key it sends, so with 'selectmode' containing `cmd` the selection is left in Select mode. When the user is in the middle of a command, it leaves the current window, its cursor, and the mode alone. That covers an operator such as `d` that waits for its motion, Insert mode, Replace mode, CTRL-O from them, and the command line. On Neovim 0.10 and later it also covers Visual or Select mode that CTRL-O started, which `state()` shows as `S`. On 0.8 and 0.9 that mode looks like plain Visual mode, so openFile ends it and selects as usual: after the call the mode is `v` with Claude's selection in the opened file, the user's next Visual command acts on that selection, and Insert mode then resumes in the opened file. It shows the file in the preview window, places the cursor there, selects nothing, does not focus it, and sends no keys, so the user's next keys finish the command where it started. When the command runs in the buffer of that file, the rules above can still load the file into it, and the user's next keys then act on the new text. The answer says so, except the `makeFrontmost=false` JSON. A register prefix such as `"a` looks like Normal mode, so openFile works as usual and the user's next command acts in the opened file. A count or a prefix such as `g` or CTRL-W blocks Neovim, which reads no request until the next key [tools/open_file.lua:handler] +- `openFile` runs `edit` in the main editor window, the first window that is not floating, a terminal, prompt, or `nofile` buffer, a diff window, the preview window, or a file-explorer or outline sidebar. With preview it shows the file in the preview window of that tab page instead, and makes one as `pedit` does when the tab page has none. Like `pedit`, it shows the file there even when that window has 'winfixbuf' set. A buffer that is already loaded is not re-read, so its unsaved changes and the folds of other windows on it stay. When its file changed on disk, openFile loads a buffer without unsaved changes again, as `:edit` does, and keeps a buffer with unsaved changes as it is, without asking the user. For that it adds a FileChangedShell autocommand for the buffer that runs after the user's and sets `v:fcs_choice`, so 'autoread' and a user's `v:fcs_choice` do not change the result. 'eventignore' or, on Neovim 0.11 and later, 'eventignorewin' can make Neovim skip that autocommand. For a window that already shows the buffer, openFile reads them as Neovim does, where 'eventignorewin' counts for the current window when it shows the buffer and otherwise only when every window that shows the buffer ignores the event. When the event is ignored there, openFile checks the file only with 'autoread', which loads it without asking, so with 'noautoread' the buffer keeps the old text. When openFile shows the buffer in a window that did not show it, and 'eventignore', or 'eventignorewin' of that window or of a window that shows the buffer, ignores the event, openFile turns Neovim's own check off for that step and then checks the file as for a window that shows the buffer. Autocommands that run during that step see the buffer's 'buftype' set to `acwrite`, which keeps 'modified' as it is. A plugin that skips buffers whose 'buftype' is set skips this one in those autocommands, until a window enters it again, and an autocommand that sets 'buftype' to `acwrite` itself is undone. A write of an `acwrite` buffer skips Neovim's E13 and changed-file checks and goes to BufWriteCmd. So when a user's `++nested` BufEnter, BufWinEnter or BufLeave autocommand writes the buffer during that step, and a user's BufWriteCmd autocommand that matches the file (such as `BufWriteCmd *`) writes ``, that handler writes the buffer over the file. Without `++nested` no BufWriteCmd runs and the write fails with E676. Without such a handler it fails the same way. Outside that step, a write of a buffer with unsaved changes whose file Claude changed is a normal write. When a user's autocommand starts one while openFile switches buffers or windows, for example `autocmd BufLeave * silent! wall`, Neovim asks "The file has been changed since reading it!!! Do you really want to write to it (y/n)?", and openFile answers Claude only after the user does. `y` writes the unsaved text over Claude's file. `n` or Escape writes nothing, and the buffer keeps its unsaved text. CTRL-C writes nothing, leaves the focus where it was, and fails openFile with "Keyboard interrupt". Other keys repeat the question. After `y` the answer to Claude also waits for Enter at Neovim's hit-enter prompt, on 0.8 to 0.12. On 0.11 and later it waits only while 'messagesopt' contains `hit-enter`, the default. With `wait:N` there instead, the prompt ends after N ms. A buffer whose 'buftype' is already set is not checked, as Neovim does not check it. A buffer opened before its file existed, for which Neovim fired BufNewFile, gets the same step, because Neovim's question for it, W13, runs no FileChangedShell. Without unsaved changes openFile reads the file into it with `:edit`, except during Visual or Select mode that CTRL-O started, which `:edit` would end, and except when an autocommand showed another buffer in the window. Then the buffer keeps its text. With unsaved changes it keeps them, and a later `:w` fails with E13 until `:w!`. openFile knows such buffers from the time `open_file.lua` loads, which `tools.register_all` does when the Claude Code server starts. A buffer loaded before then counts when its file was missing at that time. No Neovim API tells such a buffer apart, so W13 can still come for one whose file appeared before the server started, or that was made while 'eventignore' ignored BufNewFile. A write that runs no BufWritePost does not end the tracking: one from an autocommand without `++nested`, such as `autocmd BufLeave * update`, or one through a BufWriteCmd handler. openFile then keeps running its acwrite step for that buffer, and reads the file into it again with `:edit` when it has no unsaved changes. Neovim no longer asks W13 for that buffer, but it asks W12 when the buffer has unsaved text and its file changed. The acwrite step turns Neovim's check off, so no question comes, and unsaved text stays. Neovim can still ask when a user's FileChangedShell autocommand deletes openFile's: the user's autocommand then decides alone. An error in a user's autocommand fails openFile. Run from Lua, an error in a user's BufReadPre or BufReadPost autocommand during a reload stops the reload halfway. The buffer keeps the old text with empty lines added, and Neovim takes the file as read. So when the reload began, openFile reads the file again under `:silent!`, as a `:checktime` that the user types would finish, before it fails. Then the buffer gets the whole file. That read does not end Visual mode. openFile sees the reload begin in its own BufReadPre autocommand, which runs after the user's. So after an error in a user's BufReadPre autocommand, openFile does not read the file again, and the buffer keeps the old text with empty lines added [tools/open_file.lua:run_reload_command]. After a FileChangedShell error no reload begins. The buffer keeps the old text, and a later `:w` warns that the file changed. With 'autoread' set, Neovim fires FileChangedShell only for a buffer with unsaved changes. When no suitable window exists, it can split or move windows. It goes to the top-left window, then to the window right of it. When that is the preview window, it moves to the window below, because the preview window keeps its 'previewheight' lines when it is split, and a vertical split gives the new window that height too. It splits the window it reached instead of editing into it when that window shows a terminal or `nofile` buffer or is a diff or preview window, and turns 'diff' off in the new split. With `makeFrontmost=false` it gives the focus back to the window that had it. With preview it splits only to make the preview window when the tab page has none, so a terminal keeps one window. Then it works in the window that shows the file. For line or text ranges it searches that window's buffer and sets the visual marks and the cursor there. `makeFrontmost` focuses that window, and with `makeFrontmost=false` the JSON answer describes its buffer. Visual mode exists only in the current window, so with `makeFrontmost=false` and another window current, the marks are set but no selection stays on. With preview this holds even when the current window shows the same file, because the file goes to the preview window. With 'selection' set to inclusive, a range that ends on an empty line also covers that line's line break. When `endText` is not found, it only places the cursor on the `startText` match. After it places the cursor on a `startText` match, it scrolls that window so the match line is centered (`normal! zz`). Selecting never changes the buffer's text. It ends any Visual or Select mode before it selects, it always selects charwise, and `gv` is the last key it sends, so with 'selectmode' containing `cmd` the selection is left in Select mode. When the user is in the middle of a command, it leaves the current window, its cursor, and the mode alone. That covers an operator such as `d` that waits for its motion, Insert mode, Replace mode, CTRL-O from them, and the command line. On Neovim 0.10 and later it also covers Visual or Select mode that CTRL-O started, which `state()` shows as `S`. On 0.8 and 0.9 that mode looks like plain Visual mode, so openFile ends it and selects as usual: after the call the mode is `v` with Claude's selection in the opened file, the user's next Visual command acts on that selection, and Insert mode then resumes in the opened file. It shows the file in the preview window, places the cursor there, selects nothing, does not focus it, and sends no keys, so the user's next keys finish the command where it started. When the command runs in the buffer of that file, the rules above can still load the file into it, and the user's next keys then act on the new text. The answer says so, except the `makeFrontmost=false` JSON. A register prefix such as `"a` looks like Normal mode, so openFile works as usual and the user's next command acts in the opened file. A count or a prefix such as `g` or CTRL-W blocks Neovim, which reads no request until the next key [tools/open_file.lua:handler] - `openDiff` opens a blocking diff through `claudecode.diff`. The response stays deferred until the user accepts or rejects the diff [tools/open_diff.lua:handler]. A diff that resolves before `openDiff` yields, for example from a `ClaudeCodeDiffOpened` handler, is answered at once [diff.lua:M.open_diff_blocking] - `saveDocument` runs `write` in the matching buffer with `nvim_buf_call` [tools/save_document.lua:handler] - `closeAllDiffTabs` calls `diff.close_all_diffs("closeAllDiffTabs tool", client.id)` and returns `CLOSED__DIFF_TABS`. `n` counts only the diffs it closed: a diff the user accepted for another client stays open and is not counted [tools/close_all_diff_tabs.lua:handler] [diff.lua:M.close_all_diffs] @@ -82,4 +82,5 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o ## Verification - Run: `mise run test` -- Targeted: `busted -v tests/unit/tools_spec.lua`, `busted -v tests/unit/tools/open_file_spec.lua`, `busted -v tests/unit/tools/open_diff_mcp_spec.lua`, `busted -v tests/unit/tools/get_diagnostics_spec.lua`, `busted -v tests/unit/tools/save_document_spec.lua`, `busted -v tests/unit/tools/close_tab_spec.lua`, `busted -v tests/unit/tools/get_current_selection_spec.lua`, `busted -v tests/unit/tools/get_open_editors_spec.lua`, `busted -v tests/integration/mcp_tools_spec.lua`, `busted -v tests/integration/open_file_selection_spec.lua` +- Targeted: `busted -v tests/unit/tools_spec.lua`, `busted -v tests/unit/tools/open_file_spec.lua`, `busted -v tests/unit/tools/open_diff_mcp_spec.lua`, `busted -v tests/unit/tools/get_diagnostics_spec.lua`, `busted -v tests/unit/tools/save_document_spec.lua`, `busted -v tests/unit/tools/close_tab_spec.lua`, `busted -v tests/unit/tools/get_current_selection_spec.lua`, `busted -v tests/unit/tools/get_open_editors_spec.lua`, `busted -v tests/integration/mcp_tools_spec.lua` +- Real Neovim: `scripts/run_integration_tests_individually.sh` runs each `tests/integration/*_spec.lua` file in headless Neovim with plenary.nvim and skips `command_args_spec.lua`. It runs `tests/integration/open_file_selection_spec.lua`, which `busted` only marks as pending diff --git a/scripts/README.md b/scripts/README.md index 78a440cd8..c212368d1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -23,19 +23,19 @@ CI runs four of these files: `.github/workflows/release-please.yaml` runs `./scr - `get_claude_ws_url` prints `ws://localhost:PORT` [lib_claude.sh:get_claude_ws_url]. - `get_claude_auth_token PORT` reads `authToken` from `PORT.lock`, with `jq` or a `grep`/`sed` fallback [lib_claude.sh:get_claude_auth_token]. - `create_message`, `create_notification`, and `create_init_message` print JSON-RPC requests, notifications, and an `initialize` request (protocolVersion `2025-03-26`) [lib_claude.sh:create_message, create_notification, create_init_message]. -- `send_claude_message MSG [WS_URL] [AUTH_TOKEN]` sends one message through `timeout` + `websocat`. It always returns 0. When `timeout` exits nonzero, it prints a JSON-RPC error with code `-32000` after any response it got [lib_claude.sh:send_claude_message]. +- `send_claude_message MSG [WS_URL] [AUTH_TOKEN]` sends one message through `timeout` + `websocat`. It always returns 0. When `timeout` exits nonzero, it prints a JSON-RPC error with code `-32000` after any response it got [lib_claude.sh:send_claude_message]. Without an `AUTH_TOKEN` argument it reads the token from the newest lock file. With a token it puts `--header` before the URL. websocat 1.14.1, which `mise.toml` pins, takes the URL as a second header value and exits with `No URL specified`, so the call prints only the `-32000` error. `websocat --text` sends each line of the message as its own WebSocket message, and `create_message` prints multi-line JSON. The server answers each line that is not a whole JSON-RPC message with `-32700` [lib_claude.sh:send_claude_message, create_message]. - `list_claude_tools`, `get_current_selection`, `open_file PATH`, and `initialize_claude_session` wrap `tools/list`, `tools/call getCurrentSelection`, `tools/call openFile`, and the initialize handshake. `open_file` returns 0 even when the call fails [lib_claude.sh:list_claude_tools, get_current_selection, open_file, initialize_claude_session]. - `init_log_dir`, `log_message_and_response`, `claude_is_running`, `command_exists`, and `check_required_tools` are helpers for logging, liveness, and tool checks [lib_claude.sh:init_log_dir, log_message_and_response, claude_is_running, command_exists, check_required_tools]. -- `lib_ws_persistent.sh` keeps a WebSocket open across calls. Functions: `ws_connect URL [CONN_ID] [AUTH_TOKEN]`, `ws_is_connected`, `ws_disconnect`, `ws_request JSON [CONN_ID] [TIMEOUT]`, `ws_notify`, `ws_rpc_request`, `ws_create_message`, `ws_format_json`, `ws_cleanup_all`, `ws_setup_trap`, `ws_start_listener CONN_ID CALLBACK [METHOD]`, and `ws_stop_listener` [lib_ws_persistent.sh:ws_connect, ws_request, ws_start_listener]. It uses `declare -A`, so it needs bash 4 or later [lib_ws_persistent.sh:WS_CONNECTIONS]. +- `lib_ws_persistent.sh` keeps a WebSocket open across calls. Functions: `ws_connect URL [CONN_ID] [AUTH_TOKEN]`, `ws_is_connected`, `ws_disconnect`, `ws_request JSON [CONN_ID] [TIMEOUT]`, `ws_notify`, `ws_rpc_request`, `ws_create_message`, `ws_format_json`, `ws_cleanup_all`, `ws_setup_trap`, `ws_start_listener CONN_ID CALLBACK [METHOD]`, and `ws_stop_listener` [lib_ws_persistent.sh:ws_connect, ws_request, ws_start_listener]. It uses `declare -A`, so it needs bash 4 or later [lib_ws_persistent.sh:WS_CONNECTIONS]. With a token, `ws_connect` puts `--header` before the URL, so websocat 1.14.1 exits with `No URL specified`, and `ws_connect` returns 1 [lib_ws_persistent.sh:ws_connect]. - `ws_rpc_request` special-cases `mcp.connect`: it sends a notification and returns a fake success [lib_ws_persistent.sh:ws_rpc_request]. - `claude_shell_helpers.sh` defines interactive shell commands: `claude_port`, `claude_ws_url`, `claude_running`, `claude_get_selection [-v|--verbose]`, `claude_open_file`, `claude_list_tools [-v|--verbose]`, `claude_send METHOD PARAMS [ID]`, `claude_interactive`, and `claude_help` [claude_shell_helpers.sh:claude_get_selection, claude_send, claude_help]. - `claude_shell_helpers.sh` sets `CLAUDE_LIB_DIR` from `$0`, or to `$HOME/.claude/bin` when `$0` is `-bash` or `-zsh`, and sources `lib_claude.sh` from there. When you source it from bash, `$0` names the shell, not this file, so `lib_claude.sh` must be in the directory that `CLAUDE_LIB_DIR` resolves to [claude_shell_helpers.sh:CLAUDE_LIB_DIR]. -- `claude_get_selection` without `-v` or `--verbose` reads `.result.text`. The server returns the selection in `result.content[0].text`, so the command always prints `No text currently selected` [claude_shell_helpers.sh:claude_get_selection]. `claude_open_file` prints `Opened:` whatever the server answers [claude_shell_helpers.sh:claude_open_file]. +- `claude_get_selection` gets no response from the current plugin, because `send_claude_message` fails on websocat 1.14.1 (see above). So `claude_get_selection` prints `No text currently selected`, and `claude_get_selection -v` prints the `-32000` error [lib_claude.sh:send_claude_message]. Without `-v` or `--verbose` it also reads `.result.text`, while the server returns the selection as a JSON string in `result.content[0].text`. So a response with a selection also prints `No text currently selected` [claude_shell_helpers.sh:claude_get_selection]. `claude_open_file` prints `Opened:` whatever the server answers [claude_shell_helpers.sh:claude_open_file]. ### WebSocket clients and probes - `mcp_test.sh [-p|--port PORT] [-l|--logs DIR] [-t|--timeout SEC] [-c|--compact] [-h|--help] [tests]` runs the MCP tests `connect`, `toolslist`, `toolinvoke`, `methods`, and `selection`. The default is `all`, and a comma-separated list is accepted. The port defaults to `find_claude_lockfile` [mcp_test.sh:should_run_test, WEBSOCKET_PORT]. `-t` sets the per-request timeout (default 5 s). The selection test always listens for 10 s. `-h` exits 1, and the script skips an unknown test name without a message [mcp_test.sh:TIMEOUT, listen_duration, usage, should_run_test]. -- In `mcp_test.sh`, `toolinvoke` and `methods` stop the whole run. `read -r` keeps the single quotes around each test case's params, `ws_format_json` (`jq -c`) rejects them with exit 5, and `set -e` exits. So `all` never reaches `methods` or `selection` [mcp_test.sh:test_tool_invoke, test_methods] [lib_ws_persistent.sh:ws_format_json]. +- In `mcp_test.sh`, against a server without an auth token, where the connect step works, `toolinvoke` and `methods` stop the whole run. `read -r` keeps the single quotes around each test case's params. `ws_format_json` (`jq -c`) rejects them, and `set -e` exits with jq's status: 5 with jq 1.7 and 1.8, and 4 with jq 1.6. So `all` never reaches `methods` or `selection` [mcp_test.sh:test_tool_invoke, test_methods] [lib_ws_persistent.sh:ws_format_json]. - `claude_interactive.sh` (no arguments) is a menu REPL: get selection, list tools, open file, custom JSON-RPC message, initialize, connection info, listen, reconnect, and exit [claude_interactive.sh:show_menu, handle_custom_message, handle_listen]. It exits 1 when the first `ws_connect` fails and exits 0 on the exit option [claude_interactive.sh:ws_connect]. - `research_messages.sh [-p|--port PORT] [-t|--timeout SEC] [-l|--log LOGFILE]` listens for TIMEOUT seconds (default 30), probes several method names, and prints counts of methods, ids, `selection_changed` notifications, and tool responses [research_messages.sh:send_test_message, TIMEOUT]. `-l` moves only the JSONL log. `claude_messages_pretty.txt` always goes to the current directory [research_messages.sh:PRETTY_LOG]. - In `research_messages.sh`, `grep -c` exits the script with 1 under `set -e` when the log holds no `selection_changed` notification, or no reply with id `2` or `3`. The probes go over separate one-shot `websocat -n1` connections whose replies never reach the log, so the script normally stops before its last count line [research_messages.sh:selection_changed_count, tool_responses, send_test_message]. @@ -60,7 +60,7 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr - `repro_issue_218.sh [--no-render-markdown]` (#218) checks for a Neovim crash when a new-file markdown diff is accepted in a new tab with render-markdown.nvim. `NVIM_BIN` selects the binary. Exit 0 means the expected outcome happened. Exit 1 also covers a missing `agent-tty` or `nvim` and a failed `agent-tty` session. It uses `jq` but does not check for it [repro_issue_218.sh:WANT_RM, NVIM_BIN, SID]. - `repro_issue_231.lua` (#231) checks that a terminal-only layout does not fail with "No suitable editor window found". Exit 0 means fixed, 1 means reproduced [repro_issue_231.lua:verdict]. - `repro_issue_232.lua` (#232) checks for a `BufEnter` auto-insert autocmd on the terminal buffer. Env: `CLAUDECODE_PROVIDER=snacks|native`, `CLAUDECODE_AUTO_INSERT=true|false`. Exit 1 means reproduced, 0 means not reproduced or skipped, and 2 means a setup error [repro_issue_232.lua:reproduced]. `CLAUDECODE_PROVIDER` defaults to `snacks`. The script looks for snacks.nvim only at `~/.local/share/nvim/lazy/snacks.nvim` and `~/.local/share/*/lazy/snacks.nvim`, and prints `SKIP` (exit 0) when it cannot load it [repro_issue_232.lua:provider, candidates]. -- `repro_issue_238.lua` (#238) checks that `:q` on the proposed window rejects the diff. CI runs it with `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, which also works on Neovim 0.8. Exit 0 means fixed, 1 means reproduced, and 2 means the script raised an error before its verdict [repro_issue_238.lua:verdict]. It runs `open_in_new_tab=false`, `open_in_new_tab=true`, a split proposed window, and a new file. It finds the repo root from its own path, which `luafile` passes as a relative path, so it must run from the repo root [repro_issue_238.lua:run_scenario, run_multiwindow_scenario, run_newfile_scenario, repo_root]. +- `repro_issue_238.lua` (#238) checks that `:q` on the proposed window rejects the diff. CI runs it with `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, which also works on Neovim 0.8. Exit 0 means fixed, 1 means reproduced, and 2 means the script raised an error before its verdict [repro_issue_238.lua:verdict]. It runs `open_in_new_tab=false`, `open_in_new_tab=true`, a split proposed window, and a new file. It finds the repo root with `:h:h` on its own path. `luafile` passes that path as you typed it, so run the CI command from the repo root, or pass an absolute path. Run from `scripts/` with a bare file name, it cannot load `claudecode.diff` and exits 2 [repro_issue_238.lua:run_scenario, run_multiwindow_scenario, run_newfile_scenario, repo_root]. - `repro_issue_238.sh --file PATH [--lock-dir DIR] [--out FILE] [--hold SEC] [--tab NAME]` (#238) is an external MCP client. It opens one `openDiff` and holds the socket while you run `:q` in Neovim. It prints a `RESULT:` line, but its exit code does not reflect the result [repro_issue_238.sh:RESULT]. `-h` prints the usage header. The lock directory is `CLAUDE_LOCKFILE_DIR`, else `$CLAUDE_CONFIG_DIR/ide`, else `$HOME/.claude/ide`, and `WEBSOCAT` overrides the binary. Exit 2 means a bad or missing argument, and exit 1 means a missing tool, lock file, or token. The proposed contents replace the text `line three` in `--file`, so a file without that text gets a diff with no change [repro_issue_238.sh:LOCK_DIR, WEBSOCAT, contents]. - `repro_issue_246.lua` (#246) checks that single-line and fast visual selections reach Claude. Exit 0 means all checks pass, 1 means a check failed [repro_issue_246.lua:check]. - `repro_issue_248.sh [-n|--num N] [--cleanup]` (#248) opens N diffs and disconnects without `close_tab`. It prints no verdict: run `:DiffState` in Neovim and read the counts [repro_issue_248.sh:NUM_DIFFS, CLEANUP]. `:DiffState` comes from the `fixtures/remote-diff` config. N is a cap: the script diffs the first N sorted `*.txt`, `*.lua`, and `*.md` files at the top level of `workspaceFolders[0]`. `-h` or `--help` prints the usage header, and an unknown argument exits 2. It exits 1 for a missing tool, a missing lock file, token, or workspace, or no file to diff. It exits 0 otherwise, because it ignores a `websocat` failure. It uses `mapfile`, so it needs bash 4 or later [repro_issue_248.sh:LOCK_DIR, FILES]. @@ -80,11 +80,11 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr - `release-please-runner.js` exports `CommuniqueSimpleStrategy`, `UnreleasedAwareChangelog`, `assertLlmCredentials`, `buildCommuniqueArgs`, `createCommuniqueChangelogNotes`, `findLineOutsideFences`, `formatChangelogMarkdown`, `formatChangelogSection`, `formatPullRequestOutputs`, `formatReleaseOutputs`, `normalizeCommuniqueBody`, `resolvePrettierBin`, `releasePleaseNotesAreEmpty`, and `todayIsoDate` [release-please-runner.js:module.exports]. - GitHub Actions outputs: `releases_created`, `release_tags`, `prs_created`, `pr_branches`, and `pr_metadata` [release-please-runner.js:formatReleaseOutputs, formatPullRequestOutputs]. - `package.json` is the private package `claudecode.nvim-release-tools`. It pins `prettier` 3.8.4 and `release-please` 17.9.0 and defines the `test:release-please-runner` script [package.json:scripts]. `package-lock.json` locks the full dependency tree of `package.json` [package-lock.json:packages]. Both CI workflows install from it with `npm ci --prefix scripts` [.github/workflows/test.yml:npm ci] [.github/workflows/release-please.yaml:npm ci]. -- `test-release-please-runner.js` takes no arguments and prints nothing on success. It needs `git`, `prettier`, the `release-please` package, `communique` on `PATH`, and the repo's `communique.toml`. Its Communique check asserts that one release-notes submission is between 4096 and 8192 tokens and gets through in one request [test-release-please-runner.js:testCommuniqueConfigFitsReleaseNotes]. +- `test-release-please-runner.js` takes no arguments and prints nothing on success. `npm run` prints only its own two-line header. It needs `git`, `prettier`, the `release-please` package, a real `communique` binary on `PATH` (for example under `mise exec --`), and the repo's `communique.toml`. It runs `communique` in a temp directory outside the repo. There a mise shim for the `communique` that the repo's `mise.toml` pins prints `communique is not a valid shim`, and the test exits 1. Its Communique check asserts that one release-notes submission is between 4096 and 8192 tokens and gets through in one request [test-release-please-runner.js:testCommuniqueConfigFitsReleaseNotes]. ## Invariants -- Most clients find the server port from the lock file name (`PORT.lock`) in a lock directory. `mcp_test.sh` and `research_messages.sh` skip that lookup when you pass `-p`. `send_claude_message`, the first `ws_connect` in `claude_interactive.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` send the lock file's `authToken` in the `x-claude-code-ide-authorization` header. `repro_issue_277.sh` sends the same header, but reads the port and the token from the plugin state through nvim RPC [lib_claude.sh:get_claude_auth_token, send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:TOKEN] [repro_issue_248.sh:TOKEN]. `mcp_test.sh`, `test_neovim_websocket.sh`, `research_messages.sh`, `websocat.sh`, `test_opendiff.lua`, and `test_opendiff_simple.sh` send no token. The plugin server rejects their upgrade request when it has an auth token, which `start` always sets, so these six clients cannot talk to the current plugin. `mcp_test.sh` exits 1 at its connect step [mcp_test.sh:ws_connect, test_connection] [websocat.sh:websocat] [lua/claudecode/server/handshake.lua:validate_upgrade_request]. +- Most clients find the server port from the lock file name (`PORT.lock`) in a lock directory. `mcp_test.sh` and `research_messages.sh` skip that lookup when you pass `-p`. `send_claude_message`, the first `ws_connect` in `claude_interactive.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` send the lock file's `authToken` in the `x-claude-code-ide-authorization` header. `repro_issue_277.sh` sends the same header, but reads the port and the token from the plugin state through nvim RPC [lib_claude.sh:get_claude_auth_token, send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:TOKEN] [repro_issue_248.sh:TOKEN]. `repro_issue_238.sh`, `repro_issue_248.sh`, and `repro_issue_277.sh` put the URL before `--header`, which websocat 1.14.1 accepts. `send_claude_message` and `ws_connect` put it after, so on websocat 1.14.1 they exit with `No URL specified` before they connect [repro_issue_238.sh:WEBSOCAT] [repro_issue_277.sh:WEBSOCAT] [lib_claude.sh:send_claude_message] [lib_ws_persistent.sh:ws_connect]. `mcp_test.sh`, `test_neovim_websocket.sh`, `research_messages.sh`, `websocat.sh`, `test_opendiff.lua`, and `test_opendiff_simple.sh` send no token. The plugin server rejects their upgrade request when it has an auth token, which `start` always sets, so these six clients cannot talk to the current plugin. `mcp_test.sh` exits 1 at its connect step [mcp_test.sh:ws_connect, test_connection] [websocat.sh:websocat] [lua/claudecode/server/handshake.lua:validate_upgrade_request]. - `lib_claude.sh` checks only `websocat` and `jq`. Sourcing it runs `check_required_tools`, which returns 1 when one is missing but does not call `exit`. A caller that sourced it under `set -e` (`mcp_test.sh`, `research_messages.sh`, `test_neovim_websocket.sh`) therefore exits. `send_claude_message` also needs `timeout` [lib_claude.sh:check_required_tools, send_claude_message]. - `send_claude_message` builds a single-quoted `bash -c` string, so a single quote in the message or token breaks the command [lib_claude.sh:send_claude_message]. - `lib_ws_persistent.sh` assumes single-line compact JSON. `ws_request` needs a non-empty `.id`, and `ws_start_listener` matches the exact text `"method":"NAME"` [lib_ws_persistent.sh:ws_request, ws_start_listener]. @@ -113,7 +113,7 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr ## Side Effects - Sourcing `lib_ws_persistent.sh` creates `${TMPDIR:-/tmp}/ws_logs`. `ws_connect` writes `pid`, `log.txt`, `request.json`, and `response.json` under `ws_logs/CONN_ID/` and starts a background `tail -f | websocat | tee` pipeline [lib_ws_persistent.sh:WS_LOG_DIR, ws_connect]. `ws_request` appends every request to `ws_logs/CONN_ID/debug.log`, which nothing truncates. No function deletes `ws_logs/CONN_ID/` [lib_ws_persistent.sh:ws_request]. -- `ws_disconnect` sends SIGTERM to the saved PID only. That PID is `$!` of the pipeline, which is the `tee` process, not `tail -f` or `websocat`. It sends SIGKILL only when the SIGTERM send fails, not when the process ignores SIGTERM. `ws_setup_trap` runs `ws_cleanup_all` on EXIT, INT, and TERM, and sourcing the library installs that trap. The trap does not call `exit`, so after Ctrl+C the caller closes its connections and keeps running [lib_ws_persistent.sh:ws_connect, ws_disconnect, ws_setup_trap]. +- `ws_disconnect` sends SIGTERM to the saved PID only. That PID is `$!` of the pipeline, which is the `tee` process, not `tail -f` or `websocat`. After each `mcp_test.sh` run, also one whose connect step fails, its `tail -f` and the subshell that waits for the pipeline keep running. After a `set -e` exit in `toolinvoke`, `methods`, or `all`, its `websocat` keeps running too [mcp_test.sh:ws_connect]. It sends SIGKILL only when the SIGTERM send fails, not when the process ignores SIGTERM. `ws_setup_trap` runs `ws_cleanup_all` on EXIT, INT, and TERM, and sourcing the library installs that trap. The trap does not call `exit`, so after Ctrl+C the caller closes its connections and keeps running [lib_ws_persistent.sh:ws_connect, ws_disconnect, ws_setup_trap]. - `ws_start_listener` starts a background polling loop (0.5 s) and writes `listener.pid` [lib_ws_persistent.sh:ws_start_listener]. The loop has no exit condition. Only `ws_stop_listener` kills it, and it reads `listener.pid`, which each `ws_start_listener` call overwrites. `ws_disconnect`, `ws_cleanup_all`, and the trap leave listeners running. `mcp_test.sh` starts up to five listeners and stops at most the last one, so the others keep polling after the script exits [lib_ws_persistent.sh:ws_stop_listener, ws_cleanup_all] [mcp_test.sh:ws_start_listener, ws_stop_listener]. - CAUTION: Do not run the token-sending clients on a shared machine, and do not share the output of `claude_interactive.sh` option 6. The scripts pass the auth token on the `websocat` command line (`--header`), where other users can read it with `ps`. Option 6 prints the whole lock file, including `authToken` [lib_claude.sh:send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:WEBSOCAT] [repro_issue_248.sh:TOKEN] [repro_issue_277.sh:TOKEN] [claude_interactive.sh:handle_connection_info]. - Log directories are created relative to the current directory: `mcp_test_logs` [mcp_test.sh:LOG_DIR], `mcp_interactive_logs` [claude_interactive.sh:CLAUDE_LOG_DIR], `test_neovim_ws_logs` [test_neovim_websocket.sh:LOG_DIR], and `claude_messages.jsonl` / `claude_messages_pretty.txt` [research_messages.sh:LOG_FILE]. Each run truncates its log files, except `claude_interactive.sh`, which only creates its log directory. `claude_interactive.sh` keeps its connection files in `${TMPDIR:-/tmp}/ws_logs/claude_interactive/` [claude_interactive.sh:CONN_ID]. @@ -128,7 +128,7 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr - `repro_issue_70.sh` and `repro_issue_277.sh` create `agent-tty` sessions and temp directories, and remove both on exit. They create the temp directory before they install the `EXIT` trap, so an exit 3 for a missing tool leaves that directory behind. `repro_issue_70.sh` launches the real `claude` CLI and sets proxy variables to `http://127.0.0.1:1` in its scenarios [repro_issue_70.sh:run_scenario] [repro_issue_277.sh:cleanup]. Its cleanup runs `pkill -f "repro_issue_70_probe.lua $REPO_ROOT"`, which also kills the probe of another run in the same checkout [repro_issue_70.sh:cleanup]. - `repro_issue_70_probe.lua` starts the real server with `auto_start = true`, so the plugin writes a real `PORT.lock` into the user's lock directory. Other Claude CLIs can see that lock file until `claudecode.stop()` removes it at the end [repro_issue_70_probe.lua:claudecode.setup, claudecode.stop]. - `repro_issue_277.sh` clones diffview.nvim into `${XDG_DATA_HOME:-$HOME/.local/share}/issue-277/diffview.nvim` when it is absent. It copies artifacts to `${TMPDIR:-/tmp}/issue277-artifacts-latest` when `ISSUE277_ARTIFACTS` is unset [repro_issue_277.sh:DIFFVIEW_DIR]. -- `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. The fixture clones lazy.nvim and the plugins into the `stdpath("data")` of `NVIM_APPNAME` `issue-218` or `issue-218norm`, outside the temp directory. The script does not remove its temp directory [repro_issue_218.sh:APPNAME, cleanup]. +- `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. The fixture clones lazy.nvim and the plugins into `stdpath("data")/lazy` of `NVIM_APPNAME` `issue-218`, or `issue-218norm` with `--no-render-markdown`, outside the temp directory. The default run sets `XDG_CONFIG_HOME` to the repo's `fixtures/` directory, so `Lazy! sync` also rewrites the tracked file `fixtures/issue-218/lazy-lock.json`. The `--no-render-markdown` run writes its lock file in the temp directory. The script does not remove its temp directory [repro_issue_218.sh:APPNAME, CONFHOME, cleanup]. - When `--out` is omitted, `repro_issue_238.sh` writes the frames to a `mktemp` file `repro238.frames.*` and does not delete it. Its `EXIT` trap removes only the request file [repro_issue_238.sh:OUT, REQ]. - Without `--cleanup`, `repro_issue_248.sh` disconnects without `close_tab`. The server then rejects and closes that client's pending diffs, so `:DiffState` shows `active_diffs=0`. With `--cleanup` it calls `closeAllDiffTabs` first [repro_issue_248.sh:CLEANUP]. - `repro_issue_283.lua` binds TCP sockets on `127.0.0.1`. `repro_issue_283.sh` starts a background Neovim and kills it on exit [repro_issue_283.lua:listener:bind] [repro_issue_283.sh:cleanup]. @@ -138,6 +138,6 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr ## Verification -- Run: `npm --prefix scripts run test:release-please-runner` (needs `npm --prefix scripts ci` first, and `communique` on `PATH`, which `mise install` provides). +- Run: `mise exec -- npm --prefix scripts run test:release-please-runner` (needs `npm --prefix scripts ci` first). `mise exec --` puts the real `communique` binary that `mise install` provides on `PATH`. The mise shim fails, as `test-release-please-runner.js` above describes. - Run: `mise run format-check`. treefmt formats and lints `scripts/` with `shfmt` and `shellcheck` (`*.sh`), `stylua` (`*.lua`), and `prettier` (`*.js`, `*.json`, `*.md`). - `mise run check` runs `luacheck` on `lua/` and `tests/` only, so it does not cover `scripts/`. From df30eac6dc51105319993fe7eaed61523db1e030 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 12:00:29 +0000 Subject: [PATCH 03/13] docs: leave scripts/README.md to the scripts cleanup PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket shell clients are being removed in their own PR, which carries the refreshed scripts README. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- scripts/README.md | 107 ++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 61 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index c212368d1..8f1f10e61 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,140 +4,125 @@ `scripts/` holds developer and release tooling for claudecode.nvim. Nothing here is loaded by the plugin at runtime. The directory has five groups: -1. Shell libraries that speak JSON-RPC to the plugin's WebSocket server (`lib_claude.sh`, `lib_ws_persistent.sh`, `claude_shell_helpers.sh`). `lib_claude.sh` finds the server through its lock file. `lib_ws_persistent.sh` takes the URL and the token from its caller. +1. Shell libraries that find the plugin's WebSocket server through its lock file and speak JSON-RPC to it (`lib_claude.sh`, `lib_ws_persistent.sh`, `claude_shell_helpers.sh`). 2. Interactive and probe clients built on those libraries (`claude_interactive.sh`, `mcp_test.sh`, `research_messages.sh`, `websocat.sh`, `test_neovim_websocket.sh`). 3. Test harnesses (`run_integration_tests_individually.sh`, `test_opendiff.lua`, `test_opendiff_simple.sh`, `manual_test_helper.lua`). -4. Standalone reproductions of GitHub issues (`repro_issue_*.lua`, `repro_issue_*.sh`). `repro_issue_70_probe.lua` matches that pattern, but it is a helper that `repro_issue_70.sh` runs. -5. Release tooling: a release-please wrapper that writes changelog entries with Communique (`release-please-runner.js`, `run-release-please.sh`, `package.json`, `package-lock.json`). `test-release-please-runner.js` holds the tests for `release-please-runner.js`. It also runs the pinned `communique` binary with the repo's `communique.toml` against a fake Anthropic API, to check that one release-notes submission fits the configured output budget. +4. Standalone reproductions of GitHub issues (`repro_issue_*.lua`, `repro_issue_*.sh`). +5. Release tooling: a release-please wrapper that writes changelog entries with Communique (`release-please-runner.js`, `run-release-please.sh`, `package.json`). `test-release-please-runner.js` holds the tests for `release-please-runner.js`. It also runs the pinned `communique` binary with the repo's `communique.toml` against a fake Anthropic API, to check that one release-notes submission fits the configured output budget. CI runs four of these files: `.github/workflows/release-please.yaml` runs `./scripts/run-release-please.sh`, and `.github/workflows/test.yml` runs `./scripts/run_integration_tests_individually.sh` and `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"` on Neovim stable and v0.8.0, and `npm --prefix scripts run test:release-please-runner` (which runs `test-release-please-runner.js`). -`mise.toml` provisions `websocat` and `jq` for the WebSocket scripts, and `communique` for the release tooling. It does not provide `agent-tty`, which `repro_issue_70.sh`, `repro_issue_218.sh`, and `repro_issue_277.sh` need. `repro_issue_277.sh` also needs `git` and `perl`, and `repro_issue_70.sh` needs a logged-in `claude` CLI [repro_issue_70.sh:MISSING dependency] [repro_issue_218.sh:agent-tty] [repro_issue_277.sh:MISSING dependency]. +`mise.toml` provisions `websocat` and `jq` for the WebSocket scripts. ## Interface ### Shell libraries (source them) -- `lib_claude.sh` sets `CLAUDE_LOCKFILE_DIR` to `$CLAUDE_CONFIG_DIR/ide`, or to `$HOME/.claude/ide` when `CLAUDE_CONFIG_DIR` is unset or empty. It also exports `CLAUDE_LOG_DIR=mcp_test_logs` and `CLAUDE_WS_TIMEOUT=10` each time you source it, so set either variable after the `source` line [lib_claude.sh:CLAUDE_LOCKFILE_DIR, CLAUDE_LOG_DIR, CLAUDE_WS_TIMEOUT]. +- `lib_claude.sh` sets `CLAUDE_LOCKFILE_DIR` to `$CLAUDE_CONFIG_DIR/ide`, or to `$HOME/.claude/ide` when `CLAUDE_CONFIG_DIR` is unset. It also exports `CLAUDE_LOG_DIR=mcp_test_logs` and `CLAUDE_WS_TIMEOUT=10` [lib_claude.sh:CLAUDE_LOCKFILE_DIR]. - `find_claude_lockfile` prints the port of the newest `*.lock` file by mtime and returns 1 when none exists. It reads the mtime with GNU `stat -c "%Y"`. BSD `stat` on macOS rejects that option, so there the function finds no lock file [lib_claude.sh:find_claude_lockfile]. - `get_claude_ws_url` prints `ws://localhost:PORT` [lib_claude.sh:get_claude_ws_url]. - `get_claude_auth_token PORT` reads `authToken` from `PORT.lock`, with `jq` or a `grep`/`sed` fallback [lib_claude.sh:get_claude_auth_token]. - `create_message`, `create_notification`, and `create_init_message` print JSON-RPC requests, notifications, and an `initialize` request (protocolVersion `2025-03-26`) [lib_claude.sh:create_message, create_notification, create_init_message]. -- `send_claude_message MSG [WS_URL] [AUTH_TOKEN]` sends one message through `timeout` + `websocat`. It always returns 0. When `timeout` exits nonzero, it prints a JSON-RPC error with code `-32000` after any response it got [lib_claude.sh:send_claude_message]. Without an `AUTH_TOKEN` argument it reads the token from the newest lock file. With a token it puts `--header` before the URL. websocat 1.14.1, which `mise.toml` pins, takes the URL as a second header value and exits with `No URL specified`, so the call prints only the `-32000` error. `websocat --text` sends each line of the message as its own WebSocket message, and `create_message` prints multi-line JSON. The server answers each line that is not a whole JSON-RPC message with `-32700` [lib_claude.sh:send_claude_message, create_message]. -- `list_claude_tools`, `get_current_selection`, `open_file PATH`, and `initialize_claude_session` wrap `tools/list`, `tools/call getCurrentSelection`, `tools/call openFile`, and the initialize handshake. `open_file` returns 0 even when the call fails [lib_claude.sh:list_claude_tools, get_current_selection, open_file, initialize_claude_session]. +- `send_claude_message MSG [WS_URL] [AUTH_TOKEN]` sends one message through `timeout` + `websocat` [lib_claude.sh:send_claude_message]. +- `list_claude_tools`, `get_current_selection`, `open_file PATH`, and `initialize_claude_session` wrap `tools/list`, `tools/call getCurrentSelection`, `tools/call openFile`, and the initialize handshake [lib_claude.sh:list_claude_tools, get_current_selection, open_file, initialize_claude_session]. - `init_log_dir`, `log_message_and_response`, `claude_is_running`, `command_exists`, and `check_required_tools` are helpers for logging, liveness, and tool checks [lib_claude.sh:init_log_dir, log_message_and_response, claude_is_running, command_exists, check_required_tools]. -- `lib_ws_persistent.sh` keeps a WebSocket open across calls. Functions: `ws_connect URL [CONN_ID] [AUTH_TOKEN]`, `ws_is_connected`, `ws_disconnect`, `ws_request JSON [CONN_ID] [TIMEOUT]`, `ws_notify`, `ws_rpc_request`, `ws_create_message`, `ws_format_json`, `ws_cleanup_all`, `ws_setup_trap`, `ws_start_listener CONN_ID CALLBACK [METHOD]`, and `ws_stop_listener` [lib_ws_persistent.sh:ws_connect, ws_request, ws_start_listener]. It uses `declare -A`, so it needs bash 4 or later [lib_ws_persistent.sh:WS_CONNECTIONS]. With a token, `ws_connect` puts `--header` before the URL, so websocat 1.14.1 exits with `No URL specified`, and `ws_connect` returns 1 [lib_ws_persistent.sh:ws_connect]. +- `lib_ws_persistent.sh` keeps a WebSocket open across calls. Functions: `ws_connect URL [CONN_ID] [AUTH_TOKEN]`, `ws_is_connected`, `ws_disconnect`, `ws_request JSON [CONN_ID] [TIMEOUT]`, `ws_notify`, `ws_rpc_request`, `ws_create_message`, `ws_format_json`, `ws_cleanup_all`, `ws_setup_trap`, `ws_start_listener CONN_ID CALLBACK [METHOD]`, and `ws_stop_listener` [lib_ws_persistent.sh:ws_connect, ws_request, ws_start_listener]. - `ws_rpc_request` special-cases `mcp.connect`: it sends a notification and returns a fake success [lib_ws_persistent.sh:ws_rpc_request]. -- `claude_shell_helpers.sh` defines interactive shell commands: `claude_port`, `claude_ws_url`, `claude_running`, `claude_get_selection [-v|--verbose]`, `claude_open_file`, `claude_list_tools [-v|--verbose]`, `claude_send METHOD PARAMS [ID]`, `claude_interactive`, and `claude_help` [claude_shell_helpers.sh:claude_get_selection, claude_send, claude_help]. -- `claude_shell_helpers.sh` sets `CLAUDE_LIB_DIR` from `$0`, or to `$HOME/.claude/bin` when `$0` is `-bash` or `-zsh`, and sources `lib_claude.sh` from there. When you source it from bash, `$0` names the shell, not this file, so `lib_claude.sh` must be in the directory that `CLAUDE_LIB_DIR` resolves to [claude_shell_helpers.sh:CLAUDE_LIB_DIR]. -- `claude_get_selection` gets no response from the current plugin, because `send_claude_message` fails on websocat 1.14.1 (see above). So `claude_get_selection` prints `No text currently selected`, and `claude_get_selection -v` prints the `-32000` error [lib_claude.sh:send_claude_message]. Without `-v` or `--verbose` it also reads `.result.text`, while the server returns the selection as a JSON string in `result.content[0].text`. So a response with a selection also prints `No text currently selected` [claude_shell_helpers.sh:claude_get_selection]. `claude_open_file` prints `Opened:` whatever the server answers [claude_shell_helpers.sh:claude_open_file]. +- `claude_shell_helpers.sh` defines interactive shell commands: `claude_port`, `claude_ws_url`, `claude_running`, `claude_get_selection [-v]`, `claude_open_file`, `claude_list_tools [-v]`, `claude_send METHOD PARAMS [ID]`, `claude_interactive`, and `claude_help` [claude_shell_helpers.sh:claude_get_selection, claude_send, claude_help]. ### WebSocket clients and probes -- `mcp_test.sh [-p|--port PORT] [-l|--logs DIR] [-t|--timeout SEC] [-c|--compact] [-h|--help] [tests]` runs the MCP tests `connect`, `toolslist`, `toolinvoke`, `methods`, and `selection`. The default is `all`, and a comma-separated list is accepted. The port defaults to `find_claude_lockfile` [mcp_test.sh:should_run_test, WEBSOCKET_PORT]. `-t` sets the per-request timeout (default 5 s). The selection test always listens for 10 s. `-h` exits 1, and the script skips an unknown test name without a message [mcp_test.sh:TIMEOUT, listen_duration, usage, should_run_test]. -- In `mcp_test.sh`, against a server without an auth token, where the connect step works, `toolinvoke` and `methods` stop the whole run. `read -r` keeps the single quotes around each test case's params. `ws_format_json` (`jq -c`) rejects them, and `set -e` exits with jq's status: 5 with jq 1.7 and 1.8, and 4 with jq 1.6. So `all` never reaches `methods` or `selection` [mcp_test.sh:test_tool_invoke, test_methods] [lib_ws_persistent.sh:ws_format_json]. -- `claude_interactive.sh` (no arguments) is a menu REPL: get selection, list tools, open file, custom JSON-RPC message, initialize, connection info, listen, reconnect, and exit [claude_interactive.sh:show_menu, handle_custom_message, handle_listen]. It exits 1 when the first `ws_connect` fails and exits 0 on the exit option [claude_interactive.sh:ws_connect]. -- `research_messages.sh [-p|--port PORT] [-t|--timeout SEC] [-l|--log LOGFILE]` listens for TIMEOUT seconds (default 30), probes several method names, and prints counts of methods, ids, `selection_changed` notifications, and tool responses [research_messages.sh:send_test_message, TIMEOUT]. `-l` moves only the JSONL log. `claude_messages_pretty.txt` always goes to the current directory [research_messages.sh:PRETTY_LOG]. -- In `research_messages.sh`, `grep -c` exits the script with 1 under `set -e` when the log holds no `selection_changed` notification, or no reply with id `2` or `3`. The probes go over separate one-shot `websocat -n1` connections whose replies never reach the log, so the script normally stops before its last count line [research_messages.sh:selection_changed_count, tool_responses, send_test_message]. +- `mcp_test.sh [-p PORT] [-l DIR] [-t SEC] [-c] [-h] [tests]` runs the MCP tests `connect`, `toolslist`, `toolinvoke`, `methods`, and `selection`. The default is `all`, and a comma-separated list is accepted. The port defaults to `find_claude_lockfile` [mcp_test.sh:should_run_test, WEBSOCKET_PORT]. +- `claude_interactive.sh` (no arguments) is a menu REPL: get selection, list tools, open file, custom JSON-RPC message, initialize, connection info, listen, reconnect, and exit [claude_interactive.sh:show_menu, handle_custom_message, handle_listen]. +- `research_messages.sh [-p PORT] [-t SEC] [-l LOGFILE]` listens for TIMEOUT seconds (default 30), probes several method names, and prints counts of methods, ids, and `selection_changed` notifications [research_messages.sh:send_test_message, TIMEOUT]. - `websocat.sh` opens an interactive `websocat` session to `get_claude_ws_url` [websocat.sh:get_claude_ws_url]. -- `test_neovim_websocket.sh [-t|--timeout SEC] [-l|--listen SEC] [-d|--log-dir DIR] [-h|--help] [all|connect|toolslist|selection]` starts Neovim with the plugin, waits for a new lock file, and runs connection, `tools/list`, and selection-listen tests [test_neovim_websocket.sh:usage, run_tests, find_newest_lockfile]. It must run from the repo root, because its init runs `set rtp+=.`. It starts a full-screen `nvim` in the same terminal, not a headless one [test_neovim_websocket.sh:NVIM_INIT_FILE, NVIM_PID]. -- In `test_neovim_websocket.sh`, `((ELAPSED++))` returns status 1 when `ELAPSED` is 0, so `set -e` exits the script with 1 after the first one-second wait, unless the first check already found the new lock file. `((selection_count++))` ends the selection test the same way at its first notification [test_neovim_websocket.sh:ELAPSED, selection_count]. +- `test_neovim_websocket.sh [-t SEC] [-l SEC] [-d DIR] [-h] [all|connect|toolslist|selection]` starts Neovim with the plugin, waits for a new lock file, and runs connection, `tools/list`, and selection-listen tests [test_neovim_websocket.sh:usage, run_tests, find_newest_lockfile]. ### Test harnesses - `run_integration_tests_individually.sh` (no arguments) runs each `tests/integration/*_spec.lua` in its own headless Neovim under `timeout 30s`, skips `command_args_spec.lua`, and prints totals [run_integration_tests_individually.sh:run_test_file]. -- `test_opendiff.lua` is a raw-socket Lua client that sends `tools/call openDiff` with an edited `README.md` [test_opendiff.lua:test_opendiff]. You run it with plain `lua` from the project root. It needs the `socket` (luasocket) and `json` Lua modules. `require("json")` raises an error when `json` is missing, so the `cjson` and `dkjson` fallbacks never run [test_opendiff.lua:json]. It exits 1 when setup or the handshake fails, and 0 otherwise [test_opendiff.lua:os.exit]. -- `test_opendiff_simple.sh` sends the same `openDiff` call through `websocat` or `wscat` and reports exit 124 as "blocked" [test_opendiff_simple.sh:JSON_MESSAGE]. It must run from the project root, and it needs `jq` and `timeout`. It exits 0 even after it prints `❌ Connection failed` [test_opendiff_simple.sh:ABS_PATH, NEW_CONTENT]. -- `manual_test_helper.lua` is run with `:luafile` in a Neovim that has the plugin on its `runtimepath`. It calls the `claudecode.tools.open_diff` handler in a coroutine and reports whether it yields [manual_test_helper.lua:test_opendiff_directly]. +- `test_opendiff.lua` is a raw-socket Lua client that sends `tools/call openDiff` with an edited `README.md` [test_opendiff.lua:test_opendiff]. +- `test_opendiff_simple.sh` sends the same `openDiff` call through `websocat` or `wscat` and reports exit 124 as "blocked" [test_opendiff_simple.sh:JSON_MESSAGE]. +- `manual_test_helper.lua` is run with `:luafile`. It calls the `claudecode.tools.open_diff` handler in a coroutine and reports whether it yields [manual_test_helper.lua:test_opendiff_directly]. ### Issue reproductions The Lua repros run from the repository root with `nvim --headless -u NONE -l scripts/`, which needs Neovim 0.9 or later. They prepend the repo root to `runtimepath` [repro_issue_208.lua:rtp:prepend]. `repro_issue_70_probe.lua` is the exception: it prepends `/lua` to `package.path` instead [repro_issue_70_probe.lua:package.path]. `repro_issue_238.lua` runs with the `:luafile` command in its entry below, which also works on Neovim 0.8. -- `repro_issue_70.sh` (#70) checks whether the `claude` CLI connects back to the plugin when an HTTP proxy has no localhost exclusion. It prints `PASS issue #70 reproduced` (exit 0) or `INCONCLUSIVE` (exit 1). It drives `repro_issue_70_probe.lua` [repro_issue_70.sh:run_scenario]. It needs `nvim`, `claude`, `agent-tty`, and `jq`, and exits 3 when one is missing [repro_issue_70.sh:MISSING dependency]. -- `repro_issue_70_probe.lua [wait_ms]` is a helper, not a standalone repro. `wait_ms` defaults to 120000 [repro_issue_70_probe.lua:wait_ms]. It starts the real server with `terminal.provider = "none"` and writes `phase`/`connected` JSON to the status file. Exit 0 means a connection was seen, 2 means none, and 1 means no port [repro_issue_70_probe.lua:write_status]. +- `repro_issue_70.sh` (#70) checks whether the `claude` CLI connects back to the plugin when an HTTP proxy has no localhost exclusion. It prints `PASS issue #70 reproduced` (exit 0) or `INCONCLUSIVE` (exit 1). It drives `repro_issue_70_probe.lua` [repro_issue_70.sh:run_scenario]. +- `repro_issue_70_probe.lua ` is a helper, not a standalone repro. It starts the real server with `terminal.provider = "none"` and writes `phase`/`connected` JSON to the status file. Exit 0 means a connection was seen, 2 means none, and 1 means no port [repro_issue_70_probe.lua:write_status]. - `repro_issue_208.lua` (#208) checks for a leftover `[No Name]` tab or buffer after a diff resolves with `open_in_new_tab=true` and `provider=none`. Exit 0 means fixed, 1 means reproduced [repro_issue_208.lua:verdict]. -- `repro_issue_218.sh [--no-render-markdown]` (#218) checks for a Neovim crash when a new-file markdown diff is accepted in a new tab with render-markdown.nvim. `NVIM_BIN` selects the binary. Exit 0 means the expected outcome happened. Exit 1 also covers a missing `agent-tty` or `nvim` and a failed `agent-tty` session. It uses `jq` but does not check for it [repro_issue_218.sh:WANT_RM, NVIM_BIN, SID]. +- `repro_issue_218.sh [--no-render-markdown]` (#218) checks for a Neovim crash when a new-file markdown diff is accepted in a new tab with render-markdown.nvim. `NVIM_BIN` selects the binary. Exit 0 means the expected outcome happened [repro_issue_218.sh:WANT_RM, NVIM_BIN]. - `repro_issue_231.lua` (#231) checks that a terminal-only layout does not fail with "No suitable editor window found". Exit 0 means fixed, 1 means reproduced [repro_issue_231.lua:verdict]. -- `repro_issue_232.lua` (#232) checks for a `BufEnter` auto-insert autocmd on the terminal buffer. Env: `CLAUDECODE_PROVIDER=snacks|native`, `CLAUDECODE_AUTO_INSERT=true|false`. Exit 1 means reproduced, 0 means not reproduced or skipped, and 2 means a setup error [repro_issue_232.lua:reproduced]. `CLAUDECODE_PROVIDER` defaults to `snacks`. The script looks for snacks.nvim only at `~/.local/share/nvim/lazy/snacks.nvim` and `~/.local/share/*/lazy/snacks.nvim`, and prints `SKIP` (exit 0) when it cannot load it [repro_issue_232.lua:provider, candidates]. -- `repro_issue_238.lua` (#238) checks that `:q` on the proposed window rejects the diff. CI runs it with `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, which also works on Neovim 0.8. Exit 0 means fixed, 1 means reproduced, and 2 means the script raised an error before its verdict [repro_issue_238.lua:verdict]. It runs `open_in_new_tab=false`, `open_in_new_tab=true`, a split proposed window, and a new file. It finds the repo root with `:h:h` on its own path. `luafile` passes that path as you typed it, so run the CI command from the repo root, or pass an absolute path. Run from `scripts/` with a bare file name, it cannot load `claudecode.diff` and exits 2 [repro_issue_238.lua:run_scenario, run_multiwindow_scenario, run_newfile_scenario, repo_root]. -- `repro_issue_238.sh --file PATH [--lock-dir DIR] [--out FILE] [--hold SEC] [--tab NAME]` (#238) is an external MCP client. It opens one `openDiff` and holds the socket while you run `:q` in Neovim. It prints a `RESULT:` line, but its exit code does not reflect the result [repro_issue_238.sh:RESULT]. `-h` prints the usage header. The lock directory is `CLAUDE_LOCKFILE_DIR`, else `$CLAUDE_CONFIG_DIR/ide`, else `$HOME/.claude/ide`, and `WEBSOCAT` overrides the binary. Exit 2 means a bad or missing argument, and exit 1 means a missing tool, lock file, or token. The proposed contents replace the text `line three` in `--file`, so a file without that text gets a diff with no change [repro_issue_238.sh:LOCK_DIR, WEBSOCAT, contents]. +- `repro_issue_232.lua` (#232) checks for a `BufEnter` auto-insert autocmd on the terminal buffer. Env: `CLAUDECODE_PROVIDER=snacks|native`, `CLAUDECODE_AUTO_INSERT=true|false`. Exit 1 means reproduced, 0 means not reproduced or skipped, and 2 means a setup error [repro_issue_232.lua:reproduced]. +- `repro_issue_238.lua` (#238) checks that `:q` on the proposed window rejects the diff. CI runs it with `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, which also works on Neovim 0.8. Exit 0 means fixed, 1 means reproduced, and 2 means the script raised an error before its verdict [repro_issue_238.lua:verdict]. +- `repro_issue_238.sh --file PATH [--lock-dir DIR] [--out FILE] [--hold SEC] [--tab NAME]` (#238) is an external MCP client. It opens one `openDiff` and holds the socket while you run `:q` in Neovim. It prints a `RESULT:` line, but its exit code does not reflect the result [repro_issue_238.sh:RESULT]. - `repro_issue_246.lua` (#246) checks that single-line and fast visual selections reach Claude. Exit 0 means all checks pass, 1 means a check failed [repro_issue_246.lua:check]. -- `repro_issue_248.sh [-n|--num N] [--cleanup]` (#248) opens N diffs and disconnects without `close_tab`. It prints no verdict: run `:DiffState` in Neovim and read the counts [repro_issue_248.sh:NUM_DIFFS, CLEANUP]. `:DiffState` comes from the `fixtures/remote-diff` config. N is a cap: the script diffs the first N sorted `*.txt`, `*.lua`, and `*.md` files at the top level of `workspaceFolders[0]`. `-h` or `--help` prints the usage header, and an unknown argument exits 2. It exits 1 for a missing tool, a missing lock file, token, or workspace, or no file to diff. It exits 0 otherwise, because it ignores a `websocat` failure. It uses `mapfile`, so it needs bash 4 or later [repro_issue_248.sh:LOCK_DIR, FILES]. +- `repro_issue_248.sh [-n N] [--cleanup]` (#248) opens N diffs and disconnects without `close_tab`. It prints no verdict: run `:DiffState` in Neovim and read the counts [repro_issue_248.sh:NUM_DIFFS, CLEANUP]. - `repro_issue_262.lua` (#262) checks that a setup error after `:tabnew` does not strand a tab. Exit 0 means fixed, 1 means reproduced [repro_issue_262.lua:verdict]. -- `repro_issue_277.sh` (#277) runs three phases. Phases 1 and 3 check whether `closeAllDiffTabs` also closes diffview.nvim and user vimdiff windows. Phase 2 checks whether `openFile` runs `:edit` into a foreign diff window. `ISSUE277_ARTIFACTS` sets the artifact directory. The exit code covers all three phases: exit 0 means all three phases reproduced, 1 means a phase was not reproduced, 2 means a setup failure, and 3 means a missing tool [repro_issue_277.sh:PASS, FAIL, WEBSOCAT]. When `websocat --version` fails, the script searches `${MISE_DATA_DIR:-$HOME/.local/share/mise}/installs/websocat` for a working `websocat` binary. It exits 3 when that search finds no working binary [repro_issue_277.sh:WEBSOCAT]. -- `repro_issue_283.lua` (#283) checks the port probe. `REPRO283_MODE` selects `mechanism` (the default, also used for an unknown value), `port`, or `serve`. Serve mode reads `REPRO283_LABEL` and `REPRO283_WAIT_MS` (default 6000) [repro_issue_283.lua:mode, label, wait_ms]. Mechanism mode rebuilds the probe with raw `vim.loop` handles and calls no plugin code. Its exit 1 means libuv accepted a bind on a busy port and failed later at `listen`. That result does not change when the plugin is fixed [repro_issue_283.lua:reproduced]. -- `repro_issue_283.sh` (#283) runs `repro_issue_283.lua` in all three modes. `NVIM` overrides the binary, and the script exits 2 when it cannot find it. Exit 1 means all three parts reproduced. Exit 0 means "not fully reproduced", not "pass" [repro_issue_283.sh:VERDICT]. Part 3 looks for the text `Failed to listen on port .*EADDRINUSE`. The current `create_server` moves on to the next port and never returns that text, so on current code the script exits 0 [repro_issue_283.sh:E2E] [lua/claudecode/server/tcp.lua:create_server]. +- `repro_issue_277.sh` (#277) runs three phases. Phases 1 and 3 check whether `closeAllDiffTabs` also closes diffview.nvim and user vimdiff windows. Phase 2 checks whether `openFile` runs `:edit` into a foreign diff window. `ISSUE277_ARTIFACTS` sets the artifact directory. The exit code covers all three phases: exit 1 means a phase was not reproduced, 2 means a setup failure, and 3 means a missing tool [repro_issue_277.sh:FAIL, WEBSOCAT]. +- `repro_issue_283.lua` (#283) checks the port probe. `REPRO283_MODE` selects `mechanism` (default), `port`, or `serve`. In mechanism mode, exit 1 means reproduced [repro_issue_283.lua:mode]. +- `repro_issue_283.sh` (#283) runs `repro_issue_283.lua` in all three modes. `NVIM` overrides the binary. Exit 1 means all three parts reproduced. Exit 0 means "not fully reproduced", not "pass" [repro_issue_283.sh:VERDICT]. - `repro_issue_285.lua` (#285) checks that a path with a `$post` segment is accepted by `:ClaudeCodeAdd` and the `openFile` handler, and that `:ClaudeCodeAdd %` still works. Exit 0 means fixed, 1 means reproduced or a regression [repro_issue_285.lua:verdict]. -- `repro_issue_289.lua` (#289) checks that `ClaudeCodeSend` does not treat a Lua buffer as a file-tree buffer because its path contains `neo-tree`. It tests a `_neo-tree_.lua` path against a control path. It does not test an `NvimTree` path. Exit 1 means reproduced [repro_issue_289.lua:AFFECTED, affected_misrouted]. +- `repro_issue_289.lua` (#289) checks that `ClaudeCodeSend` does not treat a Lua buffer as a file-tree buffer because its path contains `neo-tree` or `NvimTree`. Exit 1 means reproduced [repro_issue_289.lua:affected_misrouted]. ### Release tooling -- `run-release-please.sh [args]` changes to the repo root and runs `node release-please-runner.js`. It uses `scripts/node_modules/release-please` when present, else the `release-please` on `PATH` through `NODE_PATH`. When it finds neither, it exits 1 with no message [run-release-please.sh:release_please_bin]. -- `node release-please-runner.js [--dry-run]` registers Communique-generated changelog notes and the `simple` release type, then creates releases and release PRs. Without `--dry-run` it prints `release-please: releases=[...] prs=[...]`. With `--dry-run` it prints JSON with `releases` and `pullRequests` (including `changelogPreview`) and writes nothing to GitHub. `--dry-run` still needs `GITHUB_TOKEN` and still reads from the GitHub API. Any error sets exit code 1 [release-please-runner.js:main]. -- `buildCommuniqueArgs` builds `communique generate HEAD [PREVIOUS_TAG] --concise --repo OWNER/REPO --output /notes.md [--model COMMUNIQUE_MODEL]` [release-please-runner.js:buildCommuniqueArgs]. +- `run-release-please.sh [args]` changes to the repo root and runs `node release-please-runner.js`. It uses `scripts/node_modules/release-please` when present, else the `release-please` on `PATH` through `NODE_PATH` [run-release-please.sh:release_please_bin]. +- `node release-please-runner.js [--dry-run]` registers Communique-generated changelog notes and the `simple` release type, then creates releases and release PRs. With `--dry-run` it prints JSON with `releases` and `pullRequests` (including `changelogPreview`) and writes nothing to GitHub [release-please-runner.js:main]. - Environment for `release-please-runner.js`: `GITHUB_TOKEN` and `GITHUB_REPOSITORY` (required), `RELEASE_PLEASE_TARGET_BRANCH`, `GITHUB_OUTPUT`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `COMMUNIQUE_MODEL`, and `PRETTIER_BIN` [release-please-runner.js:requireEnv, assertLlmCredentials, resolvePrettierBin]. - `release-please-runner.js` exports `CommuniqueSimpleStrategy`, `UnreleasedAwareChangelog`, `assertLlmCredentials`, `buildCommuniqueArgs`, `createCommuniqueChangelogNotes`, `findLineOutsideFences`, `formatChangelogMarkdown`, `formatChangelogSection`, `formatPullRequestOutputs`, `formatReleaseOutputs`, `normalizeCommuniqueBody`, `resolvePrettierBin`, `releasePleaseNotesAreEmpty`, and `todayIsoDate` [release-please-runner.js:module.exports]. - GitHub Actions outputs: `releases_created`, `release_tags`, `prs_created`, `pr_branches`, and `pr_metadata` [release-please-runner.js:formatReleaseOutputs, formatPullRequestOutputs]. -- `package.json` is the private package `claudecode.nvim-release-tools`. It pins `prettier` 3.8.4 and `release-please` 17.9.0 and defines the `test:release-please-runner` script [package.json:scripts]. `package-lock.json` locks the full dependency tree of `package.json` [package-lock.json:packages]. Both CI workflows install from it with `npm ci --prefix scripts` [.github/workflows/test.yml:npm ci] [.github/workflows/release-please.yaml:npm ci]. -- `test-release-please-runner.js` takes no arguments and prints nothing on success. `npm run` prints only its own two-line header. It needs `git`, `prettier`, the `release-please` package, a real `communique` binary on `PATH` (for example under `mise exec --`), and the repo's `communique.toml`. It runs `communique` in a temp directory outside the repo. There a mise shim for the `communique` that the repo's `mise.toml` pins prints `communique is not a valid shim`, and the test exits 1. Its Communique check asserts that one release-notes submission is between 4096 and 8192 tokens and gets through in one request [test-release-please-runner.js:testCommuniqueConfigFitsReleaseNotes]. +- `package.json` is the private package `claudecode.nvim-release-tools`. It pins `prettier` 3.8.4 and `release-please` 17.9.0 and defines the `test:release-please-runner` script [package.json:scripts]. ## Invariants -- Most clients find the server port from the lock file name (`PORT.lock`) in a lock directory. `mcp_test.sh` and `research_messages.sh` skip that lookup when you pass `-p`. `send_claude_message`, the first `ws_connect` in `claude_interactive.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` send the lock file's `authToken` in the `x-claude-code-ide-authorization` header. `repro_issue_277.sh` sends the same header, but reads the port and the token from the plugin state through nvim RPC [lib_claude.sh:get_claude_auth_token, send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:TOKEN] [repro_issue_248.sh:TOKEN]. `repro_issue_238.sh`, `repro_issue_248.sh`, and `repro_issue_277.sh` put the URL before `--header`, which websocat 1.14.1 accepts. `send_claude_message` and `ws_connect` put it after, so on websocat 1.14.1 they exit with `No URL specified` before they connect [repro_issue_238.sh:WEBSOCAT] [repro_issue_277.sh:WEBSOCAT] [lib_claude.sh:send_claude_message] [lib_ws_persistent.sh:ws_connect]. `mcp_test.sh`, `test_neovim_websocket.sh`, `research_messages.sh`, `websocat.sh`, `test_opendiff.lua`, and `test_opendiff_simple.sh` send no token. The plugin server rejects their upgrade request when it has an auth token, which `start` always sets, so these six clients cannot talk to the current plugin. `mcp_test.sh` exits 1 at its connect step [mcp_test.sh:ws_connect, test_connection] [websocat.sh:websocat] [lua/claudecode/server/handshake.lua:validate_upgrade_request]. -- `lib_claude.sh` checks only `websocat` and `jq`. Sourcing it runs `check_required_tools`, which returns 1 when one is missing but does not call `exit`. A caller that sourced it under `set -e` (`mcp_test.sh`, `research_messages.sh`, `test_neovim_websocket.sh`) therefore exits. `send_claude_message` also needs `timeout` [lib_claude.sh:check_required_tools, send_claude_message]. +- Clients find the server port from the lock file name (`PORT.lock`) in `CLAUDE_LOCKFILE_DIR`. `send_claude_message`, the first `ws_connect` in `claude_interactive.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` send the lock file's `authToken` in the `x-claude-code-ide-authorization` header. `repro_issue_277.sh` sends the same header, but reads the port and the token from the plugin state through nvim RPC [lib_claude.sh:get_claude_auth_token, send_claude_message] [lib_ws_persistent.sh:ws_connect]. `mcp_test.sh`, `test_neovim_websocket.sh`, `research_messages.sh`, `websocat.sh`, `test_opendiff.lua`, and `test_opendiff_simple.sh` send no token. The plugin server rejects their upgrade request when it has an auth token, which `start` always sets [mcp_test.sh:ws_connect] [websocat.sh:websocat]. +- `lib_claude.sh` checks only `websocat` and `jq`. Sourcing it runs `check_required_tools`, which returns 1 when one is missing but does not call `exit`. A caller that sourced it under `set -e` (`mcp_test.sh`, `research_messages.sh`) therefore exits. `send_claude_message` also needs `timeout` [lib_claude.sh:check_required_tools, send_claude_message]. - `send_claude_message` builds a single-quoted `bash -c` string, so a single quote in the message or token breaks the command [lib_claude.sh:send_claude_message]. - `lib_ws_persistent.sh` assumes single-line compact JSON. `ws_request` needs a non-empty `.id`, and `ws_start_listener` matches the exact text `"method":"NAME"` [lib_ws_persistent.sh:ws_request, ws_start_listener]. - `ws_request` reads only response bytes written after the request starts. On timeout it prints the last received line when any bytes arrived, else a JSON-RPC error with code `-32000`. It still returns 0 [lib_ws_persistent.sh:ws_request]. - `ws_connect` disconnects an existing connection with the same `CONN_ID` first, and returns 1 unless the process is alive after 0.5 s [lib_ws_persistent.sh:ws_connect]. - `claude_interactive.sh` exits 1 when `claude_is_running` fails [claude_interactive.sh:claude_is_running]. `claude_running`, `claude_get_selection`, `claude_open_file`, `claude_list_tools`, and `claude_send` return 1 when it fails. `claude_port`, `claude_ws_url`, `claude_interactive`, and `claude_help` do not call it [claude_shell_helpers.sh:claude_send]. -- `claude_interactive.sh` `handle_reconnect` calls `ws_connect` without the auth token, so the server rejects the reconnect [claude_interactive.sh:handle_reconnect] [lua/claudecode/server/handshake.lua:validate_upgrade_request]. -- `run_integration_tests_individually.sh` must run from the repo root. It adds a file to `FAILED_FILES` when it parses failures or errors, or when nvim exits nonzero with a code other than 124 or 143 (timeouts). This includes an nvim crash that prints no `Failed :` line. A spec that times out without a parsed failure or error counts as passed. The script exits 1 when `FAILED_FILES` is non-empty, else 0 [run_integration_tests_individually.sh:FAILED_FILES, EXIT_CODE]. -- `test_opendiff.lua`, `test_opendiff_simple.sh`, `repro_issue_238.sh`, and `repro_issue_248.sh` take the first lock file found, not the newest [test_opendiff.lua:discover_port] [test_opendiff_simple.sh:LOCK_FILE] [repro_issue_238.sh:LOCK_FILE] [repro_issue_248.sh:LOCK_FILE]. -- `repro_issue_238.sh` and `repro_issue_248.sh` read `CLAUDE_LOCKFILE_DIR` from the environment instead of sourcing `lib_claude.sh`. `repro_issue_248.sh` falls back to `$HOME/.claude/ide` and ignores `CLAUDE_CONFIG_DIR` [repro_issue_238.sh:LOCK_DIR] [repro_issue_248.sh:LOCK_DIR]. +- `claude_interactive.sh` `handle_reconnect` calls `ws_connect` without the auth token [claude_interactive.sh:handle_reconnect]. +- `run_integration_tests_individually.sh` must run from the repo root. It adds a file to `FAILED_FILES` when it parses failures or errors, or when nvim exits nonzero with a code other than 124 or 143 (timeouts). This includes an nvim crash that prints no `Failed :` line. It exits 1 when `FAILED_FILES` is non-empty or the parsed failure or error totals are non-zero, else 0 [run_integration_tests_individually.sh:FAILED_FILES, TOTAL_FAILED]. +- `test_opendiff.lua`, `test_opendiff_simple.sh`, and `repro_issue_238.sh` take the first lock file found, not the newest [test_opendiff.lua:discover_port] [test_opendiff_simple.sh:LOCK_FILE] [repro_issue_238.sh:LOCK_FILE]. - `test_opendiff.lua` and `test_opendiff_simple.sh` hardcode `~/.claude/ide` and ignore `CLAUDE_CONFIG_DIR` [test_opendiff.lua:discover_port] [test_opendiff_simple.sh:LOCK_DIR]. - `manual_test_helper.lua` hardcodes an absolute path to `README.md` in one developer's checkout, and returns early when that file is not readable [manual_test_helper.lua:readme_path]. - `repro_issue_70_probe.lua` resolves the lock directory from `CLAUDE_CONFIG_DIR/ide`, else `$HOME/.claude/ide` [repro_issue_70_probe.lua:config_dir]. -- The Lua repros call private plugin functions, for example `diff._setup_blocking_diff`, `diff._cleanup_all_active_diffs`, `selection._cancel_demotion_timer`, and `claudecode._create_commands`. A rename of one of these breaks the repro that calls it [repro_issue_208.lua:_setup_blocking_diff] [repro_issue_246.lua:_cancel_demotion_timer] [repro_issue_285.lua:_create_commands]. -- Some Lua repros exit 0 ("fixed") when the run did not prove a fix. `repro_issue_208.lua` prints a setup error but does not count it as a leak. `repro_issue_231.lua` reports fixed when scenario A fails with any error other than "No suitable editor window found". A failed control scenario only prints a warning in `repro_issue_262.lua` and `repro_issue_285.lua`. In `repro_issue_289.lua` it forces exit 0 ("fixed"), even when the affected buffer was misrouted [repro_issue_208.lua:setup_ok] [repro_issue_231.lua:bug_reproduced] [repro_issue_262.lua:control] [repro_issue_285.lua:WARNING, regressed] [repro_issue_289.lua:control_ok]. - `release-please-runner.js` requires `GITHUB_TOKEN` and `GITHUB_REPOSITORY` in `owner/repo` form [release-please-runner.js:requireEnv, main]. - `assertLlmCredentials` throws when both `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are empty, or when `ANTHROPIC_API_KEY` is empty and `COMMUNIQUE_MODEL` is unset [release-please-runner.js:assertLlmCredentials]. - When default release-please notes have one line or fewer, the runner returns them without calling Communique [release-please-runner.js:releasePleaseNotesAreEmpty]. -- `UnreleasedAwareChangelog` keeps the first `## [Unreleased]` or `### [Unreleased]` heading and inserts the new entry below it. It drops the draft lines under that heading, up to the next `#` or `##` heading. When the heading is missing, it adds `## [Unreleased]` below the `# ` title. Headings inside code fences are ignored [release-please-runner.js:UnreleasedAwareChangelog, findLineOutsideFences, NEXT_SECTION_PATTERN]. -- release-please reads its config and manifest from the remote target branch (`RELEASE_PLEASE_TARGET_BRANCH`, else the default branch), not from the local checkout. Local edits to `release-please-config.json` take effect only after you push them [release-please-runner.js:main]. -- The runner imports internal modules from `release-please/build/src/`, so it depends on the file layout of the pinned `release-please` version [release-please-runner.js:Simple, Changelog]. +- `UnreleasedAwareChangelog` keeps the top `Unreleased` section and inserts new entries below it. Headings inside code fences are ignored [release-please-runner.js:UnreleasedAwareChangelog, findLineOutsideFences]. - `formatChangelogSection` asserts a non-empty version and a `YYYY-MM-DD` date, and writes `## [VERSION] - DATE` without a `v` prefix [release-please-runner.js:formatChangelogSection]. - `resolvePrettierBin` uses `PRETTIER_BIN`, then `scripts/node_modules/.bin/prettier`, then `prettier` on `PATH` [release-please-runner.js:resolvePrettierBin]. - `main` creates releases before release PRs [release-please-runner.js:main]. ## Side Effects -- Sourcing `lib_ws_persistent.sh` creates `${TMPDIR:-/tmp}/ws_logs`. `ws_connect` writes `pid`, `log.txt`, `request.json`, and `response.json` under `ws_logs/CONN_ID/` and starts a background `tail -f | websocat | tee` pipeline [lib_ws_persistent.sh:WS_LOG_DIR, ws_connect]. `ws_request` appends every request to `ws_logs/CONN_ID/debug.log`, which nothing truncates. No function deletes `ws_logs/CONN_ID/` [lib_ws_persistent.sh:ws_request]. -- `ws_disconnect` sends SIGTERM to the saved PID only. That PID is `$!` of the pipeline, which is the `tee` process, not `tail -f` or `websocat`. After each `mcp_test.sh` run, also one whose connect step fails, its `tail -f` and the subshell that waits for the pipeline keep running. After a `set -e` exit in `toolinvoke`, `methods`, or `all`, its `websocat` keeps running too [mcp_test.sh:ws_connect]. It sends SIGKILL only when the SIGTERM send fails, not when the process ignores SIGTERM. `ws_setup_trap` runs `ws_cleanup_all` on EXIT, INT, and TERM, and sourcing the library installs that trap. The trap does not call `exit`, so after Ctrl+C the caller closes its connections and keeps running [lib_ws_persistent.sh:ws_connect, ws_disconnect, ws_setup_trap]. -- `ws_start_listener` starts a background polling loop (0.5 s) and writes `listener.pid` [lib_ws_persistent.sh:ws_start_listener]. The loop has no exit condition. Only `ws_stop_listener` kills it, and it reads `listener.pid`, which each `ws_start_listener` call overwrites. `ws_disconnect`, `ws_cleanup_all`, and the trap leave listeners running. `mcp_test.sh` starts up to five listeners and stops at most the last one, so the others keep polling after the script exits [lib_ws_persistent.sh:ws_stop_listener, ws_cleanup_all] [mcp_test.sh:ws_start_listener, ws_stop_listener]. -- CAUTION: Do not run the token-sending clients on a shared machine, and do not share the output of `claude_interactive.sh` option 6. The scripts pass the auth token on the `websocat` command line (`--header`), where other users can read it with `ps`. Option 6 prints the whole lock file, including `authToken` [lib_claude.sh:send_claude_message] [lib_ws_persistent.sh:ws_connect] [repro_issue_238.sh:WEBSOCAT] [repro_issue_248.sh:TOKEN] [repro_issue_277.sh:TOKEN] [claude_interactive.sh:handle_connection_info]. -- Log directories are created relative to the current directory: `mcp_test_logs` [mcp_test.sh:LOG_DIR], `mcp_interactive_logs` [claude_interactive.sh:CLAUDE_LOG_DIR], `test_neovim_ws_logs` [test_neovim_websocket.sh:LOG_DIR], and `claude_messages.jsonl` / `claude_messages_pretty.txt` [research_messages.sh:LOG_FILE]. Each run truncates its log files, except `claude_interactive.sh`, which only creates its log directory. `claude_interactive.sh` keeps its connection files in `${TMPDIR:-/tmp}/ws_logs/claude_interactive/` [claude_interactive.sh:CONN_ID]. +- Sourcing `lib_ws_persistent.sh` creates `${TMPDIR:-/tmp}/ws_logs`. `ws_connect` writes `pid`, `log.txt`, `request.json`, and `response.json` under `ws_logs/CONN_ID/` and starts a background `tail -f | websocat | tee` pipeline [lib_ws_persistent.sh:WS_LOG_DIR, ws_connect]. +- `ws_disconnect` sends SIGTERM to the saved PID only. It sends SIGKILL only when the SIGTERM send fails, not when the process ignores SIGTERM. `ws_setup_trap` runs `ws_cleanup_all` on EXIT, INT, and TERM, and sourcing the library installs that trap [lib_ws_persistent.sh:ws_disconnect, ws_setup_trap]. +- `ws_start_listener` starts a background polling loop (0.5 s) and writes `listener.pid` [lib_ws_persistent.sh:ws_start_listener]. +- Log directories are created relative to the current directory: `mcp_test_logs` [mcp_test.sh:LOG_DIR], `mcp_interactive_logs` [claude_interactive.sh:CLAUDE_LOG_DIR], `test_neovim_ws_logs` [test_neovim_websocket.sh:LOG_DIR], and `claude_messages.jsonl` / `claude_messages_pretty.txt` [research_messages.sh:LOG_FILE]. Each run truncates its log files, except `claude_interactive.sh`, which only creates its log directory. - Sourcing `claude_shell_helpers.sh` sets `CLAUDE_LOG_DIR=$HOME/.claude/logs` and creates that directory [claude_shell_helpers.sh:CLAUDE_LOG_DIR]. -- The WebSocket clients connect to the plugin server on `127.0.0.1` or `localhost` at the lock-file port [mcp_test.sh:test_connection] [lib_claude.sh:get_claude_ws_url, send_claude_message]. +- The WebSocket clients connect to the plugin server on `127.0.0.1` or `localhost` at the lock-file port [mcp_test.sh:test_connection] [lib_claude.sh:send_claude_message]. - `open_file` and `claude_open_file` make the connected Neovim open a file [lib_claude.sh:open_file] [claude_shell_helpers.sh:claude_open_file]. - `test_neovim_websocket.sh` writes `/tmp/sample_file.lua` and `/tmp/test_claudecode_init.lua`, starts `nvim` in the background, and on exit kills it and deletes those fixed paths and `/tmp/test_claudecode_buffer.txt` [test_neovim_websocket.sh:SAMPLE_FILE, NVIM_BUFFER_FILE, cleanup]. -- `run_integration_tests_individually.sh` creates one `mktemp` file per test and deletes it [run_integration_tests_individually.sh:temp_output]. Each nvim it starts loads `tests/minimal_init.lua`, which runs `git clone` of plenary.nvim into `stdpath("data")` when plenary is missing [run_integration_tests_individually.sh:run_test_file]. +- `run_integration_tests_individually.sh` creates one `mktemp` file per test and deletes it [run_integration_tests_individually.sh:temp_output]. - `test_opendiff.lua` can loop forever when no response arrives after its 30 s receive timeout [test_opendiff.lua:sock:receive]. - The Lua repros create temp files or directories, stub plugin functions (for example `logger`, `vim.notify`, `diff._create_diff_view_from_window`), and rearrange tabs and windows inside their own headless Neovim [repro_issue_285.lua:logger] [repro_issue_289.lua:vim.notify] [repro_issue_262.lua:_create_diff_view_from_window]. - `repro_issue_231.lua` deletes `/__issue_231_repro__.md` [repro_issue_231.lua:try_open_diff]. -- `repro_issue_70.sh` and `repro_issue_277.sh` create `agent-tty` sessions and temp directories, and remove both on exit. They create the temp directory before they install the `EXIT` trap, so an exit 3 for a missing tool leaves that directory behind. `repro_issue_70.sh` launches the real `claude` CLI and sets proxy variables to `http://127.0.0.1:1` in its scenarios [repro_issue_70.sh:run_scenario] [repro_issue_277.sh:cleanup]. Its cleanup runs `pkill -f "repro_issue_70_probe.lua $REPO_ROOT"`, which also kills the probe of another run in the same checkout [repro_issue_70.sh:cleanup]. -- `repro_issue_70_probe.lua` starts the real server with `auto_start = true`, so the plugin writes a real `PORT.lock` into the user's lock directory. Other Claude CLIs can see that lock file until `claudecode.stop()` removes it at the end [repro_issue_70_probe.lua:claudecode.setup, claudecode.stop]. +- `repro_issue_70.sh` and `repro_issue_277.sh` create `agent-tty` sessions and temp directories, and remove both on exit. They create the temp directory before they install the `EXIT` trap, so an exit 3 for a missing tool leaves that directory behind. `repro_issue_70.sh` launches the real `claude` CLI and sets proxy variables to `http://127.0.0.1:1` in its scenarios [repro_issue_70.sh:run_scenario] [repro_issue_277.sh:cleanup]. - `repro_issue_277.sh` clones diffview.nvim into `${XDG_DATA_HOME:-$HOME/.local/share}/issue-277/diffview.nvim` when it is absent. It copies artifacts to `${TMPDIR:-/tmp}/issue277-artifacts-latest` when `ISSUE277_ARTIFACTS` is unset [repro_issue_277.sh:DIFFVIEW_DIR]. -- `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. The fixture clones lazy.nvim and the plugins into `stdpath("data")/lazy` of `NVIM_APPNAME` `issue-218`, or `issue-218norm` with `--no-render-markdown`, outside the temp directory. The default run sets `XDG_CONFIG_HOME` to the repo's `fixtures/` directory, so `Lazy! sync` also rewrites the tracked file `fixtures/issue-218/lazy-lock.json`. The `--no-render-markdown` run writes its lock file in the temp directory. The script does not remove its temp directory [repro_issue_218.sh:APPNAME, CONFHOME, cleanup]. -- When `--out` is omitted, `repro_issue_238.sh` writes the frames to a `mktemp` file `repro238.frames.*` and does not delete it. Its `EXIT` trap removes only the request file [repro_issue_238.sh:OUT, REQ]. +- `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. It does not remove its temp directory [repro_issue_218.sh:cleanup]. - Without `--cleanup`, `repro_issue_248.sh` disconnects without `close_tab`. The server then rejects and closes that client's pending diffs, so `:DiffState` shows `active_diffs=0`. With `--cleanup` it calls `closeAllDiffTabs` first [repro_issue_248.sh:CLEANUP]. - `repro_issue_283.lua` binds TCP sockets on `127.0.0.1`. `repro_issue_283.sh` starts a background Neovim and kills it on exit [repro_issue_283.lua:listener:bind] [repro_issue_283.sh:cleanup]. - `release-please-runner.js` without `--dry-run` creates GitHub tags, releases, and pull requests through the GitHub API [release-please-runner.js:main]. -- `release-please-runner.js` spawns `communique generate` with the full environment (API keys included) in the current directory, which is the repo root when `run-release-please.sh` starts it. Communique writes `notes.md` to a temp directory `communique-notes-*`, which the runner removes afterward. It runs `prettier` on each changelog update and appends outputs to `GITHUB_OUTPUT`, or prints them when that variable is unset [release-please-runner.js:createCommuniqueChangelogNotes, formatChangelogMarkdown, writeGithubOutputs]. +- `release-please-runner.js` spawns `communique generate` with the full environment (API keys included) in a temp directory `communique-notes-*` that it removes afterward. It runs `prettier` on each changelog update and appends outputs to `GITHUB_OUTPUT`, or prints them when that variable is unset [release-please-runner.js:createCommuniqueChangelogNotes, formatChangelogMarkdown, writeGithubOutputs]. - `test-release-please-runner.js` starts an HTTP server on a random `127.0.0.1` port and runs `git` and `communique` in a temp directory `communique-config-*` that it removes afterward. It passes `communique` only a placeholder API key [test-release-please-runner.js:testCommuniqueConfigFitsReleaseNotes]. ## Verification -- Run: `mise exec -- npm --prefix scripts run test:release-please-runner` (needs `npm --prefix scripts ci` first). `mise exec --` puts the real `communique` binary that `mise install` provides on `PATH`. The mise shim fails, as `test-release-please-runner.js` above describes. +- Run: `npm --prefix scripts run test:release-please-runner` (needs `npm --prefix scripts ci` first, and `communique` on `PATH`, which `mise install` provides). - Run: `mise run format-check`. treefmt formats and lints `scripts/` with `shfmt` and `shellcheck` (`*.sh`), `stylua` (`*.lua`), and `prettier` (`*.js`, `*.json`, `*.md`). - `mise run check` runs `luacheck` on `lua/` and `tests/` only, so it does not cover `scripts/`. From 63fff01f71945041130ea4113b883c5d1facd465 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 23:57:58 +0000 Subject: [PATCH 04/13] docs(server): correct README claims that the code at main contradicts Checked each line that the branch changes against lua/claudecode/server/*.lua at origin/main. on_connect only logs since #454, a tools/call without a name answers -32602 since #497, get_client_info has connected_at since #480, and last_pong no longer feeds the keepalive since #455. --- lua/claudecode/server/README.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 9fe009d0a..df62bc1e7 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -37,9 +37,9 @@ Layers, top to bottom: - `M.create_server(config, callbacks, auth_token) -> server|nil, err`: tries ports in the configured range until one binds and listens [server/tcp.lua:M.create_server] - `M.find_available_port(min_port, max_port)`: probe-only port check. Nothing in `lua/` calls it [server/tcp.lua:M.find_available_port] - `M.send_to_client`, `M.broadcast`, `M.get_client_count`, and `M.get_clients_info` [server/tcp.lua:M.send_to_client] -- `M.stop_server(server)`: closes the check and idle handles of a stall confirmation that has not run, sends a Close frame with code 1001 "Server shutting down" to clients that finished the handshake +- `M.stop_server(server)`: closes the check and idle handles of a keepalive or stall judgement that has not run, sends a Close frame with code 1001 "Server shutting down" to clients that finished the handshake or whose 101 reply is still being written, closes the TCP handle of the others, empties `server.clients`, then closes the listener. It calls no `on_disconnect`. It skips a client that is already - `closing` or `closed`, or whose socket is already closing: that client gets no Close frame. A client that sent its own Close frame is `closing` until its scheduled `on_close` runs. If `stop_server` + `closing` or `closed`, or whose socket is already closing: that client gets no Close frame. A client that sent its own Close frame is `closing`, and it stays in `server.clients` until its scheduled `on_close` runs. If `stop_server` runs first, that `on_close`, or a later EOF, closes the client's socket in `_disconnect_client` [server/tcp.lua:M.stop_server] [server/client.lua:M.close_client] [server/client.lua:M.process_data] [server/tcp.lua:M._disconnect_client] - `M.start_ping_timer(server, interval)`: default interval is 30000 ms. It sets `server.keepalive_timeout` to `interval * 2`, and clients accepted from then on carry it [server/tcp.lua:M.start_ping_timer] @@ -48,8 +48,8 @@ Layers, top to bottom: ### Connection, handshake, and frames - `client.create_client(tcp_handle, keepalive_timeout?)`, `process_data(...)`, `send_message`, `send_ping`, `close_client`, `abandon_close_frame`, `sample_write_progress(client)`, `is_client_reading(client)`, and `get_client_info` [server/client.lua:M.process_data] -- Client states: `connecting`, then `upgrading` (101 reply being written), then `connected`. `close_client` moves an `upgrading` or `connected` client to `closing`, then to `closed` when its Close frame write completes. It moves a `connecting` client, or one whose socket is already closing, straight to `closed`. A rejected handshake and a received Close frame set `closing`, and the state never becomes `closed` [server/client.lua:M.create_client] [server/client.lua:M.close_client] [server/client.lua:M.process_data] -- `client.get_client_info(client) -> { id, state, handshake_complete, buffer_size, last_ping, last_pong }`. `buffer_size` counts the unparsed bytes and the reads of an unfinished frame. `tcp.get_clients_info` returns these tables [server/client.lua:M.get_client_info] +- Client states: `connecting`, then `upgrading` (101 reply being written), then `connected`. `close_client` moves an `upgrading` or `connected` client to `closing`, then to `closed` when the write callback of its Close frame runs, or when the Close frame deadline closes the socket. It moves a `connecting` client, or one whose socket is already closing, straight to `closed`. A rejected handshake and a received Close frame set `closing`, and the state never becomes `closed`. An EOF or a read error changes no state [server/client.lua:M.create_client] [server/client.lua:M.close_client] [server/client.lua:M.process_data] +- `client.get_client_info(client) -> { id, state, handshake_complete, buffer_size, last_ping, last_pong, connected_at }`. `connected_at` is the loop time when the handshake completed, and `nil` before. `buffer_size` counts the unparsed bytes and the reads of an unfinished frame. `tcp.get_clients_info` returns these tables [server/client.lua:M.get_client_info] - `client.send_message` calls its callback with "Client not connected" and writes nothing unless the state is `connected`. `send_ping` does nothing unless the state is `connected` [server/client.lua:M.send_message] [server/client.lua:M.send_ping] - `handshake.process_handshake(request, expected_auth_token) -> ok, response, headers?` [server/handshake.lua:M.process_handshake] - `handshake.validate_upgrade_request`, `create_handshake_response`, `parse_request_line`, `is_websocket_endpoint`, `create_error_response`, and `extract_http_request` [server/handshake.lua:M.validate_upgrade_request] @@ -75,7 +75,7 @@ Layers, top to bottom: - `M.start` refuses with "Server already running" when `M.state.server` is set. `M.stop` returns "Server not running" when it is not set. `send`, `send_response`, and `broadcast` return `false` without a server [server/init.lua:M.start] - While the server runs, `send` and `send_response` return `true` even for an unknown or unconnected client: they pass no callback, so `tcp.send_to_client` drops the message silently [server/init.lua:M.send_response] [server/tcp.lua:M.send_to_client] - A failed `M.start` leaves `M.state.auth_token`, the handlers, and `tools.setup(M)` in place, with `M.state.server` still `nil`, so a retry is allowed. `M.start` returns `true` even when the ping timer could not be created. Only `on_error` logs that failure [server/init.lua:M.start] [server/tcp.lua:M.start_ping_timer] -- `on_connect` fires when the TCP connection is accepted, before the WebSocket handshake and the auth check. So `process_mention_queue(true)` is scheduled for every TCP connection, also one the handshake rejects, and `client_count` includes clients that have not finished the handshake [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] +- `on_connect` fires when the TCP connection is accepted, before the WebSocket handshake and the auth check. It only logs. `process_mention_queue(true)` waits for `on_open`, after the handshake. `client_count` includes clients that have not finished the handshake [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] - When an auth token is set, the upgrade request must carry `x-claude-code-ide-authorization`. The header length must be 10..500. The token is compared with `utils.constant_time_compare` [server/handshake.lua:M.validate_upgrade_request] - The upgrade request must have `Upgrade: websocket`, `Connection` containing `upgrade`, `Sec-WebSocket-Version: 13`, and a 24-character `Sec-WebSocket-Key` [server/handshake.lua:M.validate_upgrade_request] - A non-GET or non-HTTP/1.1 request gets 404. A failed validation gets 400 "Bad WebSocket upgrade request: ". A failed accept key gets 500 "Failed to generate WebSocket handshake response" [server/handshake.lua:M.process_handshake] @@ -86,22 +86,21 @@ Layers, top to bottom: - A whole message runs through `on_message` only if, when the main loop gets to it, the client is still `connected` and its socket is open. A request from a client that hung up or sent its Close frame meanwhile does not run [server/client.lua:M.process_data] - A text message can arrive in fragments (a TEXT frame with FIN clear, then CONTINUATION frames up to one with FIN set), with control frames between them. `process_data` joins the fragments and calls `on_message` once with the whole text. Only the whole message must be well-formed UTF-8, and one that is not closes with 1007. A binary message closes with 1003, and nothing runs it. A CONTINUATION frame with no message to continue, or a TEXT or BINARY frame inside a fragmented message, closes with 1002. A message whose fragments add up to more than `frame.MAX_PAYLOAD_LENGTH` (100 MiB) closes with 1009. Each Close frame carries the RFC 6455 name of its status as the reason: `Protocol error` (1002), `Unsupported data` (1003), `Invalid frame payload data` (1007) or `Message too big` (1009) [server/client.lua:add_data_frame] [server/client.lua:M.process_data] - `process_data` parses every frame of one read from an offset into its buffer and keeps the unparsed rest once at the end, so many small frames in one read cost linear time. The reads of a frame longer than one read wait in `client.pending` until the whole frame arrived, and are joined once, so a large frame costs linear time too [server/client.lua:M.process_data] -- `parse_frame` rejects set RSV bits, invalid opcodes, fragmented control frames, and control payloads over 125 bytes with 1002. It rejects a payload over `MAX_PAYLOAD_LENGTH` (100 MiB) with 1009, and a close reason that is not well-formed UTF-8 with 1007. It rejects a Close payload of 1 byte with 1002. It names the broken rule (for example `Reserved opcode 0x3`). `process_data` passes that name to `on_error`, so the client's disconnect reason is `Client error: ` with code 1006, while the Close frame carries the RFC 6455 status name. It does not check the UTF-8 of a text frame, because a fragment can end inside a code point. `is_valid_utf8` uses `utils.utf8_sequence_length`, the decoder behind the outbound `utils.valid_utf8`, so it also rejects overlong forms, UTF-16 surrogates, code points above U+10FFFF, and the bytes C0, C1 and F5 to FF [server/frame.lua:M.parse_frame] [server/utils.lua:M.is_valid_utf8] +- `parse_frame` rejects set RSV bits, invalid opcodes, fragmented control frames, and control payloads over 125 bytes with 1002. It rejects a payload over `MAX_PAYLOAD_LENGTH` (100 MiB) with 1009, and a close reason that is not well-formed UTF-8 with 1007. It rejects a Close payload of 1 byte with 1002. It names the broken rule (for example `Reserved opcode 0x3`). `process_data` passes that name to `on_error`, so the client's disconnect reason is `Client error: ` with code 1006, while the Close frame carries the RFC 6455 status name. `parse_frame` does not check the UTF-8 of a text frame, because a fragment can end inside a code point. `is_valid_utf8` uses `utils.utf8_sequence_length`, the decoder behind the outbound `utils.valid_utf8`, so it also rejects overlong forms, UTF-16 surrogates, code points above U+10FFFF, and the bytes C0, C1 and F5 to FF [server/frame.lua:M.parse_frame] [server/utils.lua:M.is_valid_utf8] - `parse_frame` does not reject unmasked client frames, non-minimal length encodings, or any Close status code. Server frames are unmasked by default (`masked=false`) [server/frame.lua:M.parse_frame] [server/frame.lua:M.create_frame] - `create_frame` and `create_close_frame` do not check the payload size, so a Close reason over 123 bytes makes a control frame that breaks RFC 6455 [server/frame.lua:M.create_close_frame] -- A received Close frame with a payload under 2 bytes counts as code 1000 with an empty reason. A `connected` client gets its code and reason echoed back, the state becomes `closing`, `on_close` is scheduled, and later bytes are dropped. A PING gets a PONG with the same payload. Any PONG sets `last_pong` [server/client.lua:M.process_data] +- A received Close frame with an empty payload counts as code 1000 with an empty reason. A `connected` client gets its code and reason echoed back, the state becomes `closing`, `on_close` is scheduled, and later bytes are dropped. A PING gets a PONG with the same payload. Any PONG sets `last_pong`, which only `get_client_info` reads. The keepalive uses `ping_unanswered_since`, which any bytes clear [server/client.lua:M.process_data] [server/tcp.lua:M._handle_new_connection] - Every text frame the server sends is valid UTF-8. `send_message` replaces each ill-formed sequence with U+FFFD, so a buffer name or path with invalid bytes reaches the client as U+FFFD and does not drop the connection [server/client.lua:M.send_message] - JSON-RPC errors: -32700 for a parse error and -32600 unless `jsonrpc == "2.0"` [server/init.lua:M._handle_message]. -32601 for an unknown method and -32603 for a handler that raises [server/init.lua:M._handle_request]. `tools/call` answers `-32602` "Invalid params" before it runs a tool when `params` is not an object, `name` is not a string, or `arguments` is present and is not an object. The only log line of a rejected call is one debug line (see below). A JSON array is not an object, so `[]` and `[1]` both get `-32602`, as `params` and as `arguments`. A request without `params` gets `-32602` naming `params`: `_handle_request` passes a plain `{}`, and a plain `{}` counts as a list. `"params": {}` is an object and gets `-32602` naming `name`. `vim.json.decode` gives `[]` a table without the empty-dict mark and `{}` a table with it, and `vim.islist` (`vim.tbl_islist` before Neovim 0.10) tells them apart. A call without `arguments` is valid: the tool gets `{}` [server/init.lua:M.register_handlers] [tools/init.lua:M.handle_invoke] - When the JSON decodes to a non-table value (for example a number or a boolean), `_handle_message` replies -32600 with `"id": null`. It does not raise [server/init.lua:M._handle_message] - A message with a truthy `id` is a request. Any other message is a notification. A valid notification never gets a response. The -32700 and -32600 checks run before the `id` check, so a malformed notification gets an error reply [server/init.lua:M._handle_message] - Notification handlers run in `pcall`. Their errors and return values are discarded, and an unknown notification is ignored without a reply [server/init.lua:M._handle_notification] -- A `tools/call` without a string or number `name` raises in `tools.handle_invoke`, so `_handle_request` replies -32603 [server/init.lua:M.register_handlers] [server/init.lua:M._handle_request] - A deferred response is keyed by `tostring(coroutine)` in `_G.claude_deferred_responses`. It replies to `result.client` with the original request id. A result with `content` is sent as `result`, one with `error` is sent as `error`, and a result with neither becomes -32603 [server/init.lua:M._setup_deferred_response] - A ping timer tick pings every connected client and never closes one for a missing answer. `client.ping_unanswered_since` holds the loop time of the oldest ping sent since bytes were last read from the client. The read callback clears it on any bytes, not only a pong: Claude shows it is alive by writing anything, and a pong that waits behind more unread data than the socket holds is not in the first poll after a block. The tick calls `vim.loop.update_time()` before it reads the clock, because libuv caches the loop time when a pass of the main loop starts and runs the timers of the pass with it, and a callback that ran before the tick in the pass can have blocked since, or the machine can have slept in it. A stale time would hide a wake and would stamp a ping older than it is. Each tick that pinged a client arms `after_next_poll` (below), and its callback runs `judge_silence` after the next poll. libuv runs the check handle right after the poll phase that reads the sockets, so every byte that reached a socket before the loop time of the check was read. The check closes each client that the tick pinged when its oldest ping is `interval * 2.5` old, but only if its loop time is at most `interval * 1.5` after the tick. After a longer block Neovim and Claude can have slept together (a suspended machine), and Claude has had no time to answer. A tick cannot decide this, because libuv can run a tick before it reads the pong that Claude sent: after a block in a check callback or an I/O read callback, and, with libuv 1.44 (bundled in Neovim 0.8), after a block in any callback. libuv 1.44 also runs the timers again at the end of a pass, so two ticks can run with no read between them. `vim.schedule` cannot decide it either, because Neovim runs its callbacks between two passes, so it can run one before the read. Drop bound: a block of the main loop, of any length and in any callback, does not close a Claude that writes something within `interval * 2.5` (75 s by default) of each ping. A tick never closes a client, a check judges only after a poll within `interval * 1.5` of its tick, and any bytes clear the stamp. A client that writes data but never answers a ping is not closed by this rule: it is alive, and the plugin does not judge what it writes (the stall rule below closes it when it stops reading, but only once the kernel's socket buffers are full and libuv's write queue backs up, because a queue that is empty counts as progress). A block longer than `interval * 1.5` restarts the count: the next tick sees the gap, takes it for a wake and clears every stamp. So a block delays the close of a silent Claude, and a run of such blocks can postpone it. Without a long block, a Claude that sends nothing is closed at the first read of its socket after the third tick that follows its first unanswered ping, which is 90 s by default after that ping. The Close frame of a dropped client carries 1011 "Keepalive timeout" (RFC 6455 reserves 1006, so no Close frame carries it), `on_disconnect` gets 1006 "Connection timeout", and the check logs `Closed client_: no reply for s` at info level. A peer that stopped reading does not get that Close frame: once its receive window is full, every write to it (replies, pings, and the Close frame) waits in libuv's queue, and closing the socket drops the queue [server/tcp.lua:M.start_ping_timer] [server/tcp.lua:judge_silence] [server/tcp.lua:drop_client] - A Claude that answers each ping but never reads would keep that queue, which is Neovim's memory, without limit, so the server also closes a connected client that answers and whose write queue made no progress at two ticks in a row. Each tick calls `is_client_reading` for each connected client, which calls `sample_write_progress`: it compares `queued_bytes - get_write_queue_size()` with its value at the last progress, and an empty queue counts as progress. A tick that finds progress clears `client.write_stalled`. The first tick that finds none sets it. The second asks for a confirmation, and a confirmation that still finds no progress drops the client: the Close frame carries 1011 "Not reading", `on_disconnect` gets 1006 "Write queue stalled", and the server logs `Closed client_: not reading for s, KB queued` at info level. The confirmation runs right after the next poll. libuv reports that a socket is writable again only in a poll phase, so a tick that Neovim runs after a main-loop block inside an I/O callback can find no progress although Claude read during the block, whether the tick is late or on time. `after_next_poll` arms a check handle, which libuv runs right after each poll phase in every version, and an idle handle, which keeps that poll from waiting for a timer or a socket. The check callback closes both handles, removes them from `server.pending_polls`, and judges the queue once more: progress clears `write_stalled`, and no progress drops the client. A client that left or started to leave in the meantime, because the poll read its EOF, a read error, its Close frame or a protocol error, is skipped without a log line. `stop_server` closes the handles of a confirmation that has not run, which `server.pending_polls` lists. If libuv cannot create a handle, the tick leaves the client for the next tick. The look is a sample at one moment, not proof. libuv takes its snapshot of the ready sockets when a poll starts, so a callback that blocks inside that poll, after the snapshot, hides what Claude reads during the block from the check callback that follows. A client that was stalled for the whole window and resumes during such a block is dropped. That is a deadline race, as with any timeout: the client was stalled when the window ended, and what it read afterwards is not reported yet. Verifier probes closed a reader that resumed 0.1 s into a 0.5 s to 1.5 s block inside the confirming poll, on Neovim 0.12.3 and 0.8.0, at 3 s, 5 s and 30 s intervals. A block after the tick and before that poll, or in a scheduled callback, keeps the reader. The bound is two to three ticks after the last progress, which is 60 s to 90 s at the default interval, plus the delay of late ticks, each of which comes at most `interval * 1.5` after the tick before it. A tick that comes more than `interval * 1.5` after the tick before it counts as a wake from sleep. A wake tick starts a fresh stall window: it clears the count and judges nothing, so it delays the drop by one tick when the count was clear, and by two ticks when the wake tick takes the place of the confirming tick. The next two ticks always count, wake ticks or not, because wake ticks that come again and again are a loop that stays blocked, not sleeps. So whatever the pattern of late ticks, a stalled client is closed at the second tick after a fresh window started. A wake tick three or more ticks after the last fresh start starts a new window. The bound is never the size of the queue, so a Claude that reads slowly, or is sent one message of tens of MB, stays connected while libuv writes any of it. libuv writes more only after the kernel reports the socket writable again, which took 1.2 to 1.5 MB of reading on a Linux loopback socket with default buffers (measured), so a Claude that reads less than about 1.4 MB per window counts as stalled while a backlog exists, and one that reads just faster than that still lets the queue grow as long as Neovim queues more than it reads. `write` queues a frame of more than 1 MiB in pieces of 1 MiB, because libuv on Windows lowers the queue only when a whole write request is done, so a large frame shows its progress per piece there too, and the callback of the last piece reports the frame. [server/tcp.lua:M.start_ping_timer] [server/tcp.lua:judge_stall] [server/tcp.lua:after_next_poll] [server/tcp.lua:M.stop_server] [server/client.lua:M.is_client_reading] [server/client.lua:M.sample_write_progress] [server/client.lua:write] - On one tick a client counts as answered when no ping of it waited for `interval * 2`. Only a client that answered is confirmed for a stall. A client that did not answer is dropped by the keepalive check above, whatever its write queue shows. A client that answers while its queue is stalled is not saved: its bytes clear the stamp, the next tick finds it answered, and then asks for the confirmation. Every connected client is sampled on every tick except one that starts a fresh stall window, answering or not, so "two ticks in a row" means consecutive ticks. A tick gap above `interval * 1.5` counts as a wake from sleep and clears `ping_unanswered_since` of each connected client. A wake tick that starts a fresh stall window also resets `last_drain`, clears `write_stalled`, and does not sample the queue. `last_drain` feeds only the `not reading for s` of the log line, so the reset keeps the sleep out of it. A wake tick within two ticks after the start of a fresh window clears the stamps only, and counts like any tick. A client that is not connected is not pinged, so a main loop that blocks during the handshake does not count against it [server/tcp.lua:M.start_ping_timer] [server/client.lua:M.process_data] - `_disconnect_client` is idempotent: `on_disconnect` fires once per client id. A client that already left the server (the server stopped before a scheduled `on_close` or an EOF ran) gets its socket closed there, unless the write callback of its Close frame will close it [server/tcp.lua:M._disconnect_client] -- A client's EOF, read error, or protocol error reaches the server only as its `on_disconnect` code 1006 and reason (`EOF`, `Client read error: …`, or `Client error: …`), which `init.lua` logs at debug level. `on_error` carries only the server's own failures (listen, accept, and creating a client handle or the ping timer), which `init.lua` logs at error level [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] +- A client's EOF, read error, or protocol error reaches the server only as its `on_disconnect` code 1006 and reason (`EOF`, `Client read error: …`, or `Client error: …`), which `init.lua` logs at debug level. The server's `on_error` callback, not the `on_error` argument of `process_data`, carries only the server's own failures (listen, accept, and creating a client handle or the ping timer), which `init.lua` logs at error level [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] - A rejected handshake logs `Rejected client_: ` at warn level, with the reason from the body of the error response. Once the error response is written, `on_error` removes the client from the server and closes its socket [server/client.lua:M.process_data] - Once a client is closing, `process_data` drops the bytes it sends, and `close_client` frees the unparsed bytes and the reads of an unfinished frame [server/client.lua:M.process_data] [server/client.lua:M.close_client] - The server logs each error, warning, and info line from a libuv callback through `logger.one_line`, so the line never stops Neovim at "Press ENTER" [server/init.lua:M.start] [server/client.lua:M.process_data] [server/tcp.lua:M.start_ping_timer] @@ -120,12 +119,12 @@ Layers, top to bottom: - `tools/call` debug lines name the tool and list the argument and result keys with their sizes (string bytes, table entry counts), never their values, so file contents and selected text stay out of `:messages`. An error result logs its code and message. A rejected call logs one line, `Rejected tools/call: ()`, and none of the others [server/init.lua:M.register_handlers] - `client.create_client` increments `_G.claudecode_clients_created` [server/client.lua:M.create_client] - `tcp.lua` seeds `math.randomseed` once per load of the module, on the first `port_iterator` call for a non-empty range, so a reload of `tcp.lua` seeds again. `init.lua` calls `math.random` at load. Both change the global PRNG [server/tcp.lua:ensure_rng_seeded] [server/tcp.lua:port_iterator] -- `client.process_data` writes handshake, PONG, and Close frames to the socket. It runs `on_message` and `on_close` through `vim.schedule`. It calls `on_error` directly after a failed handshake write, and after it queues the Close frame for a protocol error [server/client.lua:M.process_data] [server/client.lua:fail_connection] +- `client.process_data` writes handshake, PONG, and Close frames to the socket. It runs `on_message` and `on_close` through `vim.schedule`. It calls `on_error` directly after a failed handshake write or a written rejection, and after it queues the Close frame for a protocol error [server/client.lua:M.process_data] [server/client.lua:fail_connection] - The ping timer writes ping frames. The check handle that each tick arms closes clients that timed out or stopped reading [server/tcp.lua:M.start_ping_timer] [server/tcp.lua:after_next_poll] - Each tick that pinged a client creates one `vim.loop` check handle and one idle handle, and the judgement of every client of that tick, keepalive and stall, shares that pair. The check callback closes them, and `stop_server` closes those that did not run [server/tcp.lua:after_next_poll] [server/tcp.lua:M.stop_server] - `close_client` starts a repeating `vim.loop` timer for the Close frame of a client that has `keepalive_timeout`. It runs until the frame is written, the socket closes, or the deadline closes the socket [server/client.lua:M.close_client] [server/client.lua:start_close_timer] - `handshake.lua`, `frame.lua`, and `utils.lua` do no I/O [server/handshake.lua:M] -- `mock.lua` opens no sockets and keeps its clients and messages in its own `M.state` tables. Its `mcp.tool.invoke` handler calls the real `tools.handle_invoke`, which looks the tool up in the global registry. `mock.start` does not fill that registry. So in a fresh Neovim a simulated tool call returns `-32601` "Tool not found" and has no side effects. After `tools.setup` or the real server's `M.start` fills the registry, the call has that tool's real side effects (for example `saveDocument` writes the file) [server/mock.lua:M.register_handlers] [server/mock.lua:M.state] [tools/init.lua:M.handle_invoke] [tools/init.lua:M.setup] +- `mock.lua` opens no sockets and keeps its clients and messages in its own `M.state` tables. Its `mcp.tool.invoke` handler calls the real `tools.handle_invoke`, which looks the tool up in the global registry. `mock.start` does not fill that registry. So in a fresh Neovim a simulated tool call returns `-32601` "Tool not found: " and has no side effects. After `tools.setup` or the real server's `M.start` fills the registry, the call has that tool's real side effects (for example `saveDocument` writes the file) [server/mock.lua:M.register_handlers] [server/mock.lua:M.state] [tools/init.lua:M.handle_invoke] [tools/init.lua:M.setup] ## Verification From 438c2aa1f6dcb62258514c8a7103b16b58d4f2b0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 23:59:30 +0000 Subject: [PATCH 05/13] docs: correct README claims for the blueprint and the plugin loader Add what rejects a pending openDiff (Claude's close tools too) and say that nothing answers it when Neovim exits. Say that the commands of auto-setup exist after a startup script that waits lets the deferred setup run. --- README.md | 7 +++++-- plugin/README.md | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dea91759f..d02a32374 100644 --- a/README.md +++ b/README.md @@ -1136,8 +1136,11 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ resolves it by accepting or rejecting it. The plugin also rejects a pending diff when the server stops [lua/claudecode/server/init.lua:M.stop], when the client that opened it disconnects [lua/claudecode/server/init.lua:M.start], or when a new diff reuses its `tab_name` - [lua/claudecode/diff.lua:M.open_diff_blocking]. A diff that resolves before `openDiff` yields, for example from a - `ClaudeCodeDiffOpened` handler, is answered at once [lua/claudecode/diff.lua:M.open_diff_blocking]. + [lua/claudecode/diff.lua:M.open_diff_blocking]. Claude's `close_tab` and `closeAllDiffTabs` calls also reject a + pending diff [lua/claudecode/diff.lua:M.close_diff_by_tab_name]. A diff that resolves before `openDiff` yields, for example from a + `ClaudeCodeDiffOpened` handler, is answered at once [lua/claudecode/diff.lua:M.open_diff_blocking]. When Neovim exits, + the `ClaudeCodeMCPDiff` `VimLeavePre` autocmd removes every diff before the `ClaudeCodeShutdown` autocmd stops the + server, so a pending `openDiff` gets no answer [lua/claudecode/diff.lua:M.\_cleanup_all_active_diffs]. ### Global Side Effects diff --git a/plugin/README.md b/plugin/README.md index 47ad5821a..613b76a99 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -54,9 +54,11 @@ when `require("claudecode").setup()` runs. The main module lives in scheduling, and the `pcall(require, "claudecode")` run synchronously while the file is sourced. The deferred `setup` call runs later, after the current script finishes. `[plugin/claudecode.lua:vim.defer_fn]` -- Commands from auto-setup do not exist while startup scripts run. In a +- Commands from auto-setup do not exist while startup scripts run, unless a + script waits for events, for example with `:sleep` or `vim.wait()`. In a headless check on Neovim 0.12.3, `:ClaudeCode` was missing when the `-c` - startup commands ran, and present after the deferred callback ran. + startup commands and `VimEnter` ran, and present after the deferred callback + ran. `[plugin/claudecode.lua:vim.defer_fn]` - The deferred `require("claudecode").setup(...)` call is not wrapped in `pcall`. An error from loading the module or from `M.setup` surfaces from the From ac7360c57ddbedee7e77c064f71fb2bd290c2a3c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 00:00:48 +0000 Subject: [PATCH 06/13] docs(tools): correct README claims that the code at main contradicts Checked each line that the branch changes against lua/claudecode/tools/*.lua at origin/main. A call without arguments runs the tool with {} since #500, the integration runner no longer skips command_args_spec.lua since #445, and the getCurrentSelection bullet needs the #457 condition. The openFile recovery read does not end Visual mode, which a probe confirmed. --- lua/claudecode/tools/README.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/lua/claudecode/tools/README.md b/lua/claudecode/tools/README.md index 80f1b5bac..989717b34 100644 --- a/lua/claudecode/tools/README.md +++ b/lua/claudecode/tools/README.md @@ -39,34 +39,31 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o - An unknown tool, an empty `name` included, returns `{ error = { code = -32601, message = "Tool not found: " } }` [tools/init.lua:M.handle_invoke] - An error that is a `{ code, message }` table keeps its code, whether the handler raises it or returns `false, err`. This holds for tools with `requires_coroutine` too, also when the reply is sent after a deferred resume. Any other error maps to `-32000` [tools/init.lua:M.build_response] [diff.lua:M.open_diff_blocking] -- A successful handler returns `{ result = }` [tools/init.lua:M.handle_invoke]. Each tool handler builds the MCP shape `{ content = { { type = "text", text = ... } } }` itself [tools/open_file.lua:handler]. `openDiff` returns the value of `diff.open_diff_blocking` unchanged, and `claudecode.diff` builds that shape [tools/open_diff.lua:handler] [diff.lua:M._resolve_diff_as_saved] -- The handlers read their parameters without checking that `arguments` is present. A call without `arguments` to a tool that reads one (for example `openFile`, `saveDocument`, or `getDiagnostics`, whose only parameter is optional) raises a Lua error, which `build_response` maps to `-32000`, not `-32602`. No handler enforces `additionalProperties = false` [tools/init.lua:M.handle_invoke] [tools/get_diagnostics.lua:handler] +- A successful handler returns `{ result = }` [tools/init.lua:M.handle_invoke]. Each tool handler builds the MCP shape `{ content = { { type = "text", text = ... } } }` itself [tools/open_file.lua:handler]. `openDiff` returns the value of `diff.open_diff_blocking` unchanged, and `claudecode.diff` builds that shape, or `claudecode.diff_inline` for the unified layout [tools/open_diff.lua:handler] [diff.lua:M._resolve_diff_as_saved] +- A call without `arguments` runs the tool with `{}`. A tool with a required parameter then raises `-32602`, and `getDiagnostics` returns the diagnostics of all buffers. No handler enforces `additionalProperties = false` [tools/init.lua:M.handle_invoke] [tools/get_diagnostics.lua:handler] - Tools with `requires_coroutine` run in `coroutine.create`. If the coroutine yields, `handle_invoke` returns `{ _deferred = true, coroutine, client, params }` and sends no response yet. Only `openDiff` sets `requires_coroutine = true` [tools/init.lua:M.handle_invoke] -- `openDiff` raises `-32602` when a required parameter is missing or is not a string. Outside a non-main coroutine it raises `-32000` with `message = "Internal server error"` and `data = "openDiff - must run in coroutine context"`. It raises `-32000` with `data = "Failed to load diff module"` when `claudecode.diff` does not load [tools/open_diff.lua:handler] +- `openDiff` raises `-32602` when a required parameter is missing or is not a string. Outside a non-main coroutine it raises `-32000` with `message = "Internal server error"` and `data = "openDiff must run in coroutine context"`. It raises `-32000` with `data = "Failed to load diff module"` when `claudecode.diff` does not load [tools/open_diff.lua:handler] - After a deferred `openDiff` yields, `diff.open_diff_blocking` maps the outcome with `build_response` and sends it through `_G.claude_deferred_responses[tostring(co)]`, then clears that entry [diff.lua:M.open_diff_blocking] - `openDiff` calls `diff.open_diff_blocking(old, new, contents, tab_name, client.id)`. Structured errors pass through. Other errors become `-32000` "Error opening blocking diff" [tools/open_diff.lua:handler] - `openFile`, `checkDocumentDirty`, and `saveDocument` raise `-32602` "Missing filePath parameter" when `filePath` is missing or is not a string [tools/open_file.lua:handler] -- `openFile` raises `-32602` before it opens the file when `startLine` or `endLine` is given but is not an integer (`false` included), `startLine` is below 1 or past the last line, or `endLine` is below `startLine`. The last line comes from the loaded buffer that `:edit` would reuse, which `bufexists()` and `bufadd()` find by name or by the inode Neovim cached for it, so a symlink or hard link finds it. A buffer made before its file existed has no cached inode when another program creates the file, so a symlink to that file does not find it. Without one it comes from the file on disk. For a loaded buffer without unsaved changes the file on disk counts too, because openFile loads the file again when it changed. When the buffer openFile then shows has fewer lines than `startLine`, openFile selects its last line. An `endLine` past the last line selects through the last line, and the answer names the lines it selected [tools/open_file.lua:handler] +- `openFile` raises `-32602` before it opens the file when `startLine` or `endLine` is given but is not an integer (`false` included), `startLine` is below 1 or past the last line, or `endLine` is below `startLine`. The last line comes from the loaded buffer that `:edit` would reuse, which `bufexists()` and `bufadd()` find by name or by the inode Neovim cached for it, so a symlink or hard link finds it. Without one it comes from the file on disk. A buffer made before its file existed has no cached inode when another program creates the file, so a symlink to that file does not find it. For a loaded buffer without unsaved changes the file on disk counts too, because openFile loads the file again when it changed. When the buffer openFile then shows has fewer lines than `startLine`, openFile selects its last line. An `endLine` past the last line selects through the last line, and the answer names the lines it selected [tools/open_file.lua:handler] - `openFile` raises `-32000` with `message = "File operation error"` and `data = "File not found: "` when the file is not readable, and with `data = "No preview window shows the file"` when the preview window is closed or no longer has 'previewwindow' set after it gets the file, for example because an autocommand cleared it. It raises `-32000` and changes nothing while the command-line window is open, and when the user is in the middle of a command in the preview window, which openFile would reuse. Its defaults are `preview=false`, `makeFrontmost=true`, and `selectToEndOfLine=false` [tools/open_file.lua:handler] -- `openFile` expands only a leading `~` in `filePath` (`utils.expand_tilde`). When only `endLine` is given, `startLine` is 1. When only `startLine` is given, `endLine` equals it [tools/open_file.lua:handler] -- `openFile` finds `startText` and `endText` as plain text and takes the first match. It searches `endText` after the `startText` match, ignores an empty string, and applies `selectToEndOfLine` only when `endText` matches. A text selection replaces a line range. When `startText` is not found, the call succeeds with `Opened file, but text "" not found` [tools/open_file.lua:handler] +- `openFile` expands `~` only when `filePath` is `~` or starts with `~/` (`utils.expand_tilde`). When only `endLine` is given, `startLine` is 1. When only `startLine` is given, `endLine` equals it [tools/open_file.lua:handler] +- `openFile` finds `startText` and `endText` as plain text and takes the first match. It searches `endText` after the `startText` match, ignores an empty string, and applies `selectToEndOfLine` only when `endText` matches. A text selection replaces a line range. When `startText` is not found, the call succeeds, and with `makeFrontmost=true` its answer starts with `Opened file, but text "" not found` [tools/open_file.lua:handler] - With `makeFrontmost=false`, `openFile` answers with JSON text `{ success = true, filePath, languageId, lineCount }` for the buffer it shows [tools/open_file.lua:handler] - The main editor window can be in another tab page, because `find_main_editor_window` scans `nvim_list_wins()`. With `makeFrontmost=true`, focusing it switches to that tab page [tools/open_file.lua:find_main_editor_window] -- `checkDocumentDirty` and `saveDocument` return a `success=false` JSON result (not an error) with `Document not open: ` when `vim.fn.bufnr(filePath)` is `-1`. They do not normalize the path, and a buffer that exists but is unloaded counts as open [tools/save_document.lua:handler] [tools/check_document_dirty.lua:handler] +- `checkDocumentDirty` and `saveDocument` return a `success=false` JSON result (not an error) with `Document not open: ` when `vim.fn.bufnr(filePath)` is `-1`. They pass `filePath` to `bufnr()` unchanged, and `bufnr()` matches it as a file pattern. So a relative path, or a part that only one buffer name contains, finds that buffer, and an empty `filePath` finds the current buffer. A buffer that exists but is unloaded counts as open [tools/save_document.lua:handler] [tools/check_document_dirty.lua:handler] - `saveDocument` reports a failed `:write` as a `success=false` result with `Failed to save file: `, not as an error. `` is the Vim error (`E: …`) when the message holds one, else the message's first line, so no Lua stack trace reaches Claude [tools/save_document.lua:handler] - `getDiagnostics` raises `-32602` when `uri` is given but is not a string, before any other check. It raises `-32000` when `vim.lsp`, `vim.diagnostic`, or `vim.diagnostic.get` is missing. It raises `-32001` "File not open" when no buffer exists for `uri` (`vim.fn.bufnr(path) == -1`). An unloaded buffer does not raise [tools/get_diagnostics.lua:handler] -- `getDiagnostics` returns a JSON array with one `{ uri, diagnostics }` entry per file. Each diagnostic has `message`, `severity` (`Error`, `Warning`, `Info`, or `Hint`), `range`, `source`, and `code` as a string. It drops a diagnostic that has no file path, so without `uri` the diagnostics of unnamed buffers are left out [tools/get_diagnostics.lua:append_diagnostic] [tools/get_diagnostics.lua:severity_name] +- `getDiagnostics` returns a JSON array with one `{ uri, diagnostics }` entry per file. Each diagnostic has `message`, `severity` (`Error`, `Warning`, `Info`, or `Hint`), and `range`. It has `source`, and `code` as a string, only when the diagnostic has them. It drops a diagnostic that has no file path, so without `uri` the diagnostics of unnamed buffers are left out [tools/get_diagnostics.lua:append_diagnostic] [tools/get_diagnostics.lua:severity_name] - `getLatestSelection` returns a `success=false` JSON result when no selection exists. Otherwise it returns the stored selection without a `success` field [tools/get_latest_selection.lua:handler] - With no selection, `getCurrentSelection` returns `success=false` only when the active buffer has no name. When the Claude terminal is current, it answers from the window the user came from (`winnr('#')`), or `success=false` when there is none. When nothing is tracked (`track_selection = false`), it reads the editor at call time: the Visual or Select selection, or the cursor position [tools/get_current_selection.lua:handler] -- `getCurrentSelection` reads the same stored selection as `getLatestSelection` (`selection.get_latest_selection()`) and adds `success=true`. It does not check that the selection belongs to the active buffer [tools/get_current_selection.lua:handler] [selection.lua:M.get_latest_selection] +- When a selection is stored, `getCurrentSelection` returns the same one as `getLatestSelection` (`selection.get_latest_selection()`) and does not check that it belongs to the active buffer. It adds `success=true` to each selection it returns [tools/get_current_selection.lua:handler] [selection.lua:M.get_latest_selection] - `getCurrentSelection` and `getLatestSelection` raise `-32000` with `message = "Internal server error"` and `data = "Failed to load selection module"` when `claudecode.selection` does not load [tools/get_current_selection.lua:handler] [tools/get_latest_selection.lua:handler] - `getCurrentSelection` raises `-32000` with `message = "Internal server error"` and `data = "Failed to encode : "` when `vim.json.encode` fails [tools/get_current_selection.lua:safe_json_encode] -- `getOpenEditors` lists only buffers that are loaded, listed, and named. It gives every buffer the current tab page as its group, and puts the latest selection on the active buffer's entry without checking which file it came from [tools/get_open_editors.lua:handler] +- `getOpenEditors` lists only buffers that are loaded, listed, and named. It gives every buffer the handle of the current tab page, not its number, as its group (`groupIndex` is the handle minus 1), and puts the latest selection on the active buffer's entry without checking which file it came from [tools/get_open_editors.lua:handler] - `getWorkspaceFolders` always returns one folder, built from `vim.fn.getcwd()`. It adds no LSP workspace folders [tools/get_workspace_folders.lua:handler] -- `close_tab` raises `-32602` when `tab_name` is missing or is not a string, like the other tools. It answers `TAB_CLOSED` when it closes the diff, when it keeps a diff the user accepted for another - client, when no diff has that name, and when `claudecode.diff` does not load. Its value for a logger that does not load uses code `-32603`, which is not in `M.ERROR_CODES` - [tools/close_tab.lua:handler] +- `close_tab` raises `-32602` when `tab_name` is missing or is not a string, like the other tools. It answers `TAB_CLOSED` when it closes the diff, when it keeps a diff the user accepted for another client, when no diff has that name, and when `claudecode.diff` does not load. When `claudecode.logger` does not load, it returns `{ code = -32603, message = "Internal error", data = "Failed to load logger module" }` as a result, not as an error, and `-32603` is not in `M.ERROR_CODES` [tools/close_tab.lua:handler] ## Side Effects @@ -75,8 +72,8 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o - `saveDocument` runs `write` in the matching buffer with `nvim_buf_call` [tools/save_document.lua:handler] - `closeAllDiffTabs` calls `diff.close_all_diffs("closeAllDiffTabs tool", client.id)` and returns `CLOSED__DIFF_TABS`. `n` counts only the diffs it closed: a diff the user accepted for another client stays open and is not counted [tools/close_all_diff_tabs.lua:handler] [diff.lua:M.close_all_diffs] - `close_tab` calls `diff.close_diff_by_tab_name` with the tab name and the calling client. It closes or deletes no other buffer, whatever the name [tools/close_tab.lua:handler] -- Both close tools resolve a pending diff as rejected first, which resumes the waiting `openDiff` with `DIFF_REJECTED`. An accepted diff reloads its file buffers 100 ms later [diff.lua:M.close_diff_by_tab_name] -- Loading `open_file.lua` creates the `ClaudeCodeOpenFileNewFiles` augroup (`BufNewFile`, `BufReadPost`/`BufUnload`, and `BufWritePost` autocmds) that tracks buffers opened before their file existed, and marks such buffers that are already loaded [tools/open_file.lua:new_file_buffers] +- Both close tools resolve a pending diff as rejected first, which resumes the waiting `openDiff` with `DIFF_REJECTED`. When an accepted diff closes, the buffers of its file that have no unsaved changes load the file again 100 ms later [diff.lua:M.close_diff_by_tab_name] +- Loading `open_file.lua` creates the `ClaudeCodeOpenFileNewFiles` augroup (`BufNewFile`, `BufReadPost`/`BufUnload`, and `BufWritePost` autocmds) that tracks buffers opened before their file existed, and marks each loaded buffer that has a name and an empty 'buftype' and whose file is not readable at that time [tools/open_file.lua:new_file_buffers] - `getCurrentSelection`, `getLatestSelection`, `getOpenEditors`, `getWorkspaceFolders`, and `checkDocumentDirty` only read editor state [tools/get_open_editors.lua:handler] - `getDiagnostics` reads editor state and writes debug log entries through `claudecode.logger` [tools/get_diagnostics.lua:handler] - `handle_invoke` writes debug log entries through `claudecode.logger` on the `requires_coroutine` path [tools/init.lua:M.handle_invoke] @@ -86,4 +83,4 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o - Run: `mise run test` - Targeted: `busted -v tests/unit/tools_spec.lua`, `busted -v tests/unit/tools/open_file_spec.lua`, `busted -v tests/unit/tools/open_diff_mcp_spec.lua`, `busted -v tests/unit/tools/get_diagnostics_spec.lua`, `busted -v tests/unit/tools/save_document_spec.lua`, `busted -v tests/unit/tools/close_tab_spec.lua`, `busted -v tests/unit/tools/get_current_selection_spec.lua`, `busted -v tests/unit/tools/get_open_editors_spec.lua`, `busted -v tests/integration/mcp_tools_spec.lua` -- Real Neovim: `scripts/run_integration_tests_individually.sh` runs each `tests/integration/*_spec.lua` file in headless Neovim with plenary.nvim and skips `command_args_spec.lua`. It runs `tests/integration/open_file_selection_spec.lua`, which `busted` only marks as pending +- Real Neovim: `scripts/run_integration_tests_individually.sh` runs each `tests/integration/*_spec.lua` file in headless Neovim with plenary.nvim. It runs `tests/integration/open_file_selection_spec.lua`, which `busted` only marks as pending From 0b8bfda896655d14561b42d72201a6e0e5e0062d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 00:02:23 +0000 Subject: [PATCH 07/13] docs: correct the exit sentence about a pending openDiff At exit Neovim fires BufUnload for each loaded buffer before VimLeavePre. The BufUnload autocmd of the proposal buffer rejects its pending diff (diff.lua:1279), so the VimLeavePre cleanup finds none. A probe that fired VimLeavePre alone had hidden this. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d02a32374..a0946aed1 100644 --- a/README.md +++ b/README.md @@ -1139,8 +1139,9 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ [lua/claudecode/diff.lua:M.open_diff_blocking]. Claude's `close_tab` and `closeAllDiffTabs` calls also reject a pending diff [lua/claudecode/diff.lua:M.close_diff_by_tab_name]. A diff that resolves before `openDiff` yields, for example from a `ClaudeCodeDiffOpened` handler, is answered at once [lua/claudecode/diff.lua:M.open_diff_blocking]. When Neovim exits, - the `ClaudeCodeMCPDiff` `VimLeavePre` autocmd removes every diff before the `ClaudeCodeShutdown` autocmd stops the - server, so a pending `openDiff` gets no answer [lua/claudecode/diff.lua:M.\_cleanup_all_active_diffs]. + it fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the `BufUnload` autocmd of the proposal buffer + rejects its pending diff [lua/claudecode/diff.lua:register_diff_autocmds]. So the `VimLeavePre` cleanup of + `ClaudeCodeMCPDiff` finds none [lua/claudecode/diff.lua:M.\_cleanup_all_active_diffs]. ### Global Side Effects From c8e22669bfd9ded78eec51f6b7a592edf226e7b0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 00:02:23 +0000 Subject: [PATCH 08/13] docs(lua): correct README claims that the code at main contradicts Checked each line that the branch changes against lua/claudecode/*.lua at origin/main. Drop lockfile.update (removed by #459), the old mention-queue polling (removed by #454), the unified bufhidden=wipe claim (#469), the BufEnter pile-up claim (#478) and the integration runner skip (#445). Add TextChangedI and TextChangedP (#452). --- lua/claudecode/README.md | 45 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 5ee46363e..afc551342 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -47,13 +47,13 @@ Module map (direct files): - `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 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 first line of the repository's `VERSION` file at load. Without a readable, well-formed `VERSION` file the version is `0.0.0-unknown` [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]. Nothing outside `tests/` calls `M._add_paths_to_claude` or `M._ensure_terminal_visible_if_connected`. The user commands use a local `add_paths_to_claude` that calls `M.send_at_mention` for each path, with no file cap and no batches [init.lua:M._create_commands] +- 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]. Nothing outside `tests/` calls `M._add_paths_to_claude` or `M._ensure_terminal_visible_if_connected`. The user commands call `M.send_at_mention` for each path, with no file cap and no batches. `:ClaudeCodeSend` does it through a local `add_paths_to_claude` [init.lua:M._create_commands] ### User commands [init.lua:M._create_commands] - Server: `:ClaudeCodeStart`, `:ClaudeCodeStop`, and `:ClaudeCodeStatus`. - Context: `:ClaudeCodeSend` (range, tree-aware), `:ClaudeCodeTreeAdd`, and `:ClaudeCodeAdd [start] [end]`. -- `:ClaudeCodeAdd` reads Neovim's split arguments (`opts.fargs`), so a path with escaped spaces, such as the expansion of `%` for a buffer named `my file.lua`, stays one argument. It needs a running server and at most 3 arguments. Only a path that starts with `%`, `#`, or `<` goes through `vim.fn.expand`. Other paths expand only a leading `~`, so `$VAR` stays literal. The path must be a readable file or a directory. Line numbers must be at least 1, and start must not be past end. +- `:ClaudeCodeAdd` reads Neovim's split arguments (`opts.fargs`), so a path with escaped spaces, such as the expansion of `%` for a buffer named `my file.lua`, stays one argument. It needs a running server and at most 3 arguments. Only a path that starts with `%`, `#`, or `<` goes through `vim.fn.expand`. Other paths expand only a bare `~` or a leading `~/`, so `$VAR` stays literal. The path must be a readable file or a directory. Line numbers must be at least 1, and start must not be past end. - `:ClaudeCodeTreeAdd` refuses without a running server. The tree handlers detect a tree buffer by filetype only, and the visual `:ClaudeCodeSend` handler does not accept `snacks_picker_list`, which the normal handler accepts. - Terminal: `:ClaudeCode [args]`, `:ClaudeCodeFocus [args]`, `:ClaudeCodeOpen [args]`, `:ClaudeCodeClose`, and `:ClaudeCodeSendText[!] `. These commands exist only when `claudecode.terminal` loads. `:ClaudeCodeSendText!` inserts the text without submitting it. - Diff: `:ClaudeCodeDiffAccept`, `:ClaudeCodeDiffDeny`, and `:ClaudeCodeCloseAllDiffs`. @@ -78,8 +78,8 @@ Module map (direct files): - `terminal.lua`: `open`, `simple_toggle`, and `focus_toggle` take `(opts_override, cmd_args)` and call the provider method of the same name with `(cmd_string, env_table, effective_config)`. `M.toggle` calls `M.simple_toggle`, not the provider's `toggle`. `toggle_open_no_focus` and `ensure_visible` call the provider's `ensure_visible` when it has one, else open the terminal without focus when it is not visible [terminal.lua:M.toggle] [terminal.lua:ensure_terminal_visible_no_focus] - `terminal.lua` test hook: `_get_managed_terminal_for_test()` returns the provider's `_get_terminal_for_test()` result, or nil [terminal.lua:M._get_managed_terminal_for_test] - `terminal.lua` defaults: `split_side = "right"`, `split_width_percentage = 0.30`, `diff_split_width_percentage = nil`, `provider = "auto"`, `show_native_term_exit_tip = true`, `auto_close = true`, `auto_insert = true`, `env = {}`, `snacks_win_opts = {}`, `fix_streamed_paste = "auto"`, `cwd = nil`, `git_repo_cwd = false`, and `cwd_provider = nil` [terminal.lua:M.defaults] -- `diff.lua`: `setup`, `open_diff(old_file_path, new_file_path, new_file_contents, tab_name)` (wraps `_open_native_diff`, a legacy path that nothing in the plugin calls. It never checks the tab for a user's diff. It opens the old file in the main editor window, current tab first, which is never itself a diff window, and runs `:diffthis` there, so it joins a vimdiff in that tab. With no main editor window, it uses the window right of the top-left one, which can be one of the user's diff windows, and splits it when it shows a terminal or `nofile` buffer or is the preview window), `open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name, client_id)`, `close_diff_by_tab_name`, `close_all_diffs`, `close_pending_diffs`, `close_rejected_diffs`, `close_saved_diffs`, `close_diffs_for_client`, `accept_current_diff`, `deny_current_diff`, and `reload_file_buffers_manual` [diff.lua:M.open_diff_blocking] -- `diff.lua` internals that `diff_inline.lua` depends on: `_register_diff_state`, `_get_active_diffs`, `_cleanup_diff_state`, `_resolve_diff_as_saved`, `_resolve_diff_as_rejected`, `_save_window_sizes`, `_restore_window_sizes`, `_restore_cursor`, `_resize_terminal_for_diff`, `_find_main_editor_window`, `_find_claudecode_terminal_window`, `_leave_preview_window`, `_is_buffer_dirty`, `_detect_filetype`, `_get_autocmd_group`, and `_display_terminal_in_new_tab` [diff.lua:M._register_diff_state] [diff.lua:M._get_active_diffs] +- `diff.lua`: `setup`, `open_diff(old_file_path, new_file_path, new_file_contents, tab_name)` (wraps `_open_native_diff`, a legacy path that nothing in the plugin calls. It never checks the tab for a user's diff. It opens the old file in the main editor window, current tab first, which is never itself a diff window, and runs `:diffthis` there, so it joins a vimdiff in that tab. With no main editor window, it uses the window right of the top-left one, which can be one of the user's diff windows, and splits it when it shows a terminal or `nofile` buffer. When it is the preview window, it splits the Claude terminal's window instead when that window is tiled in the same tab), `open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name, client_id)`, `close_diff_by_tab_name`, `close_all_diffs`, `close_pending_diffs`, `close_rejected_diffs`, `close_saved_diffs`, `close_diffs_for_client`, `accept_current_diff`, `deny_current_diff`, and `reload_file_buffers_manual` [diff.lua:M.open_diff_blocking] +- `diff.lua` internals that `diff_inline.lua` depends on: `_register_diff_state`, `_get_active_diffs`, `_cleanup_diff_state`, `_resolve_diff_as_rejected`, `_write_proposal`, `_register_reject_when_hidden`, `_close_proposal_window`, `_save_window_sizes`, `_restore_window_sizes`, `_restore_cursor`, `_resize_terminal_for_diff`, `_find_main_editor_window`, `_find_claudecode_terminal_window`, `_leave_preview_window`, `_is_buffer_dirty`, `_detect_filetype`, `_get_autocmd_group`, and `_display_terminal_in_new_tab` [diff.lua:M._register_diff_state] [diff.lua:M._get_active_diffs] - `diff.lua` internals exposed for testing: `_resolve_split_width_percentage`, `_auto_resize_enabled`, `_tabpage_has_diff_window`, and `_register_diff_autocmds`. `_setup_blocking_diff` calls `_create_diff_view_from_window` through `M`, so tests can replace it [diff.lua:M._register_diff_autocmds] [diff.lua:M._create_diff_view_from_window] - `diff_inline.lua`: `setup_inline_diff`, `compute_inline_diff`, `extract_new_content`, `render_diff_buffer`, `resolve_inline_as_saved`, `resolve_inline_as_rejected`, and `cleanup_inline_diff` [diff_inline.lua:M.setup_inline_diff] - `selection.lua`: `enable(server, visual_demotion_delay_ms)`, `disable`, `get_latest_selection`, `get_visual_selection`, `get_cursor_position(winid?)`, `is_claude_terminal_buffer`, `send_current_selection`, `get_range_selection`, `send_at_mention_for_visual_selection`, and the autocmd handlers `on_cursor_moved`, `on_mode_changed`, and `on_text_changed` [selection.lua:M.enable] @@ -87,7 +87,6 @@ Module map (direct files): `flush_visual_selection`, `has_selection_changed`, `send_selection_update`, `_cancel_debounce_timer`, `_cancel_demotion_timer`, `_create_autocommands`, and `_clear_autocommands` [selection.lua:M.update_selection] [selection.lua:M.enable] - `lockfile.lua`: `lock_dir`, `generate_auth_token`, `create(port, auth_token?) -> ok, path, token`, `remove`, `get_auth_token`, and `get_workspace_folders` [lockfile.lua:M.create] -- `lockfile.update(port)` removes the lock file and calls `create(port)` without a token, so the new file holds a newly generated token that no longer matches the server's token. Nothing outside `tests/` calls it [lockfile.lua:M.update] - `lockfile.get_workspace_folders()` returns `getcwd()` first, then each LSP client's workspace folder with `file://` removed (no percent-decoding), without duplicates [lockfile.lua:M.get_workspace_folders] - `integrations.lua`: `get_selected_files_from_tree()` picks a reader by filetype: `NvimTree` (`_get_nvim_tree_selection`), `neo-tree` (`_get_neotree_selection`), `oil` (`_get_oil_selection`), `minifiles` (`_get_mini_files_selection`), `netrw` (`_get_netrw_selection`), and `snacks_picker_list` (`_get_snacks_picker_selection`). For any other filetype it returns `nil` and an error, not `{}` [integrations.lua:M.get_selected_files_from_tree] - `integrations._get_mini_files_selection_with_range(start_line, end_line)` is not a dispatch target. The visual `:ClaudeCodeSend` handler calls it in a `minifiles` buffer [integrations.lua:M._get_mini_files_selection_with_range] [init.lua:M._create_commands] @@ -124,7 +123,7 @@ Module map (direct files): - Provider selection: `auto` uses snacks if available, else native. `external` requires `external_terminal_cmd` as a function or as a string containing `%s`. Every other failure falls back to `native` [terminal.lua:get_provider] - `get_provider` picks the provider again on every call. Only the provider modules are cached [terminal.lua:get_provider]. When a `snacks`, `external`, `none`, or custom table provider fails, each call logs a warning that it falls back to `native`. `terminal.setup` calls it too [terminal.lua:M.setup] - `terminal.setup` changes `M.defaults` in place. It always sets `terminal_cmd` and `env` from its own arguments and ignores a `terminal_cmd` key in the terminal options. An invalid `provider` value keeps the previous value (default `auto`), although the warning says "Defaulting to 'native'" [terminal.lua:M.setup] -- `terminal.setup` warns through `vim.notify` directly, not through the logger. So its warnings show at once, and `log_level` does not filter them. It warns on a terminal config that is not a table, an invalid `terminal_cmd` or `env`, an invalid value for a known key, and an unknown key [terminal.lua:M.setup] +- `terminal.setup` warns about its options through `vim.notify` directly, not through the logger. So its warnings show at once, and `log_level` does not filter them. It warns on a terminal config that is not a table, an invalid `terminal_cmd` or `env`, an invalid value for a known key, and an unknown key [terminal.lua:M.setup] - A per-call `opts_override` changes only a key that has a validator (`split_side`, `split_width_percentage`, `snacks_win_opts`, `cwd`, `git_repo_cwd`, `cwd_provider`) and a non-nil current value. So a per-call `cwd` or `cwd_provider` is ignored unless `setup` set one. Providers get only `split_side`, `split_width_percentage`, `auto_close`, `auto_insert`, `snacks_win_opts`, and the resolved `cwd` [terminal.lua:build_config] - The Claude command is `terminal_cmd`, or `claude` when it is unset. `no_proxy` and `NO_PROXY` always include `localhost`, `127.0.0.1`, and `::1` [terminal.lua:get_claude_command_and_env] - Terminal cwd precedence: `cwd_provider`, then static `cwd`, then the git root when `git_repo_cwd` is set [terminal.lua:build_config] @@ -138,12 +137,12 @@ Module map (direct files): - 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] -- A mention sent while disconnected is formatted before it is queued, as a connected send is, so it carries the absolute path and a later `:cd` does not change the file it names. A directory mention loses its line numbers. When `_format_path_for_at_mention` raises, `send_at_mention` returns `false` and the error, queues nothing, and opens no terminal. A queued mention fires no `ClaudeCodeSendComplete` [init.lua:M.send_at_mention] [init.lua:queue_mention] -- A connected send calls `terminal.open()` when `focus_after_send` is set, else `terminal.ensure_visible()`, then fires `ClaudeCodeSendComplete` with the formatted path. Success means the mention was accepted, not delivered [init.lua:M.send_at_mention] -- Connected mentions are debounced by 50 ms, unless `disable_broadcast_debouncing` is set, or busted runs without `enable_broadcast_debouncing_in_tests`. A flush sends one mention every 25 ms. After a new connection it waits `connection_wait_delay` and polls for the handshake every `max(50, floor(connection_wait_delay / 4))` ms [init.lua:M._broadcast_at_mention] [init.lua:M.process_mention_queue] +- A mention sent while disconnected is formatted before it is queued, as a connected send is, so it carries the absolute path and a later `:cd` does not change the file it names. A directory mention loses its line numbers. When `_format_path_for_at_mention` raises, `send_at_mention` returns `false` and the error, queues nothing, and opens no terminal. A mention sent while disconnected fires no `ClaudeCodeSendComplete` [init.lua:M.send_at_mention] [init.lua:queue_mention] +- A connected send that succeeds calls `terminal.open()` when `focus_after_send` is set, else `terminal.ensure_visible()`, then fires `ClaudeCodeSendComplete` with the formatted path. Success means the mention was accepted, not delivered [init.lua:M.send_at_mention] +- Broadcast debouncing is off when `disable_broadcast_debouncing` is set, or when busted is loaded and `enable_broadcast_debouncing_in_tests` is not set [init.lua:M._broadcast_at_mention] - `_format_path_for_at_mention` raises on an empty path. When busted is not loaded, it also raises on a path that is neither readable nor a directory. It returns the absolute path from `fnamemodify(path, ":p")` and cuts no cwd prefix, so with cwd `/a/proj` the path `/a/proj2/x` stays `/a/proj2/x`. It resolves a relative path against the cwd at the call. It puts `./` before a path that starts with `~`, so `~foo` names a file in the cwd, not a home directory. A directory, the cwd included, ends in a path separator (`/` outside Windows). Outside Windows it asserts that the result starts with `/` [init.lua:M._format_path_for_at_mention] -- Tracking listens to `CursorMoved`, `CursorMovedI`, `BufEnter`, `ModeChanged`, and `TextChanged`, not `TextChangedI` [selection.lua:M._create_autocommands] -- After Visual mode ends, the visual selection stays the latest selection until the cursor leaves the position it had at the exit. Then, after `visual_demotion_delay_ms`, the cursor position replaces it. A move to the Claude terminal keeps the visual selection [selection.lua:M.handle_selection_demotion] [selection.lua:M.update_selection] +- Tracking listens to `CursorMoved`, `CursorMovedI`, `BufEnter`, `ModeChanged`, `TextChanged`, `TextChangedI`, and `TextChangedP` [selection.lua:M._create_autocommands] +- After Visual mode ends, the visual selection stays the latest selection until the cursor leaves the position it had at the exit, the held text changes, or a buffer other than the Claude terminal is entered. The cursor position then replaces it, at once for a cursor move or a text change, and after the 100 ms debounce for another buffer. A move to the Claude terminal keeps the visual selection [selection.lua:reconcile_held_selection] [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. It runs `update_selection` first, so a changed selection goes out twice [selection.lua:M.send_current_selection] - `has_selection_changed` compares only `filePath`, `text`, and the start and end positions [selection.lua:M.has_selection_changed] - 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] @@ -161,18 +160,18 @@ Module map (direct files): - `open_diff_blocking` must run inside a non-main coroutine. Otherwise it raises `-32000` [diff.lua:M.open_diff_blocking] - `open_diff_blocking` makes `old_file_path` absolute against the cwd at the call, so a later `:cd` does not change the file a diff points at [diff.lua:M.open_diff_blocking] - A diff whose status is `saved` and whose `client_id` is set and differs from the caller's holds edits for another Claude client. `open_diff_blocking` then refuses the tab name with `-32000` "Diff tab in use", and `close_diff_by_tab_name` with a `client_id` keeps that diff and returns `false` [diff.lua:holds_edits_for_other_client] [diff.lua:M.close_diff_by_tab_name] -- A diff opens in a new tab when `diff_opts.open_in_new_tab` is set, or when the current tab or the tab of the window it would use already has a diff window. `close_tab` then goes back to the tab the user was in. Otherwise it starts from a window that shows the old file or from the main editor window, neither of which is a diff window, the preview window, or a floating window. The main editor window also never shows a `terminal` or `prompt` buffer or a file-explorer or outline sidebar. When neither exists, it splits the current window [diff.lua:M._setup_blocking_diff] [diff.lua:find_main_editor_window] +- A diff opens in a new tab when `diff_opts.open_in_new_tab` is set, or when the current tab or the tab of the window it would use already has a diff window. `close_tab` then goes back to the tab the user was in. Otherwise it starts from a window that shows the old file or from the main editor window, neither of which is a diff window, the preview window, or a floating window. The main editor window also never shows a `terminal` or `prompt` buffer, or a buffer whose filetype is `neo-tree`, `neo-tree-popup`, `NvimTree`, `oil`, `minifiles`, `netrw`, `aerial`, `tagbar`, `snacks_picker_list`, or `snacks_layout_box`. Other sidebars count as editor windows. When neither exists, it splits the current window [diff.lua:M._setup_blocking_diff] [diff.lua:find_main_editor_window] - The current tab comes first, as VS Code opens a diff in the active editor group: the current tab's window that shows the old file, then the current tab's main editor window, then the same two in the other tabs. A `unified` diff skips the window that shows the old file and opens next to the main editor window, current tab first. It is not a Neovim diff, so a user's diff in the current tab does not give it a new tab. Only when the current tab has neither does the diff go to another tab, and Neovim then switches to that tab. For example, with only the preview window in the current tab and the Claude terminal next to an empty window in another tab, the diff opens in that other tab. A `vertical` or `horizontal` diff of an existing file that starts from a window with an empty, unnamed buffer opens the file there, and that window keeps showing the file after `close_tab` [diff.lua:windows_current_tab_first] [diff.lua:choose_original_window] - A diff in a new tab shows the Claude terminal in a split of that tab, then puts the cursor in the proposal, in Normal mode. The terminal buffer goes into the split with `BufEnter` in `'eventignore'` (`utils.win_set_buf_without_bufenter`), so an auto-insert handler of that buffer, such as the Snacks `auto_insert` autocmd or the diff's own `BufEnter` handler from an earlier diff, cannot queue a `startinsert` that would start in the proposal. Entering that terminal window later still starts terminal mode, because with `terminal.auto_insert` on the diff gives the terminal buffer a `BufEnter` handler for it. A `WinClosed` autocmd for that window deletes the handler when that window closes. That happens when `close_tab` or the user closes the diff tab, and also when the user closes that split alone (`:q`) while the diff is still open. Until then the handler stays, even if that window now shows another buffer, and it starts terminal mode on every entry of the terminal buffer, in any window. After the window closes, a later entry of the terminal buffer, such as a restored float, queues no insert. `diff_opts.keep_terminal_focus = true` still moves the cursor to the terminal [diff.lua:display_terminal_in_new_tab] [utils.lua:M.win_set_buf_without_bufenter] -- In the current tab, a `unified` diff opens in a new split right of the main editor window. With no main editor window anywhere, it first splits the current window into an empty scratch window, so the diff adds two windows, and cleanup closes both. In a new tab, it reuses the empty window that `:tabnew` made while that window is untouched, so the tab holds no blank pane. It splits beside that window when a `TabNew` handler filled it or when the tab holds only the terminal [diff_inline.lua:M.setup_inline_diff] [diff_inline.lua:create_fallback_editor_window] [diff_inline.lua:shows_untouched_tabnew_buffer] -- A `unified` diff needs `vim.text.diff` or `vim.diff` (Neovim 0.9 or later) and raises `-32000` without one. Its proposal buffer, ` (unified diff)`, cannot be edited, so the accepted text is always the proposal [diff_inline.lua:M.setup_inline_diff] +- When it does not open a new tab, a `unified` diff opens in a new split right of the main editor window. With no main editor window anywhere, it first splits the current window into an empty scratch window, so the diff adds two windows, and cleanup closes both. In a new tab, it reuses the empty window that `:tabnew` made while that window is untouched, so the tab holds no blank pane. It splits beside that window when a `TabNew` handler filled it or when the tab holds only the terminal [diff_inline.lua:M.setup_inline_diff] [diff_inline.lua:create_fallback_editor_window] [diff_inline.lua:shows_untouched_tabnew_buffer] +- A `unified` diff needs `vim.text.diff` or `vim.diff` (Neovim 0.9 or later) and raises `-32000` without one. Its proposal buffer, ` (unified diff)`, has `'modifiable'` off, and accepting sends the proposal lines that setup computed, not the buffer's text. [diff_inline.lua:M.setup_inline_diff] [diff_inline.lua:M.resolve_inline_as_saved] - A new diff with an existing tab name first rejects the old diff (if pending), then cleans it up. This runs before the coroutine check, so a call outside a coroutine still removes the old diff [diff.lua:M.open_diff_blocking] - Every `active_diffs` entry gets an `open_order` from `_register_diff_state`. The bulk closes sort by it [diff.lua:M._register_diff_state] [diff.lua:close_active_diffs] - The native proposal buffer is an unlisted `acwrite` scratch buffer named ` (proposed)`, or ` (NEW FILE - proposed)`. A new file's original side is a read-only `nofile` placeholder named ` (NEW FILE)`. The proposal buffer carries `b:claudecode_diff_tab_name`, which `:ClaudeCodeDiffAccept` and `:ClaudeCodeDiffDeny` read [diff.lua:M._setup_blocking_diff] [diff.lua:load_original_buffer] [diff.lua:M.accept_current_diff] - A diff that resolves before `open_diff_blocking` yields, for example from a `ClaudeCodeDiffOpened` handler that runs `:ClaudeCodeDiffDeny`, is returned at once as the openDiff reply instead of a deferred one [diff.lua:M.open_diff_blocking] - A diff never splits the preview window while the Claude terminal is a tiled window in the same tab. It splits the terminal's window instead, because the preview window keeps its `'previewheight'` lines when it is split, and a vertical split gives the new window that height too. A `vertical` or `unified` diff then opens right of the terminal, so with the preview window left of the terminal, the terminal sits between the preview window and the diff. A `horizontal` diff opens below the terminal. With a floating or hidden Claude terminal, a diff in a tab with only the preview window splits the preview window. The legacy `open_diff` never opens the old file in the preview window either [diff.lua:leave_preview_window] [diff_inline.lua:create_fallback_editor_window] [diff.lua:M._open_native_diff] - A `unified` diff keeps the Claude terminal's width, with two exceptions: the terminal in a new tab, and a terminal whose window it splits (the old width includes the columns the diff took). It sizes those as the other layouts do: `terminal.diff_split_width_percentage` of the columns, else `split_width_percentage`, and not at all with `diff_opts.auto_resize_terminal = false` [diff_inline.lua:M.setup_inline_diff] [diff.lua:resize_terminal_for_diff] -- A width percentage counts only when it is a number between 0 and 1. Otherwise the diff uses 0.30. A floating terminal is never resized [diff.lua:resolve_split_width_percentage] [diff.lua:resize_terminal_for_diff] +- A width percentage counts only when it is a number between 0 and 1, both excluded. An invalid `diff_split_width_percentage` falls back to `split_width_percentage`, and an invalid `split_width_percentage` to 0.30. A floating terminal is never resized [diff.lua:resolve_split_width_percentage] [diff.lua:resize_terminal_for_diff] - Closing a diff gives the windows of the tab it split back the sizes they had before the diff, because the diff's `wincmd =` and the closing of its windows resize them. When two diffs split one tab, the sizes are those from before the first, and the last diff to close gives them back. Nothing is resized when the tab's window layout, or the space its windows fill (screen size, tab line), changed during the diff [diff.lua:restore_window_sizes] - The restored sizes include a Claude terminal in that tab, so a width the user gave it stays. A diff never resizes a Claude terminal outside its own tab, so that one keeps its width too. Only when the sizes cannot come back does a `vertical` or `horizontal` diff set the Claude terminal, in any tab, to `split_width_percentage` of the columns, and not at all with `diff_opts.auto_resize_terminal = false`. A diff in its own tab sets no width when it closes: its terminal window closes with its tab, and a Claude terminal the close has to show again opens at `split_width_percentage` [diff.lua:M._cleanup_diff_state]. A `unified` diff differs: its cleanup sets the Claude terminal back to the width it had before setup, also after it shows the terminal again for a diff in its own tab, and it does not check `auto_resize_terminal` [diff_inline.lua:M.cleanup_inline_diff] - Without `diff_opts.keep_terminal_focus`, a diff that opens in the tab of a floating Claude terminal hides the float, because a float cannot make room for a diff and would cover it. This is true for both layouts. The diff decides from the windows at that moment: it calls `hide_float(tab)` with the diff's tab when any window of the terminal in the diff's tab is a float, hidden or not, and it keeps no state. Of the built-in providers, only `snacks` hides a float, through `hide_float`, and it hides the float it tracks only when that float is visible and in that tab. A custom provider can implement it, and one that ignores `tab` hides its float in any tab. With `snacks`, a float in another tab, a float the user hid, and a terminal that is a split are left alone. A float of the terminal in the diff's tab that the provider does not track stays, and it covers the diff. Nothing shows the float again when the diff closes: the user brings it back with `:ClaudeCodeFocus`. The one exception is a diff in its own tab: when it closes, it calls `ensure_visible` if a window of the terminal was visible in any tab when it opened (`had_terminal_in_original`, set at open from `find_claudecode_terminal_window`, so a terminal split in another diff's new tab counts), and the call shows the terminal when none is visible then (a provider's own `ensure_visible` decides, when it has one). It does not check for other open diffs, so it can show the float over a diff that is still open: diff A hides the float, diff B opens in a new tab, diff C opens in another new tab while B's tab shows the terminal, and the close of C after the close of B shows the float over A. The same call shows a float that the user or another diff hid while this diff was open. A diff that opens in the tab hides the float again when the user brought it back, but a split diff does not open in a tab that already shows a diff: it opens in a new tab and leaves the float alone. The cursor restore on close never goes back into a window hidden with `hide`, so with a float that a diff hid, the cursor goes to the window it was in when the first diff opened, or stays where Neovim put it. `find_claudecode_terminal_window` skips a window hidden with `hide`, so with a hidden float `keep_terminal_focus` leaves the cursor in the diff, and a diff in its own tab shows nothing on close when no window of the terminal was visible in any tab when it opened [diff.lua:hide_terminal_float_for_diff] [diff.lua:M._cleanup_diff_state] [diff_inline.lua:M.cleanup_inline_diff] [diff.lua:find_claudecode_terminal_window] [diff.lua:restore_cursor] [terminal/snacks.lua:M.hide_float] @@ -184,8 +183,8 @@ Module map (direct files): - A diff that has its answer, by either one, clears `'modified'` on its proposed buffer. While the flag stayed set, `:wq` left the window open, and `:qa` stopped with E37 or E162 and moved a window onto the hidden buffer until `close_tab` deleted it [diff.lua:mark_proposal_unmodified] - The cleanup of a diff closes the proposal window, and the window that the split layout opened for the file's old text, only while each still shows its buffer. A window that the user switched to another buffer stays with that buffer. `:edit` turns diff mode off by itself, except when the new buffer is in diff mode in another window, as the file's own buffer is. So the split layout's cleanup ends diff mode only in a kept window that shows the file's own buffer, and leaves a diff mode in a window that shows another buffer, which is the user's. One limit comes from Neovim: the default `closeoff` in `'diffopt'` turns diff mode off in the last window that has it when another diff window closes. That ends a user's diff mode in a kept original-side window when the proposal window closes, and in a kept proposal window when the cleanup closes the window that the plugin opened for the file. A user's diff mode over two or more windows stays. The unified layout needs no cleanup there: it sets `'signcolumn'` to `yes` with local scope, for the proposal buffer only, so the window shows the sign column it had before the diff once the user switches it, and keeps a value the user sets afterwards. Two cases still close the window: a diff in its own tab (`open_in_new_tab`, or a tab that already has a diff) closes the whole tab, and the fallback window that the plugin opens in a tab that holds only the Claude terminal always closes [diff.lua:M._close_proposal_window] [diff.lua:M._cleanup_diff_state] [diff_inline.lua:M.cleanup_inline_diff] [diff_inline.lua:M.setup_inline_diff] - Rejecting a new-file diff that is not in its own tab cleans it up at once when `diff_opts.on_new_file_reject` is `"keep_empty"` (the default), before `close_tab`. A `unified` diff gets no such cleanup [diff.lua:M._resolve_diff_as_rejected] [diff_inline.lua:M.resolve_inline_as_rejected] -- `BufDelete`, `BufUnload`, and `BufWipeout` on the proposed buffer reject a pending diff. `WinClosed` rejects it only when no other window in any tab shows the proposed buffer. The original file's buffer is not watched. A `unified` proposal buffer has `bufhidden=wipe`, so closing its window rejects a pending diff [diff.lua:register_diff_autocmds] [diff_inline.lua:M.setup_inline_diff] -- `close_pending_diffs` and `close_diffs_for_client` close only `pending` diffs. `close_rejected_diffs` closes only `rejected` diffs. `close_all_diffs` closes every diff, except a saved diff that another client owns when it gets a `client_id`. Only `close_all_diffs` passes a `client_id` to `close_diff_by_tab_name`. The other bulk closes do not, so the owner check never keeps one of their diffs [diff.lua:M.close_pending_diffs] [diff.lua:M.close_all_diffs] [diff.lua:close_active_diffs] +- `BufDelete`, `BufUnload`, and `BufWipeout` on the proposed buffer reject a pending diff. `WinClosed` rejects it only when no other window in any tab shows the proposed buffer. The original file's buffer is not watched. The unified layout registers the same autocmds [diff.lua:register_diff_autocmds] [diff_inline.lua:M.setup_inline_diff] +- `close_pending_diffs` and `close_diffs_for_client` close only `pending` diffs. `close_rejected_diffs` closes only `rejected` diffs. `close_all_diffs` closes every diff, except a saved diff that another client owns when it gets a `client_id`. Of the bulk closes, only `close_all_diffs` passes a `client_id` to `close_diff_by_tab_name`. The other bulk closes do not, so the owner check never keeps one of their diffs [diff.lua:M.close_pending_diffs] [diff.lua:M.close_all_diffs] [diff.lua:close_active_diffs] - A bulk close lists its diffs first, and each close fires `ClaudeCodeDiffClosed`. Before each close it reads the entry again: a diff a handler accepted since the list was made stays open when the close selects by status, and one a handler rejected closes as listed. `close_all_diffs` selects no status, so it also closes a diff accepted in the meantime. It closes the newest diff first, not in `pairs()` order, so when each close puts the cursor back, the cursor ends where it was before the oldest of them opened. `:ClaudeCodeCloseAllDiffs` runs three bulk closes, for pending, then rejected, then accepted diffs, so it closes newest first only within each status. With diffs of more than one status, the cursor can end in another window [diff.lua:close_active_diffs] - `:ClaudeCodeCloseAllDiffs` runs `close_pending_diffs`, `close_rejected_diffs`, then `close_saved_diffs` [init.lua:M._create_commands] - `close_saved_diffs` closes a `saved` diff only once its file or the file's buffer holds the accepted text. When the file does not hold it, and the file's buffer has no unsaved changes and an empty `'buftype'`, it first loads the file into its buffer and sets the accepted lines there, unsaved. It never writes the file. The lines follow the buffer's format: in a `dos` buffer it drops a CR before each line break, so `:w` writes every line break as CRLF, LF-only lines of the proposal included. In a `'bomb'` buffer it drops a leading BOM. A text without a final line break turns `'fixeol'` off until the buffer's next write or read. Once the lines are set, it makes the buffer listed and durable: a `'bufhidden'` of `wipe`, `delete`, or `unload` becomes `hide`. When loading the file or setting the lines fails, it wipes a buffer it created, or unloads one it loaded. It checks the unsaved changes and `'buftype'` after it loads the buffer, so a buffer it then refuses stays loaded. It returns the count, each file whose buffer got the text with the command that writes it (`:w`, `:checktime, then :w!` for a `'readonly'` buffer (the `!` also skips the changed-since-reading check, so `:checktime` runs first), or when the file's folder does not exist `:w ++p`, which Neovim 0.8 lacks, so there `:call mkdir(expand('%:p:h'), 'p') | w`) and a `'fileencoding'` that cannot hold the text, and each saved diff it kept with the reason (for `E325`, the next step: open the file with `:split `, answering the swap file prompt if Neovim shows one) [diff.lua:M.close_saved_diffs] @@ -201,20 +200,20 @@ Module map (direct files): - The plugin broadcasts `at_mentioned` (`filePath`, `lineStart`, `lineEnd`) [init.lua:M._broadcast_at_mention] and `selection_changed` [selection.lua:M.send_selection_update] to every connected client - While Claude holds a visual selection, `selection.lua` attaches an `nvim_buf_attach` listener (`on_lines`, `on_reload`, `on_detach`) to its buffer, once per buffer. It detaches itself at the next `on_lines` after Claude holds no selection of that buffer, and a reload with `:edit` or `:edit!` detaches it, so it attaches again [selection.lua:watch_selection_buffer] - `init.lua` feeds `` after a successful `:ClaudeCodeSend` that does not take the visual path, outside a tree buffer. It also feeds `` when `:ClaudeCode` or `:ClaudeCodeFocus` runs in Visual mode [init.lua:M._create_commands] -- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs, so Claude gets no `DIFF_REJECTED` for them -- Each diff that opens in a new tab adds a buffer-local `BufEnter` autocmd (`startinsert`) to the Claude terminal buffer in `ClaudeCodeMCPDiff`, unless `terminal.auto_insert` is `false`, `diff_opts.hide_terminal_in_new_tab` is `true`, or no valid Claude terminal buffer exists. Diff cleanup does not delete it, so these autocmds pile up [diff.lua:display_terminal_in_new_tab] +- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs, but none are left by then: on exit Neovim fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the proposed buffer's `BufUnload` autocmd has already rejected each pending diff [diff.lua:register_diff_autocmds] +- Each diff that opens in a new tab adds a buffer-local `BufEnter` autocmd (`startinsert`) to the Claude terminal buffer in `ClaudeCodeMCPDiff`, unless `terminal.auto_insert` is `false`, `diff_opts.hide_terminal_in_new_tab` is `true`, or no valid Claude terminal buffer exists. A `WinClosed` autocmd deletes it when the terminal window it was made for closes [diff.lua:display_terminal_in_new_tab] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire `User` events. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] - When a blocking diff resolves, `open_diff_blocking` sends the reply through the global `_G.claude_deferred_responses[tostring(co)]`, then sets that entry to nil. Without an entry, it logs an error [diff.lua:M.open_diff_blocking] - The native diff writes each temp file into its own session folder under `stdpath("cache")/claudecode_diffs`, or `claudecode_diffs_fallback` when it cannot create a directory in the first. The folder name includes the Neovim PID, so two running Neovim processes never share one. If both fail, `open_diff` returns `success = false` with an error that names both directories [diff.lua:create_temp_file]. A `BufDelete`/`BufWipeout` autocmd on the proposed buffer cleans up once, even when a wipe fires both events. It deletes the file and then its empty folder with `vim.fn.delete`, and warns once for each delete that fails. When the file delete fails, the folder is not empty, so its delete fails too and both warnings show [diff.lua:cleanup_temp_file]. `_cleanup_diff_state` does not [diff.lua:M._open_native_diff] - `diff_inline.lua` creates the `claudecode_inline_diff` namespace when the module loads. It defines the four `ClaudeCodeInlineDiff*` highlight groups (with `default = true`) [diff_inline.lua:setup_highlights] and sets extmarks [diff_inline.lua:M.render_diff_buffer] - The plugin runs `git rev-parse --show-toplevel` for the git root [cwd.lua:M.git_root], and `health.check` runs ` --version` [health.lua:check_cli] -- `logger` defers each log line through `vim.schedule`. ERROR and WARN go to `vim.notify`. Other levels go to `nvim_echo` [logger.lua:log]. The level is INFO until `setup` runs. `setup` warns at once, not deferred, when `log_level` is missing or invalid, and uses INFO [logger.lua:M.setup] +- `logger` defers each log line through `vim.schedule`. ERROR and WARN go to `vim.notify`. Other levels go to `nvim_echo` [logger.lua:log]. The level is INFO until `setup` runs. `logger.setup` warns at once, not deferred, when `log_level` is missing or invalid, and uses INFO [logger.lua:M.setup]. The plugin's `setup` never reaches that warning: `config.apply` fills in `info` and raises on an invalid `log_level` first [config.lua:M.validate] - `one_line` accepts only `error`, `warn`, and `info`, and asserts on any other level [logger.lua:M.one_line] -- Below log_level debug, `one_line` replaces each run of whitespace with one space. A line wider than `v:echospace` cells drops its component tag and puts `ERROR:` or `WARN:` in place of its level tag (an info line gets none), so the level shows without colors. It drops the `[ClaudeCode]` name too when that would take more than half the width. Then it is cut to the width, ending in `...` when `v:echospace` is at least 3. Below debug, only that line reaches `:messages`: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER" (on Neovim 0.11 and later, while `'messagesopt'` has its default `hit-enter`), so the whole line shows only at log_level debug [logger.lua:M.one_line] -- `M.start` logs the reason a server did not start through `one_line`, for example `No free port in 10000-65535 (EADDRINUSE)`, because `auto_start` runs it while Neovim starts. That line, `[ClaudeCode] [init] [ERROR] No free port in 10000-65535 (EADDRINUSE)`, is 68 cells wide. At 80 columns it fits with Neovim's default `'laststatus'` of 2, and with `'ruler'` and `'showcmd'` off. With `'laststatus'` set to 0 or 1 and `'ruler'` and `'showcmd'` on, `v:echospace` is 51, and `one_line` shows `[ClaudeCode] ERROR: No free port in 10000-65535 ...` [logger.lua:fit_command_line]. `M.start` returns `Server not started: `. When the reason contains `auth`, the message ends with `(authentication related)` [init.lua:M.start] +- Below log_level debug, `one_line` replaces each run of whitespace with one space. A line wider than `v:echospace` cells drops its component tag and puts `ERROR:` or `WARN:` in place of its level tag (an info line gets none), so the level shows without colors. It drops the `[ClaudeCode]` name too when that would take more than half the width. Then it is cut to the width, ending in `...` when `v:echospace` is at least 3. Below debug, only that line reaches `:messages`: Neovim adds a line to the message history only by showing it, and showing a line wider than the command line stops at "Press ENTER" (always before Neovim 0.11, and from 0.11 on while `'messagesopt'` contains its default `hit-enter`), so the whole line shows only at log_level debug [logger.lua:M.one_line] +- `M.start` logs the reason a server did not start through `one_line`, for example `No free port in 10000-65535 (EADDRINUSE)`, because `auto_start` runs it while Neovim starts. That line, `[ClaudeCode] [init] [ERROR] No free port in 10000-65535 (EADDRINUSE)`, is 68 cells wide. At 80 columns it fits with Neovim's default `'laststatus'` of 2, and with `'ruler'` and `'showcmd'` off. With `'laststatus'` set to 0, or to 1 with one window, and `'ruler'` and `'showcmd'` on, `v:echospace` is 51, and `one_line` shows `[ClaudeCode] ERROR: No free port in 10000-65535 ...` [logger.lua:fit_command_line]. `M.start` returns `Server not started: `. When the reason contains `auth`, the message ends with `(authentication related)` [init.lua:M.start] ## Verification - Run: `mise run test` - Targeted: `busted -v tests/unit/init_spec.lua`, `busted -v tests/unit/config_spec.lua`, `busted -v tests/unit/diff_spec.lua`, `busted -v tests/unit/diff_inline_spec.lua`, `busted -v tests/unit/terminal_spec.lua`, `busted -v tests/unit/utils_spec.lua`, `busted -v tests/unit/health_spec.lua`, `busted -v tests/unit/logger_spec.lua`, `busted -v tests/unit/version_spec.lua`, `busted -v tests/unit/at_mention_spec.lua`, `busted -v tests/unit/claudecode_add_command_spec.lua`, `busted -v tests/unit/diff_inline_new_tab_spec.lua`, `busted -v tests/integration/basic_spec.lua` -- Real Neovim: `scripts/run_integration_tests_individually.sh` runs each `tests/integration/*_spec.lua` file in headless Neovim with plenary.nvim and skips `command_args_spec.lua`. It runs `tests/integration/diff_window_choice_spec.lua`, which `busted` only marks as pending +- Real Neovim: `scripts/run_integration_tests_individually.sh` runs each `tests/integration/*_spec.lua` file in headless Neovim with plenary.nvim, `command_args_spec.lua` included. It runs `tests/integration/diff_window_choice_spec.lua`, which `busted` only marks as pending From 1795b49206873e464795164cccf6741e7864de9c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 00:02:23 +0000 Subject: [PATCH 09/13] docs(terminal): correct README claims that the code at main contradicts Native close and get_active_bufnr adopt a reloaded terminal since #464, so drop the sentence that said they never adopt. Add the conditions that probes found for termopen under :silent!, the Snacks window position fallback and the config of an adopted terminal. --- lua/claudecode/terminal/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lua/claudecode/terminal/README.md b/lua/claudecode/terminal/README.md index 95237d7e7..fa186ee2e 100644 --- a/lua/claudecode/terminal/README.md +++ b/lua/claudecode/terminal/README.md @@ -59,7 +59,7 @@ Optional fields (the dispatcher does not require them): - `native`'s `simple_toggle` and `focus_toggle` show a hidden terminal focused. `focus_toggle` hides the terminal only when the cursor is in its window, and focuses it when it is visible elsewhere [terminal/native.lua:M.simple_toggle] [terminal/native.lua:M.focus_toggle] - `native` sets `bufhidden=hide`, so the Claude job survives when its window closes [terminal/native.lua:open_terminal]. Hide closes the window without force, and the buffer stays alive. When the window is the last window, hide does nothing, because Neovim raises `E444` for it [terminal/native.lua:hide_terminal] - `native` sets `filetype=claudecode` on the terminal buffer once, after `termopen` started the job, so a `FileType claudecode` autocmd runs once per terminal with `buftype=terminal`. A failed start sets none, and a hide or show of the same buffer sets none again [terminal/native.lua:open_terminal] -- `native` calls `termopen` through `pcall`. Neovim raises `E475` for a command that is not executable. When `:silent!` is in effect, `termopen` returns `-1` instead, also inside `pcall`. A plain `:silent` or `pcall` alone does not silence the error. `termopen` returns `0` only for invalid arguments, such as an empty argv (`E474`), and only under `:silent!`. When an autocmd deletes the new buffer before `termopen` runs, `native` skips `termopen` and treats it as a failure. When a `TermOpen` autocmd wipes the buffer after `termopen` started the job, the wipe also stops the job, and `native` treats it as a failure. On any of these failures `native` sends one ERROR notification, which includes the error text when `termopen` raised. Its callers (`M.open`, `M.simple_toggle`, and `M.focus_toggle`) send none. `native` also closes the new window, restores the original window, deletes the buffer it created, and clears its state. A buffer that an autocmd swapped into the split stays [terminal/native.lua:open_terminal] +- `native` calls `termopen` through `pcall`. Neovim raises `E475` for a command that is not executable. When `:silent!` is in effect, `termopen` returns `-1` instead, also inside `pcall`. A plain `:silent` or `pcall` alone does not silence the error. Under `:silent!`, `termopen` returns `0` instead for its other errors, such as an empty argv (`E474`), a `cwd` that is not a directory, or a modified buffer. When an autocmd deletes the new buffer before `termopen` runs, `native` skips `termopen` and treats it as a failure. When a `TermOpen` autocmd wipes the buffer after `termopen` started the job, the wipe also stops the job, and `native` treats it as a failure. On any of these failures `native` sends one ERROR notification, which includes the error text when `termopen` raised. Its callers (`M.open`, `M.simple_toggle`, and `M.focus_toggle`) send none. `native` also closes the new window, restores the original window, deletes the buffer it created, and clears its state. A buffer that an autocmd swapped into the split stays [terminal/native.lua:open_terminal] - `native` shows the exit tip once per load of the module when `show_native_term_exit_tip` is set. The flag that limits it is module-local, so a reload of the module resets it [terminal/native.lua:open_terminal] - `native` marks each terminal buffer it starts with `b:claudecode_native_terminal`, before `termopen` runs. It clears the mark when the job exits [terminal/native.lua:open_terminal]. While it tracks no terminal, `open`, `simple_toggle`, `focus_toggle`, `close`, and the first `get_active_bufnr` call of a module copy adopt a marked buffer whose job has not ended, whether or not a window displays @@ -71,19 +71,18 @@ Optional fields (the dispatcher does not require them): terminal that the `snacks` provider marked: the provider choice changed since the reload, and nothing else controls that Claude. The `snacks` provider closes it when its module loads and still finds it. Otherwise `owned.lua` stops the job, waits for `TermClose`, and deletes the buffer. A WARN notification follows once no such terminal runs, and an ERROR log when the buffer survives [terminal/owned.lua:stop_other] [terminal/native.lua:stop_snacks_terminal]. The handler sits in the `ClaudeCodeNativeAdopted` augroup, and each adoption replaces the buffer's earlier one, so a - buffer keeps one handler however many reloads adopt it [terminal/native.lua:adopt_existing_terminal]. `close` and `get_active_bufnr` never adopt, so after a reload `close` does nothing and - `get_active_bufnr` returns `nil` until an `open` or a toggle adopts the terminal [terminal/native.lua:M.close] [terminal/native.lua:M.get_active_bufnr] -- `snacks.is_available` requires snacks.nvim to load and `Snacks.terminal` to exist. It tries to load snacks.nvim once, when the module loads. It checks `Snacks.terminal` again on every call. Without Snacks, `open`, `simple_toggle`, and `focus_toggle` send an ERROR notification, and `close` returns without one [terminal/snacks.lua:is_available] [terminal/snacks.lua:M.open] [terminal/snacks.lua:M.close] + buffer keeps one handler however many reloads adopt it [terminal/native.lua:adopt_existing_terminal] +- `snacks.is_available` requires snacks.nvim to load and `Snacks.terminal` to exist. It tries to load snacks.nvim once, when the module loads. It checks `Snacks.terminal` again on every call. Without Snacks, `open`, `simple_toggle`, and `focus_toggle` send an ERROR notification, and `close` returns without one [terminal/snacks.lua:is_available] [terminal/snacks.lua:M.open] [terminal/snacks.lua:M.close]. The dispatcher checks `is_available` before each call and uses `native` instead, so only a direct call of the provider reaches these checks [terminal.lua:get_provider] - `snacks.focus_toggle` shows and focuses a hidden terminal, hides a focused one, and focuses a visible one that does not have the cursor. A terminal is visible only when its window is valid, not hidden through its config, and shows the terminal buffer [terminal/snacks.lua:M.focus_toggle] [terminal/snacks.lua:cc_is_visible] - `snacks` reuses the tracked instance while `terminal:buf_valid()` is true, except an instance whose Claude exited when `auto_close` is set: the entry forgets that one and starts a new Claude. It passes an argv list from `utils.parse_command`, so no shell expands bracketed model aliases [terminal/snacks.lua:M.open] - `snacks` calls `Snacks.terminal.open` through `pcall`. Snacks shows its window before it calls `termopen`, so `snacks` wraps `on_buf` to capture the instance and then calls any `on_buf` from `snacks_win_opts`. `snacks` sets its state only after `Snacks.terminal.open` returns, so an `open` that an autocmd (for example `BufEnter` or `TermOpen`) calls during that call does nothing, and one Claude runs [terminal/snacks.lua:M.open] `snacks` marks the buffer of each terminal it starts with `b:claudecode_snacks_terminal`, and also writes `b:claudecode_snacks_auto_close` (whether it started with `auto_close`), `b:claudecode_snacks_window` (the resolved window options of its Snacks instance, as plain data without functions, `keys` and `bo`, and with the anchor `win` only for a float that is `relative = "win"`: the wrapper keeps it while that window exists, and Snacks would take the current window otherwise) and, for a float with a backdrop, `b:claudecode_snacks_backdrop` (the window id of that backdrop). Snacks makes a new backdrop each time it re-creates a float, so `snacks` writes the variable again after each re-creation, and clears it when the new float has no backdrop [terminal/snacks.lua:remember_backdrop]. While it tracks no terminal, `open`, `simple_toggle`, `focus_toggle`, `close`, and `get_active_bufnr` take a marked buffer whose job has not ended, whether or not a window shows it. They do not ask `Snacks.terminal.list()`: Snacks keys an instance by its command, directory, env and count, and clears that key when any buffer with that key is wiped, so a Claude that started while an exited one waited for its close would be missing from the list. They wrap the buffer in a new Snacks window (`Snacks.win` with `buf`, shown later, in the `terminal` style so that `q` hides the terminal and does not close it) with the options from the variable, and patch it like an instance that `snacks` started. A split or a float then comes back as it started, also when its window is gone. The wrapper maps its keys to the buffer (`Snacks.win:map`): the `terminal` style's `q` and `gf`, and the keys of the entry's config, functions included. The maps of the first start close over the instance that started the Claude, and once the wrapper has hidden and shown the terminal that instance has a stale window, so its `q` would do nothing and its `gf` would leave the file in Claude's window. An entry without a config (`close`, `get_active_bufnr`) maps no key, because a map without the user's keys would turn on a key that the user turned off (Snacks never removes a map), and a hide or a show needs the maps of the wrapper. The next entry that has a config maps them, and gives the wrapper the window options of a terminal that an older version started [terminal/snacks.lua:wrapper_options] [terminal/snacks.lua:upgrade_wrapper]. When it adopts, the wrapper takes a window that shows the buffer and that Snacks made for it, one that carries the `snacks_win` marker (a window that `nvim_win_set_buf` shows, such as the copy of a diff tab, does not): one of the current tab first, then one of another tab, and then an unmarked window of the current tab (a split that an older version re-created). An unmarked window of another tab counts for nothing. After that the wrapper tracks only the window that it hides and shows itself, as an instance that `snacks` started does, so a hide and a show never mistake a copy for the terminal's window [terminal/snacks.lua:find_window]. A wrapper starts without a `Win:show`, and `cc_show` re-creates a split without one (a float goes through `Win:show`, which registers its events), so the wrapper registers what `Win:show` registers: its events that do not belong to one window (the resizes), and the `BufWinEnter` handler that moves a file that you open in the terminal's window to another window. It does so once, at its first re-creation of a split. Without them an `:edit` in the window would replace Claude's buffer. It registers no `WinClosed` event, which is bound to the id of one window and which an instance that `snacks` started never has for a re-created window: Snacks' handler would move the cursor to the previous window when the window closes [terminal/snacks.lua:register_window_events]. Three things differ from an instance that `snacks` started itself: (1) a user's `on_win` and `on_buf` callbacks and any function in `snacks_win_opts` other than a key do not run for the wrapped window (a function cannot be stored in a buffer variable, and before the first entry with a config, the user's function keys are those of the first start); (2) the first start's `FileType` is not set again; (3) a terminal that an older version started has none of the variables, so the entry's config decides its window options (a split gets the default winbar) and its `auto_close`, and a float has no backdrop of its own to hide, until Snacks re-creates the float and `snacks` records the new backdrop [terminal/snacks.lua:adopted_config]. The wrapper is a new Snacks instance: the instance that the first start created stays alive for its `TermClose` and `BufWipeout` handlers on that buffer. Each of those calls first forgets a tracked terminal whose job ended, when it started with `auto_close`. So an `open` that runs between the exit and the scheduled close (for example from a `TermClose` autocmd that runs before the provider's handler, or after a reload, where the adopted terminal has only the earlier copy's handler) starts a new Claude instead of showing the dead terminal. Nothing is wiped there, because a wipe while Neovim dispatches `TermClose` would delete the buffer-local handlers that remain for that event [terminal/snacks.lua:forget_ended_terminal]. Before `snacks` starts a terminal, and when `close` finds none, it stops a live terminal that the `native` provider marked, the same way and with the same notification [terminal/owned.lua:stop_other] [terminal/snacks.lua:stop_native_terminal]. A start fails when `termopen` raises (for example `E475` for a command that is not executable) or when the buffer is not a terminal afterwards (`termopen` returns `-1` when errors are silenced). On a failed start `snacks` closes the Snacks window, deletes its scratch buffer, clears its state, and sends one ERROR notification with the first line of the error. The notification shows the command but not the env table. The DEBUG log of the failed start lists the env keys but not their values [terminal/snacks.lua:M.open] - `snacks` forces `auto_close = false` on the Snacks instance. It sets `start_insert` only when focusing and `auto_insert ~= false`. It sets `auto_insert` whenever `auto_insert ~= false`, so a terminal created without focus also enters terminal mode on a later focus [terminal/snacks.lua:build_opts] - `snacks` shows the terminal without focus (`focus = false`) with the caller's window and mode unchanged (#423). A new terminal gets `enter = false` for its creation, and a `snacks_win_opts.enter` still wins. The instance's `opts.enter` is set back to `snacks_win_opts.enter` after the start. A re-created split gets its buffer with `BufEnter` in `'eventignore'` (a `-BufEnter` entry is dropped for that call, and the old value is restored exactly), so the Snacks `auto_insert` autocmd cannot queue a `startinsert`. A float that Snacks re-creates is shown with `opts.enter = false` for that call, restored afterwards, unless `snacks_win_opts.enter` is set [terminal/snacks.lua:cc_show] -- `snacks` gives the Snacks window the default `bo = { filetype = "claudecode" }`. It replaces the `snacks_terminal` filetype of the Snacks terminal style, so a `FileType snacks_terminal` autocmd no longer matches the Claude buffer. `snacks_win_opts` is deep-merged over the default, so `snacks_win_opts.bo.filetype` wins, `"snacks_terminal"` restores the Snacks filetype, and other `bo` keys keep the default filetype. The default also wins over a `bo.filetype` in the user's global snacks.nvim config (`styles.terminal`, `terminal.win`) or in a style named by `snacks_win_opts`, because Snacks ranks the options passed to `Snacks.terminal.open` above them. Snacks applies `bo` when it creates the buffer, before `termopen` (`buftype` is still `nofile`) and before it opens the window, so a window option set in a `FileType` handler does not reach the Claude window when it opens. Snacks applies `bo` again on a `VimResized` event, because snacks.nvim's `Win:on_resize` calls `Win:update`. With the Claude window open, a resize of the editor runs `FileType claudecode` again, and a window option set in that handler then reaches the Claude window. Snacks also applies `bo` again when it re-creates a window itself. `snacks` lets it do that when `snacks_win_opts.position` is not `left`, `right`, `top`, or `bottom` (for example `float`), and for a float that `snacks` hid whose window is gone. On Neovim before 0.10, hiding a float closes its window. A split is re-created with `split` (top or bottom) or `vsplit` (left or right), so `FileType` does not fire again for it [terminal/snacks.lua:build_opts] [terminal/snacks.lua:cc_show] [terminal/snacks.lua:cc_hide] +- `snacks` gives the Snacks window the default `bo = { filetype = "claudecode" }`. It replaces the `snacks_terminal` filetype of the Snacks terminal style, so a `FileType snacks_terminal` autocmd no longer matches the Claude buffer. `snacks_win_opts` is deep-merged over the default, so `snacks_win_opts.bo.filetype` wins, `"snacks_terminal"` restores the Snacks filetype, and other `bo` keys keep the default filetype. The default also wins over a `bo.filetype` in the user's global snacks.nvim config (`styles.terminal`, `terminal.win`) or in a style named by `snacks_win_opts`, because Snacks ranks the options passed to `Snacks.terminal.open` above them. Snacks applies `bo` when it creates the buffer, before `termopen` (`buftype` is still `nofile`) and before it opens the window, so a window option set in a `FileType` handler does not reach the Claude window when it opens. Snacks applies `bo` again on a `VimResized` event, because snacks.nvim's `Win:on_resize` calls `Win:update`. With the Claude window open, a resize of the editor runs `FileType claudecode` again, and a window option set in that handler then reaches the Claude window. Snacks also applies `bo` again when it re-creates a window itself. `snacks` lets it do that when `snacks_win_opts.position` is set to a value other than `left`, `right`, `top`, or `bottom` (for example `float`), and for a float that `snacks` hid whose window is gone. On Neovim before 0.10, hiding a float closes its window. A split is re-created with `split` (top or bottom) or `vsplit` (left or right), so `FileType` does not fire again for it [terminal/snacks.lua:build_opts] [terminal/snacks.lua:cc_show] [terminal/snacks.lua:cc_hide] - `native` and `snacks` map terminal-mode `` on the Claude terminal buffer to `suspend` through `utils.map_terminal_suspend`, once per terminal, and on no other buffer. A failed start maps none, and a hide or show of the same buffer maps none again. The mapping is buffer-local, so it shadows a global terminal-mode `` mapping in that buffer. A buffer-local terminal-mode `` mapping that already exists stays [utils.lua:M.map_terminal_suspend] - `native` maps `` right before it sets the filetype, so a `FileType claudecode` autocmd finds the mapping and can replace or delete it [terminal/native.lua:open_terminal]. `snacks` maps it after `Snacks.terminal.open` returns, which is after Snacks fired `FileType`, ran `on_buf`, and mapped its own keys. A handler can replace the mapping there, but a `vim.keymap.del` in a `FileType` handler finds nothing to delete [terminal/snacks.lua:M.open] - `external` and `none` create no Neovim buffer, so they set no filetype [terminal/external.lua:M.open] [terminal/none.lua:M.open] -- `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc`. The patched `show` and `toggle` always focus and use the config captured at open [terminal/snacks.lua:patch_instance] +- `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc`. The patched `show` and `toggle` always focus and use the config in `term._cc.config`: the config of the `open` that started the terminal, or for an adopted terminal the config of the last entry that passed one [terminal/snacks.lua:patch_instance] [terminal/snacks.lua:adopt_existing_terminal] - `snacks` hides floats with `nvim_win_set_config({hide=true})` only on Neovim 0.10 and later. It also hides the Snacks backdrop window, and show un-hides both. When the cursor is in the float it hides, it runs `wincmd p` to leave it. It closes split windows and re-creates them on show. It closes a split through `pcall`, so hiding the last window does nothing [terminal/snacks.lua:cc_hide] [terminal/snacks.lua:set_backdrop_hidden] - A top or bottom split that `snacks` re-creates takes its height from `snacks_win_opts.height`, else 30% of the editor lines. A left or right split takes its width from `snacks_win_opts.width`, else `split_width_percentage` of the editor columns, and gets the full editor height [terminal/snacks.lua:cc_show] [terminal/snacks.lua:resolve_split_size] - `paste_fix` decides at phase 1 whether a streamed paste targets the managed terminal. It replays the whole paste once as phase -1, inside the captured window if focus moved. When that window is no longer valid, it replays in the current window [terminal/paste_fix.lua:M.install] From 3a0508683a4058d05305c392bdcdfbb872cc750d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 00:04:25 +0000 Subject: [PATCH 10/13] docs(scripts): correct and complete scripts/README.md from the #462 verifier notes Name the CI job of each command and the jobs that Required needs. State the exit-code precedence of check-lib.sh and which modes download the TLC jar (only a plain run and --mutants). Say how a Lua repro exits under nvim -l and which scripts need the repo on runtimepath. List the release runner's stdout and file outputs. List the side effects of the 70 and 277 repros that the file did not name, and why the helper logs No global response sender. --- scripts/README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 27b85fb10..91eb08e3c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,7 +9,7 @@ 3. Release tooling: a release-please wrapper that writes changelog entries with Communique (`release-please-runner.js`, `run-release-please.sh`, `package.json`, `package-lock.json`). `test-release-please-runner.js` holds the tests for `release-please-runner.js`. Among them, a differential test compares fence tracking and heading demotion with commonmark.js on generated documents. It also runs the pinned `communique` binary with the repo's `communique.toml` against a fake Anthropic API, to check that one release-notes submission fits the configured output budget. 4. TLA+ model-check tooling in `tla/`: `check-lib.sh`, the harness that every `.agents/skills/verify-*/check.sh` sources, and `test-check-lib.sh`, its tests. -CI runs seven of these files: `.github/workflows/release-please.yaml` runs `./scripts/run-release-please.sh`, and `.github/workflows/test.yml` runs `scripts/coverage-summary.sh luacov.report.out "$PWD"` after the unit tests, `./scripts/run_integration_tests_individually.sh`, `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, and `scripts/manual_test_helper.lua` (by absolute path, from a directory outside the repo) on Neovim stable and v0.8.0, `npm --prefix scripts run test:release-please-runner` (which runs `test-release-please-runner.js`), and `./scripts/tla/test-check-lib.sh`. The same job also runs every `.agents/skills/verify-*/check.sh`, and each of those sources `scripts/tla/check-lib.sh`. +CI runs seven of these files. The `release-please` job of `.github/workflows/release-please.yaml` runs `./scripts/run-release-please.sh`. `.github/workflows/test.yml` runs the other six in three jobs. The `unit-tests` job runs `npm --prefix scripts run test:release-please-runner` (which runs `test-release-please-runner.js`) before the unit tests, and `scripts/coverage-summary.sh luacov.report.out "$PWD"` after them. The `integration-tests` job runs on Neovim stable and v0.8.0. It runs `./scripts/run_integration_tests_individually.sh` twice (the second time with a `TMPDIR` that holds a space and a `%`), then `nvim --headless -u NONE -c "luafile scripts/repro_issue_238.lua" -c "cquit 2"`, then `scripts/manual_test_helper.lua` by absolute path from a directory outside the repo. The `covered-lock` job runs every `.agents/skills/verify-*/check.sh`, and each of those sources `scripts/tla/check-lib.sh`. Then it runs `scripts/tla/test-check-lib.sh`. The `Required` job needs only `unit-tests` and `integration-tests` [.github/workflows/test.yml:unit-tests, integration-tests, covered-lock, required]. `mise.toml` provisions `websocat` for `repro_issue_238.sh`, `repro_issue_248.sh`, and `repro_issue_277.sh`, `jq` for the repro scripts that parse JSON, and `communique` for the release tooling. It does not provide `agent-tty`, which `repro_issue_70.sh`, `repro_issue_218.sh`, and `repro_issue_277.sh` need. `repro_issue_277.sh` also needs `git` and `perl`, and `repro_issue_70.sh` needs the `claude` CLI [repro_issue_70.sh:MISSING dependency] [repro_issue_218.sh:agent-tty] [repro_issue_277.sh:MISSING dependency]. @@ -21,17 +21,18 @@ For a manual WebSocket check, create an empty out file, then run `mise exec -- n - `run_integration_tests_individually.sh` (no arguments) runs each `tests/integration/*_spec.lua` in its own headless Neovim under `timeout -k 5s 40s` (plenary's `test_file` ends a file at 50 s on its own, without a `TIMEOUT:` line, so the limit plus the kill grace stays under 50 s), and prints totals [run_integration_tests_individually.sh:run_test_file]. - `manual_test_helper.lua` is run with `:luafile` in a Neovim that has the plugin on its `runtimepath`. It calls the `claudecode.tools.open_diff` handler in a coroutine and reports whether it yields [manual_test_helper.lua:test_opendiff_directly]. CI fails unless it opens the repo's `README.md` and the handler yields [.github/workflows/test.yml:Manual openDiff helper works outside the repo]. +- `manual_test_helper.lua` calls the handler itself, not through the server's `tools/call` path, so no response sender for its coroutine is stored in `_G.claude_deferred_responses`. When the diff resolves after the handler yielded (an accept, a reject, or a Neovim exit with the diff still open), the resolution callback resumes the coroutine and the helper prints `openDiff completed with result:`. Then `diff.lua` logs `[ClaudeCode] [diff] [ERROR] No global response sender found for coroutine: thread: 0x...`. For the helper this line is expected and harmless: no client waits for that reply, and the exit code stays 0. The CI step logs it too, because `qa!` rejects the open diff [manual_test_helper.lua:test_opendiff_directly] [diff.lua:open_diff_blocking] [server/init.lua:_setup_deferred_response]. - `coverage-summary.sh ` prints a Markdown summary of the report's Summary table: the Total row as the overall figure, then one row per file with the checkout folder stripped and a mark per threshold (90% and above, 70% and above, below 70%). It exits 0 when it prints the summary, 1 when the report has no file row, no Total row or a repeated path, 2 when the report cannot be read, and 64 for the wrong number of arguments. `tests/unit/coverage_summary_spec.lua` runs it on small reports [coverage-summary.sh:emoji]. ### TLA+ model checks -- `tla/check-lib.sh` is sourced by each `.agents/skills/verify-*/check.sh`. The check sets `SKILL`, `MODEL`, `COVERED`, `SITE_RE`, `FACTS`, `RULES`, `LIVE`, `EXPECT`, `CONSTRAINT` and `tla_constants`, declares its facts, then calls `tla_init "$@"` first and `tla_main "$@"` last. The header of the file lists the contract [check-lib.sh:tla_init, tla_main]. -- The options are `--mutants`, `--keep`, `--record` and `--hash-only`. The exit codes are 0 (pass), 1 (a rule broke), 2 (tool or script error, or a broken `TypeOK`) and 3 (the covered code changed since `covered.lock` was recorded) [check-lib.sh:tla_parse_args, tla_check, tla_mutants, tla_lock]. +- `tla/check-lib.sh` is sourced by each `.agents/skills/verify-*/check.sh`. The check sets `here`, `SKILL` and `MODEL`, then calls `tla_init "$@"`, which parses the options and moves to the repo root. Only then does it call `covered_block`. It sets `COVERED`, `SITE_RE`, `FACTS`, `RULES`, `LIVE`, `EXPECT`, `CONSTRAINT` and `tla_constants`, sets its facts, and calls `tla_main "$@"` last. The header of the file lists the contract [check-lib.sh:tla_init, tla_main]. +- The options are `--mutants`, `--keep`, `--record` and `--hash-only`. The exit codes are 0 (pass), 1 (a rule broke), 2 (tool or script error, or a broken `TypeOK`) and 3 (the covered code changed since `covered.lock` was recorded). When more than one applies, 2 wins over 1, and 1 wins over 3. A usage error, a bad `check.sh` setup or a failed jar fetch exits 2 before any TLC run, even when the lock is stale. A plain run exits 2 at once on a broken `TypeOK` or a TLC error, and exits 1 after its runs when a rule broke. With `--mutants`, a TLC error or a mutant that breaks the wrong rule gives 2, and a guard that no rule catches or a rule that never breaks gives 1. Both modes exit 3 only when nothing broke and the lock is stale, so a stale lock plus a broken rule exits 1. `--hash-only` runs no TLC and exits 0 or 3. `--record` writes the lock and exits 0, stale or not. The lib turns on `set -euo pipefail`, so a failed command or an unset variable in the code of a `check.sh` exits 1, the code for a broken rule. A script that sources the lib and ends without `tla_main` exits 2 when it would exit 0, and keeps any other exit code [check-lib.sh:tla_parse_args, tla_lock, tla_check, tla_mutants, tla_on_exit]. - `tla/test-check-lib.sh [PATH_TO_CHECK_LIB]` builds a toy skill on the lib in a scratch git repo and runs real TLC on a one-variable model. It prints one line per case and a `COUNT cases=N failed=N skipped=N` line, and exits 1 when a case fails [test-check-lib.sh:expect]. ### Issue reproductions -The Lua repros run from the repository root with `nvim --headless -u NONE -l scripts/`, which needs Neovim 0.9 or later. `repro_issue_238.lua` also runs with the `:luafile` command in its entry below, which works on Neovim 0.8. +The Lua repros run from the repository root with `nvim --headless -u NONE -l scripts/`, which needs Neovim 0.9 or later. Each of them except `repro_issue_70_probe.lua` takes the repo root from its own path (two folders up) and puts it on `runtimepath`. So an absolute path works from any directory, but a bare file name run from `scripts/` fails with `module 'claudecode.diff' not found` (or another `claudecode` module). `repro_issue_70_probe.lua` takes the repo root as its first argument instead. Under `nvim -l`, an uncaught Lua error prints an `E5113` error and exits 1, the code that most repros use for "reproduced", so read the output to tell them apart. A script's own `cquit N` or `os.exit(N)` exits N, and a script that ends without either exits 0. `repro_issue_238.lua` also runs with the `:luafile` command in its entry below, which works on Neovim 0.8. In that form an uncaught error falls through to the second `-c` and exits 2, and so does a script that ends without `cquit` [repro_issue_208.lua:repo_root] [repro_issue_70_probe.lua:package.path]. - `repro_issue_70.sh` (#70) checks whether the `claude` CLI connects back to the plugin when an HTTP proxy has no localhost exclusion. It prints `PASS issue #70 reproduced` (exit 0) or `INCONCLUSIVE` (exit 1). It drives `repro_issue_70_probe.lua` [repro_issue_70.sh:start_probe]. It needs `nvim`, `claude`, `agent-tty`, and `jq`, and exits 3 when one is missing [repro_issue_70.sh:MISSING dependency]. - `repro_issue_70_probe.lua [wait_ms]` is a helper that `repro_issue_70.sh` runs, not a standalone repro. It starts the real server with `terminal.provider = "none"` and writes its state to the status file [repro_issue_70_probe.lua:write_status]. @@ -52,8 +53,8 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr ### Release tooling -- `run-release-please.sh [args]` changes to the repo root and runs `node release-please-runner.js`. It uses `scripts/node_modules/release-please` when present, else the `release-please` on `PATH` through `NODE_PATH` [run-release-please.sh:release_please_bin]. -- `node release-please-runner.js [--dry-run]` registers Communique-generated changelog notes and the `simple` release type, then creates releases and release PRs. Without `--dry-run` it prints `release-please: releases=[...] prs=[...]`. With `--dry-run` it prints JSON with `releases` and `pullRequests` (including `changelogPreview`) and writes nothing to GitHub. `--dry-run` still needs `GITHUB_TOKEN` and still reads from the GitHub API. Any error sets exit code 1 [release-please-runner.js:main]. +- `run-release-please.sh [args]` changes to the repo root and runs `node release-please-runner.js` with the same arguments. It uses `scripts/node_modules/release-please` when present, else the `release-please` on `PATH` through `NODE_PATH`. It prints nothing itself. When neither exists, `command -v release-please` fails under `set -e`, and the script exits 1 with no message [run-release-please.sh:release_please_bin]. +- `node release-please-runner.js [--dry-run]` registers Communique-generated changelog notes and the `simple` release type, then creates releases and release PRs. release-please's own logger writes info and debug lines (`✔`, `❯`) to stdout and warnings and errors (`⚠`, `✖`) to stderr. Without `--dry-run`, the runner writes `releases_created` and `release_tags` after it creates releases, then `prs_created`, `pr_branches` and `pr_metadata` after it creates PRs, as `key=value` lines. It appends them to the file that `GITHUB_OUTPUT` names, or prints them to stdout when `GITHUB_OUTPUT` is unset or empty. Last, it prints `release-please: releases=[...] prs=[...]` to stdout. With `--dry-run`, it prints JSON with `releases` and `pullRequests` (including `changelogPreview`) to stdout after the log lines, so stdout is not pure JSON. `--dry-run` writes nothing to GitHub and no `GITHUB_OUTPUT` line. It still needs `GITHUB_TOKEN`, still reads from the GitHub API, and still runs Communique, an LLM call that needs an API key, for each release PR whose default notes have more than one line. The runner copies Communique's stderr to its own stderr, and Prettier's stderr reaches its stderr directly. Communique writes `notes.md` in a `communique-notes-*` folder under the OS temp dir, and the runner removes that folder. Any error prints the error with its stack to stderr and sets exit code 1 [release-please-runner.js:main, writeGithubOutputs, runCommuniqueBinary, createCommuniqueChangelogNotes, formatChangelogMarkdown]. - Environment for `release-please-runner.js`: `GITHUB_TOKEN` and `GITHUB_REPOSITORY` (required), `RELEASE_PLEASE_TARGET_BRANCH`, `GITHUB_OUTPUT`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `COMMUNIQUE_MODEL`, and `PRETTIER_BIN` [release-please-runner.js:requireEnv, assertLlmCredentials, resolvePrettierBin]. - GitHub Actions outputs: `releases_created`, `release_tags`, `prs_created`, `pr_branches`, and `pr_metadata` [release-please-runner.js:formatReleaseOutputs, formatPullRequestOutputs]. - `package.json` is the private package `claudecode.nvim-release-tools`. It pins `prettier` 3.8.4, `release-please` 17.9.0, and `commonmark` 0.31.2 (used only by the runner tests) and defines the `test:release-please-runner` script [package.json:scripts]. `package-lock.json` locks the full dependency tree of `package.json` [package-lock.json:packages]. Both CI workflows install from it with `npm ci --prefix scripts` [.github/workflows/test.yml:npm ci] [.github/workflows/release-please.yaml:npm ci]. @@ -85,15 +86,17 @@ The Lua repros run from the repository root with `nvim --headless -u NONE -l scr - `repro_issue_238.sh`, `repro_issue_248.sh`, and `repro_issue_277.sh` connect to the plugin server on `127.0.0.1` [repro_issue_238.sh:PORT] [repro_issue_248.sh:PORT] [repro_issue_277.sh:PORT]. - `run_integration_tests_individually.sh` creates two `mktemp` files per test (the output and the process group id) and deletes both [run_integration_tests_individually.sh:temp_output, group_file]. - `repro_issue_231.lua` deletes `/__issue_231_repro__.md` [repro_issue_231.lua:try_open_diff]. -- `repro_issue_70.sh` and `repro_issue_277.sh` create `agent-tty` sessions and temp directories, and remove both on exit. They create the temp directory before they install the `EXIT` trap, so an exit 3 for a missing tool leaves that directory behind. `repro_issue_70.sh` launches the real `claude` CLI. Its cleanup runs `pkill -f "repro_issue_70_probe.lua $REPO_ROOT"`, which also kills the probe of another run in the same checkout [repro_issue_70.sh:WORK, cleanup] [repro_issue_277.sh:WORK, cleanup]. +- `repro_issue_70.sh` and `repro_issue_277.sh` make a work dir with `mktemp -d` (`${TMPDIR:-/tmp}/issue70.XXXXXX` or `issue277.XXXXXX`) and an `att-home` folder in it before they check their tools. They install `trap cleanup EXIT` after that check, so an exit 3 for a missing tool leaves the work dir behind. The trap also runs on SIGTERM, SIGINT and SIGHUP. It destroys the `agent-tty` sessions and removes the work dir. agent-tty still leaves an empty socket folder `/tmp/agent-tty/<8 hex digits>` (it ignores `TMPDIR`) and an entry for the deleted home in `${XDG_STATE_HOME:-$HOME/.local/state}/agent-tty/homes.json`, one of each per run. The sessions inherit the caller's environment, so `CLAUDE_CONFIG_DIR` and the `XDG_*` variables reach the Neovim and the `claude` CLI that they run. `repro_issue_70.sh` launches the real `claude` CLI, and it also runs `claude --version` once outside agent-tty. Its cleanup runs `pkill -f "repro_issue_70_probe.lua $REPO_ROOT"`, which also kills the probe of another run in the same checkout [repro_issue_70.sh:WORK, cleanup] [repro_issue_277.sh:WORK, cleanup]. - `repro_issue_70_probe.lua` starts the real server with `auto_start = true`, so the plugin writes a real `PORT.lock` into the user's lock directory. Other Claude CLIs can see that lock file until `claudecode.stop()` removes it at the end [repro_issue_70_probe.lua:claudecode.setup, claudecode.stop]. -- `repro_issue_277.sh` starts Neovim with the `issue-277` fixture, which calls `claudecode.setup` with `auto_start = true`, so the plugin writes a real `PORT.lock` into the user's lock directory while the script runs. Other Claude CLIs can see that lock file until Neovim exits and the plugin removes it [fixtures/issue-277/init.lua:claudecode.setup] [repro_issue_277.sh:NVIM_APPNAME]. -- `repro_issue_277.sh` clones diffview.nvim into `${XDG_DATA_HOME:-$HOME/.local/share}/issue-277/diffview.nvim` when it is absent. It copies artifacts to `${TMPDIR:-/tmp}/issue277-artifacts-latest` when `ISSUE277_ARTIFACTS` is unset [repro_issue_277.sh:DIFFVIEW_DIR]. +- `repro_issue_70.sh` starts one background `nvim --headless -u NONE -l repro_issue_70_probe.lua` per scenario, three in all, with output sent to `/dev/null`. Each probe writes `.json` in the work dir and stops when the script touches `.stop`, after 120 s, or when cleanup sends it SIGTERM. A probe stopped by SIGTERM still removes its `PORT.lock`, but a SIGKILL leaves the lock file behind. Scenarios B and C set `http_proxy`, `https_proxy` and `all_proxy` to `http://127.0.0.1:1`, a closed port, in the agent-tty shell [repro_issue_70.sh:start_probe, run_scenario, DEAD_PROXY]. +- `repro_issue_277.sh` starts Neovim with the `issue-277` fixture in a detached agent-tty session. The fixture calls `claudecode.setup` with `auto_start = true`, so the plugin writes a real `PORT.lock` into the lock directory (`$CLAUDE_CONFIG_DIR/ide`, else `~/.claude/ide`) while the script runs. Other Claude CLIs can see that lock file. Neovim also listens on `nvim.sock` in the work dir. Cleanup runs `agent-tty destroy`, Neovim exits, and the plugin removes the lock. When the script dies by SIGKILL, the trap does not run, so the agent-tty host, both Neovim processes, the lock file and the work dir stay until you run `agent-tty --home /att-home destroy ` [fixtures/issue-277/init.lua:claudecode.setup] [repro_issue_277.sh:NVIM_SOCK, cleanup]. +- `repro_issue_277.sh` clones diffview.nvim with `git clone --depth=1` from GitHub into `${XDG_DATA_HOME:-$HOME/.local/share}/issue-277/diffview.nvim` when that folder is absent, so the first run needs the network. The clone is not pinned, and the script keeps it after exit. Its Neovim runs with `NVIM_APPNAME=issue-277`, so it writes its ShaDa file and logs under `${XDG_STATE_HOME:-$HOME/.local/state}/issue-277/`, and diffview.nvim writes `${XDG_CACHE_HOME:-$HOME/.cache}/issue-277/diffview.log`. The script ends each `nvim --remote-expr` call after 10 s and each `websocat` call after 15 s with `perl -e 'alarm N; exec ...'`, and it sleeps with `perl`, because macOS has no `timeout` [repro_issue_277.sh:DIFFVIEW_DIR, rexpr, mcp_call]. +- When `ISSUE277_ARTIFACTS` is unset, the screen snapshots go to `artifacts/` in the work dir, and cleanup removes them. A run that reaches its summary (exit 0 or 1) first copies them with `cp -R` to `${TMPDIR:-/tmp}/issue277-artifacts-latest`. When that folder already exists, the copy lands in `issue277-artifacts-latest/artifacts/`, and the files at the top level stay from the first run. A setup failure (exit 2) copies nothing. When `ISSUE277_ARTIFACTS` is set, the script creates that folder before its tool check and keeps it [repro_issue_277.sh:ARTIFACTS, snap]. - `repro_issue_218.sh` runs `Lazy! sync`, which downloads fixture plugins. It does not remove its temp directory [repro_issue_218.sh:cleanup]. - Without `--cleanup`, `repro_issue_248.sh` disconnects without `close_tab`. The server then rejects and closes that client's pending diffs, so `:DiffState` shows `active_diffs=0`. With `--cleanup` it calls `closeAllDiffTabs` first [repro_issue_248.sh:CLEANUP]. - When `--out` is omitted, `repro_issue_238.sh` writes the frames to a `mktemp` file `repro238.frames.*` and does not delete it. Its `EXIT` trap removes only the request file [repro_issue_238.sh:OUT, REQ]. - `repro_issue_283.lua` binds TCP sockets on `127.0.0.1`. `repro_issue_283.sh` starts a background Neovim and kills it on exit [repro_issue_283.lua:listener:bind] [repro_issue_283.sh:cleanup]. -- A `check.sh` makes a work dir `${TMPDIR:-/tmp}/.XXXXXX` and removes it on exit, except with `--keep`. `--record` rewrites the skill's `covered.lock`. When the TLC jar is missing, it downloads TLC 1.7.4 to `${XDG_CACHE_HOME:-$HOME/.cache}/claudecode-nvim-tla/` (or to `TLC_JAR`), and it keeps the file only when its sha256 matches the pin [check-lib.sh:tla_init, tla_lock, tla_fetch_jar]. +- A `check.sh` makes a work dir `${TMPDIR:-/tmp}/.XXXXXX` and removes it on exit, except with `--keep`. `--record` rewrites the skill's `covered.lock`. `--record` and `--hash-only` exit in `tla_lock`, before `tla_fetch_jar`, so they never read or download the TLC jar and need no `java`. A plain run and `--mutants` call `tla_fetch_jar` after the lock check. When the jar is missing, it creates the jar's folder and downloads TLC 1.7.4 to `${XDG_CACHE_HOME:-$HOME/.cache}/claudecode-nvim-tla/` (or to `TLC_JAR`) as a `.tmp` file. It moves the file into place only when its sha256 matches the pin. A failed download exits 2 and leaves the empty folder. On every run it also checks the sha256 of a jar that is already there, and exits 2 on a mismatch without deleting the jar [check-lib.sh:tla_main, tla_lock, tla_fetch_jar]. - `test-check-lib.sh` makes one `mktemp -d` directory and removes it on exit. In it, it runs `git init` and TLC [test-check-lib.sh:root]. - `release-please-runner.js` without `--dry-run` creates GitHub tags, releases, and pull requests through the GitHub API [release-please-runner.js:main]. - `release-please-runner.js` spawns `communique generate` with the full environment, API keys included [release-please-runner.js:createCommuniqueChangelogNotes, runCommuniqueBinary]. From 3afcfee0d884afab567e360e5abd6d872a7654b5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 00:20:06 +0000 Subject: [PATCH 11/13] docs: fix main's README text that the verifiers found false or incomplete mock.lua is required by tests/unit/server/mock_spec.lua. The augroup and utils lists miss ClaudeCodeNativeAdopted, ClaudeCodeOpenFileNewFiles and map_terminal_suspend. An empty CLAUDE_CONFIG_DIR uses ~/.claude/ide. The Processes bullet misses the checkhealth --version call and the git rev-parse call. The selection bullet moves below the mention bullets. --- README.md | 8 +++++--- lua/claudecode/README.md | 6 +++--- lua/claudecode/server/README.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a0946aed1..455994c74 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ This plugin creates a WebSocket server that Claude Code CLI connects to, impleme The protocol uses a WebSocket-based variant of MCP (Model Context Protocol) that: 1. Creates a WebSocket server on a random port -2. Writes a lock file to `~/.claude/ide/[port].lock` (or `$CLAUDE_CONFIG_DIR/ide/[port].lock` if `CLAUDE_CONFIG_DIR` is set) with connection info +2. Writes a lock file to `~/.claude/ide/[port].lock` (or `$CLAUDE_CONFIG_DIR/ide/[port].lock` if `CLAUDE_CONFIG_DIR` is set and not empty) with connection info 3. Sets environment variables that tell Claude where to connect 4. Implements MCP tools that Claude can call @@ -1070,7 +1070,7 @@ opts = { ## Troubleshooting - **First stop:** Run `:checkhealth claudecode` — it verifies the Claude CLI is installed, the WebSocket server is running, the lock file exists, and whether Claude is connected -- **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/` (or `$CLAUDE_CONFIG_DIR/ide/` if `CLAUDE_CONFIG_DIR` is set) +- **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/` (or `$CLAUDE_CONFIG_DIR/ide/` if `CLAUDE_CONFIG_DIR` is set and not empty) - **Need debug logs?** Set `log_level = "debug"` in opts - **Terminal issues?** Try `provider = "native"` if using snacks.nvim - **Local installation not working?** If you used `claude migrate-installer`, set `terminal_cmd = "~/.claude/local/claude"` in your config. Check `which claude` vs `ls ~/.claude/local/claude` to verify your installation type. @@ -1153,7 +1153,9 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ [lua/claudecode/selection.lua:M.send_selection_update] and `at_mentioned` [lua/claudecode/init.lua:M.\_broadcast_at_mention]. - Processes: the terminal provider spawns the Claude CLI with `ENABLE_IDE_INTEGRATION`, `FORCE_CODE_TERMINAL`, loopback `no_proxy`/`NO_PROXY`, and, while the server runs, `CLAUDE_CODE_SSE_PORT` - [lua/claudecode/terminal.lua:get_claude_command_and_env]. + [lua/claudecode/terminal.lua:get_claude_command_and_env]. `:checkhealth claudecode` runs the CLI with `--version` + [lua/claudecode/health.lua:check_cli]. With `terminal.git_repo_cwd`, the terminal module runs + `git rev-parse --show-toplevel` [lua/claudecode/cwd.lua:M.git_root]. - Disk: `open_diff` writes diff temp files under `stdpath("cache")/claudecode_diffs`, or `stdpath("cache")/claudecode_diffs_fallback` when a directory cannot be created in the first. If both fail, it returns an error that names both directories [lua/claudecode/diff.lua:create_temp_file]. The `openDiff` tool writes no temp diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index afc551342..712f28615 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -94,7 +94,7 @@ Module map (direct files): - `visual_commands.lua`: `create_visual_command_wrapper`, `exit_visual_and_schedule`, `validate_visual_mode`, `get_visual_range`, `get_tree_state`, `capture_visual_selection_data`, and `get_files_from_visual_selection` [visual_commands.lua:M.create_visual_command_wrapper] - `visual_commands.get_tree_state()` knows only neo-tree (when its window is current), nvim-tree, oil, and netrw. It returns `nil` for `minifiles` and `snacks_picker_list` [visual_commands.lua:M.get_tree_state] - `cwd.lua`: `resolve(term_cfg, ctx)` and `git_root(start_dir)` [cwd.lua:M.resolve]. Nothing in `lua/` calls `cwd.resolve`. `terminal.lua:build_config` has its own copy of the cwd order and calls only `cwd.git_root`, so a change to `resolve` does not change the terminal [terminal.lua:build_config] -- `utils.lua`: `shell_split`, `parse_command`, `expand_tilde`, `normalize_focus`, `selection_type`, `utf8_sequence_length`, `valid_utf8`, `win_set_buf_without_bufenter`, and `when_windows_allowed` [utils.lua:M.parse_command] [utils.lua:M.valid_utf8] [utils.lua:M.win_set_buf_without_bufenter] [utils.lua:M.when_windows_allowed] +- `utils.lua`: `shell_split`, `parse_command`, `expand_tilde`, `normalize_focus`, `selection_type`, `utf8_sequence_length`, `valid_utf8`, `map_terminal_suspend`, `win_set_buf_without_bufenter`, and `when_windows_allowed` [utils.lua:M.parse_command] [utils.lua:M.valid_utf8] [utils.lua:M.map_terminal_suspend] [utils.lua:M.win_set_buf_without_bufenter] [utils.lua:M.when_windows_allowed] - `logger.lua`: `levels`, `setup`, `error`, `warn`, `info`, `one_line`, `debug`, `trace`, and `is_level_enabled` [logger.lua:M.setup] - `health.lua`: `check()`. It checks, in order: Neovim 0.8.0 or later, `require("claudecode")`, that `setup()` ran, the CLI (the first word of `terminal_cmd`, or `claude`) and its `--version`, the terminal provider, then the server, its lock file, and a connected client. It stops after a failed load, a missing `setup()`, or a stopped server [health.lua:M.check] [health.lua:check_cli] [health.lua:check_server] @@ -136,11 +136,11 @@ Module map (direct files): - 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 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] - A mention sent while disconnected is formatted before it is queued, as a connected send is, so it carries the absolute path and a later `:cd` does not change the file it names. A directory mention loses its line numbers. When `_format_path_for_at_mention` raises, `send_at_mention` returns `false` and the error, queues nothing, and opens no terminal. A mention sent while disconnected fires no `ClaudeCodeSendComplete` [init.lua:M.send_at_mention] [init.lua:queue_mention] - A connected send that succeeds calls `terminal.open()` when `focus_after_send` is set, else `terminal.ensure_visible()`, then fires `ClaudeCodeSendComplete` with the formatted path. Success means the mention was accepted, not delivered [init.lua:M.send_at_mention] - Broadcast debouncing is off when `disable_broadcast_debouncing` is set, or when busted is loaded and `enable_broadcast_debouncing_in_tests` is not set [init.lua:M._broadcast_at_mention] - `_format_path_for_at_mention` raises on an empty path. When busted is not loaded, it also raises on a path that is neither readable nor a directory. It returns the absolute path from `fnamemodify(path, ":p")` and cuts no cwd prefix, so with cwd `/a/proj` the path `/a/proj2/x` stays `/a/proj2/x`. It resolves a relative path against the cwd at the call. It puts `./` before a path that starts with `~`, so `~foo` names a file in the cwd, not a home directory. A directory, the cwd included, ends in a path separator (`/` outside Windows). Outside Windows it asserts that the result starts with `/` [init.lua:M._format_path_for_at_mention] +- 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] - Tracking listens to `CursorMoved`, `CursorMovedI`, `BufEnter`, `ModeChanged`, `TextChanged`, `TextChangedI`, and `TextChangedP` [selection.lua:M._create_autocommands] - After Visual mode ends, the visual selection stays the latest selection until the cursor leaves the position it had at the exit, the held text changes, or a buffer other than the Claude terminal is entered. The cursor position then replaces it, at once for a cursor move or a text change, and after the 100 ms debounce for another buffer. A move to the Claude terminal keeps the visual selection [selection.lua:reconcile_held_selection] [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. It runs `update_selection` first, so a changed selection goes out twice [selection.lua:M.send_current_selection] @@ -200,7 +200,7 @@ Module map (direct files): - The plugin broadcasts `at_mentioned` (`filePath`, `lineStart`, `lineEnd`) [init.lua:M._broadcast_at_mention] and `selection_changed` [selection.lua:M.send_selection_update] to every connected client - While Claude holds a visual selection, `selection.lua` attaches an `nvim_buf_attach` listener (`on_lines`, `on_reload`, `on_detach`) to its buffer, once per buffer. It detaches itself at the next `on_lines` after Claude holds no selection of that buffer, and a reload with `:edit` or `:edit!` detaches it, so it attaches again [selection.lua:watch_selection_buffer] - `init.lua` feeds `` after a successful `:ClaudeCodeSend` that does not take the visual path, outside a tree buffer. It also feeds `` when `:ClaudeCode` or `:ClaudeCodeFocus` runs in Visual mode [init.lua:M._create_commands] -- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs, but none are left by then: on exit Neovim fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the proposed buffer's `BufUnload` autocmd has already rejected each pending diff [diff.lua:register_diff_autocmds] +- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], `ClaudeCodeNativeAdopted` [terminal/native.lua:adopt_existing_terminal], `ClaudeCodeOpenFileNewFiles` [tools/open_file.lua:new_file_buffers], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs, but none are left by then: on exit Neovim fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the proposed buffer's `BufUnload` autocmd has already rejected each pending diff [diff.lua:register_diff_autocmds] - Each diff that opens in a new tab adds a buffer-local `BufEnter` autocmd (`startinsert`) to the Claude terminal buffer in `ClaudeCodeMCPDiff`, unless `terminal.auto_insert` is `false`, `diff_opts.hide_terminal_in_new_tab` is `true`, or no valid Claude terminal buffer exists. A `WinClosed` autocmd deletes it when the terminal window it was made for closes [diff.lua:display_terminal_in_new_tab] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire `User` events. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] - When a blocking diff resolves, `open_diff_blocking` sends the reply through the global `_G.claude_deferred_responses[tostring(co)]`, then sets that entry to nil. Without an entry, it logs an error [diff.lua:M.open_diff_blocking] diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index df62bc1e7..948917e2b 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -12,7 +12,7 @@ Layers, top to bottom: 4. `handshake.lua` and `frame.lua`: HTTP upgrade validation and frame encode/decode. 5. `utils.lua`: base64, SHA-1, masking, UTF-8, and constant-time compare helpers. -`mock.lua` is an in-memory stand-in with no sockets. Nothing in `lua/` or `tests/` requires it. +`mock.lua` is an in-memory stand-in with no sockets. Nothing in `lua/` requires it. `tests/unit/server/mock_spec.lua` tests its `broadcast`. ## Interface From 1a4cb31bc9bba762a89cf0680f4a0df57515fe08 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 01:08:57 +0000 Subject: [PATCH 12/13] docs: state the exception to rejecting every pending diff at exit, and fix seven wording findings A new-file diff in the current tab with on_new_file_reject = "keep_empty" deletes its proposal buffer when it is rejected. In Neovim's exit loop that stops BufUnload for the later buffers, so a second native diff reaches the VimLeavePre cleanup unanswered (Neovim 0.8.0 to 0.12.3, diff.lua:1171-1175). One pending diff and the unified layout are not affected. Also: set -e exits with the status of the failed command, the patched snacks toggle hides a visible terminal and focuses nothing, error_data counts when truthy, getCurrentSelection can adopt a Claude terminal after a plugin reload, the snacks_win_ augroup joins the lists, _accumulate joins the seam only when acc is not empty, and the when_windows_allowed citation names its file. --- README.md | 15 +++++++++++---- lua/claudecode/README.md | 2 +- lua/claudecode/server/README.md | 4 ++-- lua/claudecode/terminal/README.md | 4 ++-- lua/claudecode/tools/README.md | 2 +- scripts/README.md | 2 +- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 455994c74..cd1233556 100644 --- a/README.md +++ b/README.md @@ -1139,9 +1139,15 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ [lua/claudecode/diff.lua:M.open_diff_blocking]. Claude's `close_tab` and `closeAllDiffTabs` calls also reject a pending diff [lua/claudecode/diff.lua:M.close_diff_by_tab_name]. A diff that resolves before `openDiff` yields, for example from a `ClaudeCodeDiffOpened` handler, is answered at once [lua/claudecode/diff.lua:M.open_diff_blocking]. When Neovim exits, - it fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the `BufUnload` autocmd of the proposal buffer - rejects its pending diff [lua/claudecode/diff.lua:register_diff_autocmds]. So the `VimLeavePre` cleanup of - `ClaudeCodeMCPDiff` finds none [lua/claudecode/diff.lua:M.\_cleanup_all_active_diffs]. + it fires `BufUnload` for each loaded buffer before `VimLeavePre`. The `BufUnload` autocmd of a proposal buffer rejects + its pending diff [lua/claudecode/diff.lua:register_diff_autocmds], so with one pending diff the `VimLeavePre` cleanup + of `ClaudeCodeMCPDiff` finds none [lua/claudecode/diff.lua:M.\_cleanup_all_active_diffs]. With two or more pending + native diffs, one case breaks this. The reject of a new-file diff in the current tab, with + `diff_opts.on_new_file_reject = "keep_empty"` (the default), deletes its proposal buffer + [lua/claudecode/diff.lua:M.\_resolve_diff_as_rejected] [lua/claudecode/diff.lua:M.\_cleanup_diff_state]. When that + reject runs in the exit loop, Neovim stops firing `BufUnload` for the later buffers, and the `VimLeavePre` cleanup + removes the diffs that are still pending with no answer to Claude. This holds on Neovim 0.8.0 to 0.12.3. The + `"unified"` layout is not affected. ### Global Side Effects @@ -1162,7 +1168,8 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ files [lua/claudecode/diff.lua:M.open_diff_blocking]. The `saveDocument` tool writes an open buffer to its file [lua/claudecode/tools/save_document.lua:handler]. - Editor state: autocmd groups (`ClaudeCodeShutdown`, `ClaudeCodeSelection`, `ClaudeCodeMCPDiff`, - `ClaudeCodeDiffCleanup`, `ClaudeCodeNativeAdopted`, `ClaudeCodeOpenFileNewFiles`), `nvim_buf_attach` change + `ClaudeCodeDiffCleanup`, `ClaudeCodeNativeAdopted`, `ClaudeCodeOpenFileNewFiles`, and `snacks_win_`, one per Snacks terminal whose split the plugin re-creates + [lua/claudecode/terminal/snacks.lua:register_window_events]), `nvim_buf_attach` change listeners on the buffer of a held selection [lua/claudecode/selection.lua:watch_selection_buffer], `User` events (`ClaudeCodeSendComplete`, `ClaudeCodeDiffOpened`, `ClaudeCodeDiffClosed`), the four global `ClaudeCodeInlineDiff*` highlight groups for the `"unified"` diff layout [lua/claudecode/diff_inline.lua:setup_highlights], and a wrapped diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 712f28615..fe8d4ed43 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -200,7 +200,7 @@ Module map (direct files): - The plugin broadcasts `at_mentioned` (`filePath`, `lineStart`, `lineEnd`) [init.lua:M._broadcast_at_mention] and `selection_changed` [selection.lua:M.send_selection_update] to every connected client - While Claude holds a visual selection, `selection.lua` attaches an `nvim_buf_attach` listener (`on_lines`, `on_reload`, `on_detach`) to its buffer, once per buffer. It detaches itself at the next `on_lines` after Claude holds no selection of that buffer, and a reload with `:edit` or `:edit!` detaches it, so it attaches again [selection.lua:watch_selection_buffer] - `init.lua` feeds `` after a successful `:ClaudeCodeSend` that does not take the visual path, outside a tree buffer. It also feeds `` when `:ClaudeCode` or `:ClaudeCodeFocus` runs in Visual mode [init.lua:M._create_commands] -- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], `ClaudeCodeNativeAdopted` [terminal/native.lua:adopt_existing_terminal], `ClaudeCodeOpenFileNewFiles` [tools/open_file.lua:new_file_buffers], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs, but none are left by then: on exit Neovim fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the proposed buffer's `BufUnload` autocmd has already rejected each pending diff [diff.lua:register_diff_autocmds] +- Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], `ClaudeCodeNativeAdopted` [terminal/native.lua:adopt_existing_terminal], `ClaudeCodeOpenFileNewFiles` [tools/open_file.lua:new_file_buffers], `snacks_win_`, one per Snacks terminal whose split the plugin re-creates [terminal/snacks.lua:register_window_events], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs]. That cleanup does not resolve pending diffs. On exit Neovim fires `BufUnload` for each loaded buffer before `VimLeavePre`, and the proposal buffer's `BufUnload` autocmd rejects its pending diff [diff.lua:register_diff_autocmds], so with one pending diff none is left for this cleanup. With two or more pending native diffs, a new-file diff in the current tab with `diff_opts.on_new_file_reject = "keep_empty"` (the default) breaks this: its reject deletes its proposal buffer [diff.lua:M._cleanup_diff_state], Neovim stops firing `BufUnload` for the later buffers, and this cleanup removes the diffs that are still pending with no answer to Claude (Neovim 0.8.0 to 0.12.3). The `unified` layout is not affected - Each diff that opens in a new tab adds a buffer-local `BufEnter` autocmd (`startinsert`) to the Claude terminal buffer in `ClaudeCodeMCPDiff`, unless `terminal.auto_insert` is `false`, `diff_opts.hide_terminal_in_new_tab` is `true`, or no valid Claude terminal buffer exists. A `WinClosed` autocmd deletes it when the terminal window it was made for closes [diff.lua:display_terminal_in_new_tab] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire `User` events. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] - When a blocking diff resolves, `open_diff_blocking` sends the reply through the global `_G.claude_deferred_responses[tostring(co)]`, then sets that entry to nil. Without an entry, it logs an error [diff.lua:M.open_diff_blocking] diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 948917e2b..48ff50fd7 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -25,7 +25,7 @@ Layers, top to bottom: - `initialize` returns `protocolVersion = "2024-11-05"` (`MCP_PROTOCOL_VERSION`), `capabilities` (logging, `prompts.listChanged`, `tools.listChanged`), `serverInfo.name = "claudecode-neovim"`, and `serverInfo.version = require("claudecode").version:string()` [server/init.lua:MCP_PROTOCOL_VERSION] [server/init.lua:M.register_handlers] - `tools/call` checks the shape of its params first, then delegates to `tools.handle_invoke` and returns a `_deferred` table (`{ _deferred = true, coroutine, client, params }`) for blocking tools [server/init.lua:M.register_handlers] -- Handler contract: a request handler returns `result, error_data`. A non-nil `error_data` becomes the error reply [server/init.lua:M._handle_request] +- Handler contract: a request handler returns `result, error_data`. A truthy `error_data` becomes the error reply [server/init.lua:M._handle_request] - `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, 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] @@ -113,7 +113,7 @@ Layers, top to bottom: - `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] - 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 and the client completed its handshake. A client without a completed handshake ran no tool, so it owns no diff. The cleanup runs through `utils.when_windows_allowed`: in the command-line window or under textlock it waits, and it runs once Neovim allows window changes again. One 50 ms timer checks for every waiting cleanup [server/init.lua:M.start] [utils.lua:M.when_windows_allowed] +- On disconnect, the server schedules `diff.close_diffs_for_client(client.id, "client disconnected")`, only when `package.loaded["claudecode.diff"]` is set and the client completed its handshake. A client without a completed handshake ran no tool, so it owns no diff. The cleanup runs through `utils.when_windows_allowed`: in the command-line window or under textlock it waits, and it runs once Neovim allows window changes again. One 50 ms timer checks for every waiting cleanup [server/init.lua:M.start] [lua/claudecode/utils.lua:M.when_windows_allowed] - `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] - `tools/call` debug lines name the tool and list the argument and result keys with their sizes (string bytes, table entry counts), never their values, so file contents and selected text stay out of `:messages`. An error result logs its code and message. A rejected call logs one line, `Rejected tools/call: ()`, and none of the others [server/init.lua:M.register_handlers] diff --git a/lua/claudecode/terminal/README.md b/lua/claudecode/terminal/README.md index fa186ee2e..3b6be2137 100644 --- a/lua/claudecode/terminal/README.md +++ b/lua/claudecode/terminal/README.md @@ -43,7 +43,7 @@ Optional fields (the dispatcher does not require them): - `should_enable(opt) -> boolean`: `true` and `false` map to themselves. Any other value (`"auto"`, `nil`, or another string) uses `is_affected_version()` [terminal/paste_fix.lua:M.should_enable] - `install()`: installs the `vim.paste` wrapper once (idempotent) [terminal/paste_fix.lua:M.install] - `apply(opt)`: sets the enabled flag and installs when enabled. It never uninstalls, so `apply(false)` after an install leaves the wrapper in place as a pass-through [terminal/paste_fix.lua:M.apply] -- `_accumulate(acc, lines)`: appends one streamed chunk to `acc` in place and joins `lines[1]` onto the last element, the mid-line seam. The installed wrapper calls it for each buffered phase [terminal/paste_fix.lua:M._accumulate] [terminal/paste_fix.lua:M.install] +- `_accumulate(acc, lines)`: appends one streamed chunk to `acc` in place. When `acc` is not empty, it joins `lines[1]` onto the last element, the mid-line seam. An empty `lines` adds nothing. The installed wrapper calls it for each buffered phase [terminal/paste_fix.lua:M._accumulate] [terminal/paste_fix.lua:M.install] - Test helpers: `_is_installed()` and `_is_enabled()` [terminal/paste_fix.lua:M._is_installed] ## Invariants @@ -82,7 +82,7 @@ Optional fields (the dispatcher does not require them): - `native` and `snacks` map terminal-mode `` on the Claude terminal buffer to `suspend` through `utils.map_terminal_suspend`, once per terminal, and on no other buffer. A failed start maps none, and a hide or show of the same buffer maps none again. The mapping is buffer-local, so it shadows a global terminal-mode `` mapping in that buffer. A buffer-local terminal-mode `` mapping that already exists stays [utils.lua:M.map_terminal_suspend] - `native` maps `` right before it sets the filetype, so a `FileType claudecode` autocmd finds the mapping and can replace or delete it [terminal/native.lua:open_terminal]. `snacks` maps it after `Snacks.terminal.open` returns, which is after Snacks fired `FileType`, ran `on_buf`, and mapped its own keys. A handler can replace the mapping there, but a `vim.keymap.del` in a `FileType` handler finds nothing to delete [terminal/snacks.lua:M.open] - `external` and `none` create no Neovim buffer, so they set no filetype [terminal/external.lua:M.open] [terminal/none.lua:M.open] -- `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc`. The patched `show` and `toggle` always focus and use the config in `term._cc.config`: the config of the `open` that started the terminal, or for an adopted terminal the config of the last entry that passed one [terminal/snacks.lua:patch_instance] [terminal/snacks.lua:adopt_existing_terminal] +- `snacks` overrides the instance's `hide`, `show`, and `toggle` methods and keeps the originals in `term._cc`. The patched `show` always focuses, and the patched `toggle` focuses only when it shows the terminal (it hides a visible one and focuses nothing). Both use the config in `term._cc.config`: the config of the `open` that started the terminal, or for an adopted terminal the config of the last entry that passed one [terminal/snacks.lua:patch_instance] [terminal/snacks.lua:adopt_existing_terminal] - `snacks` hides floats with `nvim_win_set_config({hide=true})` only on Neovim 0.10 and later. It also hides the Snacks backdrop window, and show un-hides both. When the cursor is in the float it hides, it runs `wincmd p` to leave it. It closes split windows and re-creates them on show. It closes a split through `pcall`, so hiding the last window does nothing [terminal/snacks.lua:cc_hide] [terminal/snacks.lua:set_backdrop_hidden] - A top or bottom split that `snacks` re-creates takes its height from `snacks_win_opts.height`, else 30% of the editor lines. A left or right split takes its width from `snacks_win_opts.width`, else `split_width_percentage` of the editor columns, and gets the full editor height [terminal/snacks.lua:cc_show] [terminal/snacks.lua:resolve_split_size] - `paste_fix` decides at phase 1 whether a streamed paste targets the managed terminal. It replays the whole paste once as phase -1, inside the captured window if focus moved. When that window is no longer valid, it replays in the current window [terminal/paste_fix.lua:M.install] diff --git a/lua/claudecode/tools/README.md b/lua/claudecode/tools/README.md index 989717b34..589948657 100644 --- a/lua/claudecode/tools/README.md +++ b/lua/claudecode/tools/README.md @@ -74,7 +74,7 @@ Sources: [tools/init.lua:M.register_all], [tools/close_tab.lua:schema], [tools/o - `close_tab` calls `diff.close_diff_by_tab_name` with the tab name and the calling client. It closes or deletes no other buffer, whatever the name [tools/close_tab.lua:handler] - Both close tools resolve a pending diff as rejected first, which resumes the waiting `openDiff` with `DIFF_REJECTED`. When an accepted diff closes, the buffers of its file that have no unsaved changes load the file again 100 ms later [diff.lua:M.close_diff_by_tab_name] - Loading `open_file.lua` creates the `ClaudeCodeOpenFileNewFiles` augroup (`BufNewFile`, `BufReadPost`/`BufUnload`, and `BufWritePost` autocmds) that tracks buffers opened before their file existed, and marks each loaded buffer that has a name and an empty 'buftype' and whose file is not readable at that time [tools/open_file.lua:new_file_buffers] -- `getCurrentSelection`, `getLatestSelection`, `getOpenEditors`, `getWorkspaceFolders`, and `checkDocumentDirty` only read editor state [tools/get_open_editors.lua:handler] +- `getCurrentSelection`, `getLatestSelection`, `getOpenEditors`, `getWorkspaceFolders`, and `checkDocumentDirty` only read editor state, with one exception. When no selection is stored, `getCurrentSelection` calls `selection.is_claude_terminal_buffer`, whose call to `terminal.get_active_terminal_bufnr()` can adopt a Claude terminal that an earlier copy of the plugin started (the first call after a plugin reload). The `native` provider then adds a `TermClose` autocmd to that buffer, and the `snacks` provider patches the instance [tools/get_open_editors.lua:handler] [tools/get_current_selection.lua:handler] [selection.lua:M.is_claude_terminal_buffer] [terminal/native.lua:M.get_active_bufnr] [terminal/snacks.lua:M.get_active_bufnr] - `getDiagnostics` reads editor state and writes debug log entries through `claudecode.logger` [tools/get_diagnostics.lua:handler] - `handle_invoke` writes debug log entries through `claudecode.logger` on the `requires_coroutine` path [tools/init.lua:M.handle_invoke] - `close_tab` writes debug and error log entries through `claudecode.logger` [tools/close_tab.lua:handler] diff --git a/scripts/README.md b/scripts/README.md index 950e1dd39..ee61e5c6a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -27,7 +27,7 @@ For a manual WebSocket check, create an empty out file, then run `mise exec -- n ### TLA+ model checks - `tla/check-lib.sh` is sourced by each `.agents/skills/verify-*/check.sh`. The check sets `here`, `SKILL` and `MODEL`, then calls `tla_init "$@"`, which parses the options and moves to the repo root. Only then does it call `covered_block`. It sets `COVERED`, `SITE_RE`, `FACTS`, `RULES`, `LIVE`, `EXPECT`, `CONSTRAINT` and `tla_constants`, sets its facts, and calls `tla_main "$@"` last. The header of the file lists the contract [check-lib.sh:tla_init, tla_main]. -- The options are `--mutants`, `--keep`, `--record` and `--hash-only`. The exit codes are 0 (pass), 1 (a rule broke), 2 (tool or script error, or a broken `TypeOK`) and 3 (the covered code changed since `covered.lock` was recorded). When more than one applies, 2 wins over 1, and 1 wins over 3. A usage error, a bad `check.sh` setup or a failed jar fetch exits 2 before any TLC run, even when the lock is stale. A plain run exits 2 at once on a broken `TypeOK` or a TLC error, and exits 1 after its runs when a rule broke. With `--mutants`, a TLC error or a mutant that breaks the wrong rule gives 2, and a guard that no rule catches or a rule that never breaks gives 1. Both modes exit 3 only when nothing broke and the lock is stale, so a stale lock plus a broken rule exits 1. `--hash-only` runs no TLC and exits 0 or 3. `--record` writes the lock and exits 0, stale or not. The lib turns on `set -euo pipefail`, so a failed command or an unset variable in the code of a `check.sh` exits 1, the code for a broken rule. A script that sources the lib and ends without `tla_main` exits 2 when it would exit 0, and keeps any other exit code [check-lib.sh:tla_parse_args, tla_lock, tla_check, tla_mutants, tla_on_exit]. +- The options are `--mutants`, `--keep`, `--record` and `--hash-only`. The exit codes are 0 (pass), 1 (a rule broke), 2 (tool or script error, or a broken `TypeOK`) and 3 (the covered code changed since `covered.lock` was recorded). When more than one applies, 2 wins over 1, and 1 wins over 3. A usage error, a bad `check.sh` setup or a failed jar fetch exits 2 before any TLC run, even when the lock is stale. A plain run exits 2 at once on a broken `TypeOK` or a TLC error, and exits 1 after its runs when a rule broke. With `--mutants`, a TLC error or a mutant that breaks the wrong rule gives 2, and a guard that no rule catches or a rule that never breaks gives 1. Both modes exit 3 only when nothing broke and the lock is stale, so a stale lock plus a broken rule exits 1. `--hash-only` runs no TLC and exits 0 or 3. `--record` writes the lock and exits 0, stale or not. The lib turns on `set -euo pipefail`. In the code of a `check.sh`, a failed command exits with that command's status (often 1, the code for a broken rule), and an unset variable exits 1. A script that sources the lib and ends without `tla_main` exits 2 when it would exit 0, and keeps any other exit code [check-lib.sh:tla_parse_args, tla_lock, tla_check, tla_mutants, tla_on_exit]. - `tla/test-check-lib.sh [PATH_TO_CHECK_LIB]` builds a toy skill on the lib in a scratch git repo and runs real TLC on a one-variable model. It prints one line per case and a `COUNT cases=N failed=N skipped=N` line, and exits 1 when a case fails [test-check-lib.sh:expect]. ### Issue reproductions From e3e48af1156359343be5e90281254cd517d882b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 02:17:14 +0000 Subject: [PATCH 13/13] docs: qualify the lock file removal and make the headless check work in a path with a space Codex P2 findings on 42ef1bd: - stop tries to remove the lock file and only logs a warning when it cannot (init.lua M.stop). A token mismatch in start does the same. - The headless auto-setup check used --cmd "set rtp^=$PWD", which Ex splits at a space in the checkout path (E518). The lua form takes the path as a string. --- README.md | 8 ++++---- plugin/README.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 799112cfc..e1fa14220 100644 --- a/README.md +++ b/README.md @@ -1123,8 +1123,8 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ - The server listens only on `127.0.0.1` [lua/claudecode/server/tcp.lua:M.\_bind_and_listen]. - A WebSocket upgrade must carry `x-claude-code-ide-authorization` that matches the lock-file token, compared in constant time [lua/claudecode/server/handshake.lua:M.validate_upgrade_request]. `start` returns `false` before it starts the server - if the token is shorter than 10 characters. It removes the lock file and stops the server if the token does not - match the token that `lockfile.create` wrote [lua/claudecode/init.lua:M.start]. + if the token is shorter than 10 characters. If the token does not match the token that `lockfile.create` wrote, it tries to remove the lock file (a failed removal is only logged) and stops the + server [lua/claudecode/init.lua:M.start]. - The token comes from `vim.loop.random` or `/dev/urandom`, never `math.random` [lua/claudecode/lockfile.lua:get_random_bytes]. - Lock files live in `$CLAUDE_CONFIG_DIR/ide`, or in `~/.claude/ide` when that variable is unset or empty, named `.lock` [lua/claudecode/lockfile.lua:M.lock_dir]. `repro_issue_238.sh`, `repro_issue_248.sh`, and the verify skill's `ws-client.mjs` read the port from that basename [scripts/repro_issue_238.sh:PORT] [scripts/repro_issue_248.sh:PORT] @@ -1151,8 +1151,8 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ ### Global Side Effects -- Lock file: `start` writes `.lock` (file `0600`, atomic rename). A new lock directory gets `0700`, and the chmod of an existing one is best-effort [lua/claudecode/lockfile.lua:M.create]. `stop` deletes it - [lua/claudecode/lockfile.lua:M.remove]. The `ClaudeCodeShutdown` `VimLeavePre` autocmd runs `stop` +- Lock file: `start` writes `.lock` (file `0600`, atomic rename). A new lock directory gets `0700`, and the chmod of an existing one is best-effort [lua/claudecode/lockfile.lua:M.create]. `stop` tries to delete it and logs a warning when it cannot + [lua/claudecode/lockfile.lua:M.remove] [lua/claudecode/init.lua:M.stop]. The `ClaudeCodeShutdown` `VimLeavePre` autocmd runs `stop` [lua/claudecode/init.lua:M.setup]. - Network: one loopback TCP listener [lua/claudecode/server/init.lua:M.start] plus WebSocket frames and JSON-RPC broadcasts [lua/claudecode/server/init.lua:M.broadcast]: `selection_changed` diff --git a/plugin/README.md b/plugin/README.md index 613b76a99..e33cccc1c 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -117,7 +117,7 @@ when `require("claudecode").setup()` runs. The main module lives in `--cmd 'let g:claudecode_auto_setup ...'` line: ```sh - nvim --headless -u NORC -i NONE --cmd "set rtp^=$PWD" \ + nvim --headless -u NORC -i NONE --cmd "lua vim.opt.rtp:prepend(vim.fn.getcwd())" \ --cmd 'let g:claudecode_auto_setup = {"auto_start": v:false}' \ -c 'lua vim.defer_fn(function() io.stdout:write(vim.fn.exists(":ClaudeCode") .. "\n") vim.cmd("qa!") end, 100)' ```