diff --git a/README.md b/README.md index acc46a293..e1fa14220 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. @@ -1084,8 +1084,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 @@ -1122,38 +1123,65 @@ 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 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 else `~/.claude/ide`, 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] [.agents/skills/verify-claudecode-nvim/scripts/ws-client.mjs:port]. `repro_issue_70.sh` and `repro_issue_277.sh` get the port another way. +- 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] + [.agents/skills/verify-claudecode-nvim/scripts/ws-client.mjs:port]. `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]. 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`. 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 -- 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` 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` + [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 - 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`), `nvim_buf_attach` change listeners on the buffer of a held selection and on every buffer - where a visual selection starts, until the first edit after the selection ends - [lua/claudecode/selection.lua:watch_selection_buffer], `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`, 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 and on every buffer where a visual selection + starts, until the first edit after the selection ends [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 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]. @@ -1161,9 +1189,10 @@ 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/`) +- 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 2a8703860..c62c9eaff 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 is in state `connected` with `handshake_complete`, so a `closing` client does not count. When it is empty, true when `client_count > 0`, with no handshake check [init.lua:M.is_claude_connected] - `M.send_at_mention(file_path, start_line?, end_line?, context?) -> ok, err?`: line numbers are 0-indexed [init.lua:M.send_at_mention] - `M.process_mention_queue(from_new_connection?)`: the server's `on_open` callback calls it with `true` once a client completed its WebSocket handshake. Without the argument it flushes the queue now. A flush sends the oldest mention only once some connected client has been connected for `connection_wait_delay` ms, and otherwise waits for that moment [init.lua:M.process_mention_queue] -- `M.open_with_model(args)`, `M.get_version()`, and `M.version`, read from the repository's `VERSION` file at load [init.lua:M.version] -- Internal API: `M._create_commands`, `M._broadcast_at_mention`, `M._add_paths_to_claude` (default `max_files` 100, `batch_size` 10), `M._format_path_for_at_mention`, `M._ensure_terminal_visible_if_connected`, and `M._maybe_warn_unfocusable_provider` [init.lua:M._add_paths_to_claude] +- `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 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]`. -- 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 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`. -- 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,40 +75,59 @@ Module map (direct files): ### Other modules - `terminal.lua`: `setup`, `open`, `close`, `simple_toggle`, `focus_toggle`, `toggle`, `toggle_open_no_focus`, `ensure_visible`, `hide_float`, `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. 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] -- `selection.lua` internals: `state`, `debounce_update`, `update_selection`, `handle_selection_demotion`, `get_visual_selection_from_marks`, `flush_visual_selection`, `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_from_marks`, + `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] -- `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.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] -- `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] +- `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`, `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()` [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 removes the lock file, stops the server, and clears its state. When `lockfile.remove` returns an error, it only logs that error as a warning and still returns the mismatch error [init.lua:M.start] - `stop` returns "Not running" without a server. When `lockfile.remove` returns an error, `stop` only logs that error as a warning. `lockfile.remove` returns that error when `os.remove` fails, also when it fails without raising (for example in a read-only directory), so the lock file stays and the failure is logged [init.lua:M.stop] [lockfile.lua:M.remove] +- `stop` tries to remove the lock file and disables selection tracking when `track_selection` is set, then stops the server. When `server.stop` fails, `stop` returns the error and keeps `state.server`, although the removal and the disable already ran [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`), and the warning names it: `Keeping 'auto'`, or `Keeping the custom provider table` when the previous value is a table [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] -- `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 @@ -113,29 +136,44 @@ 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] +- 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] -- 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] +- 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] - A visual selection is flushed on visual exit. The flush is skipped when an operator (`d`, `c`, `>`, `gU`...) changed the buffer's text in visual mode, because the marks then point at text you never selected. A plugin's edit in visual mode and a `:write` do not skip it, although both move the changedtick: Neovim ends visual mode before an operator edits, a plugin's edit runs in visual mode, and a `:write` fires no buffer callback. A reload counts as an operator. Neovim 0.8 and 0.9 end visual mode for the length of an `nvim_buf_set_lines` or `nvim_buf_set_text` call, so the `on_lines` callback only counts the edit and a `vim.schedule`d check reads the mode after the call. The flush skips a selection with an edit that is not checked yet, which is what an operator leaves, because its flush comes first [selection.lua:M.flush_visual_selection] [selection.lua:note_visual_edit] -- `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] +- `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 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] -- 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] +- 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 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, 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] +- 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] - 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] @@ -144,10 +182,12 @@ Module map (direct files): - A pending diff rejects at once when its proposed buffer is shown in no window, in the split and the unified layout: its last window closes (`:q`, `:close`, `c`, `:tabclose`), or that window switches to another buffer (`:edit`, `:buffer`, `:enew`, which only hide it). A copy of the proposal in another window or tab defers the reject until that copy leaves too. The switch path rejects on the next loop turn, and only when the diff is still the same one and still hidden. Both layouts keep the proposed buffer's `'bufhidden'` at `hide`: the buffer is modified, and `wipe` would make `:q` fail with E37 [diff.lua:M._register_reject_when_hidden] [diff_inline.lua:M.setup_inline_diff] - 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] -- `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] +- 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. 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 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 @@ -155,20 +195,25 @@ 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, or logs a warning when it cannot, 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 - While Claude holds a visual selection, and while you select in a buffer (from entering visual mode until the flush), `selection.lua` attaches an `nvim_buf_attach` listener (`on_lines`, `on_reload`, `on_detach`) to that buffer, once per buffer. It does not attach to the Claude terminal buffer, whose output is a stream of edits. It detaches itself at the next `on_lines` after Claude holds no selection of that buffer and you do not select in it, and a reload with `:edit` or `:edit!` detaches it, so it attaches again when the buffer still holds the selection [selection.lua:watch_selection_buffer] -- 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], `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] - 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. `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" (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/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` +- 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 diff --git a/lua/claudecode/server/README.md b/lua/claudecode/server/README.md index 6bca2d239..48ff50fd7 100644 --- a/lua/claudecode/server/README.md +++ b/lua/claudecode/server/README.md @@ -12,18 +12,20 @@ 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 ### 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` checks the shape of its params first, then 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` 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 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] @@ -33,17 +35,25 @@ 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.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 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)`: 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`, 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] - 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, 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 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] +- `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,25 +73,34 @@ 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. 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: " [server/handshake.lua:M.process_handshake] -- 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 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. 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. 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. `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 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] -- 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 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 (it 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 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] +- `_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. 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] @@ -94,20 +113,20 @@ 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] - `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 queues 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 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`, 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 - 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/connection_lifecycle_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 48afc7548..f4a4cbb5f 100644 --- a/lua/claudecode/terminal/README.md +++ b/lua/claudecode/terminal/README.md @@ -25,11 +25,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] - `hide_float(tab?) -> boolean`: hides the terminal when it is a visible floating window, and with `tab` only when that window is in `tab`. It returns `true` when it did. A provider that ignores `tab` hides its float in any tab. `terminal.hide_float(tab)` passes `tab` on and returns `false` for a provider without it. Nothing shows the float again but the user's own terminal commands and the `ensure_visible` call: a diff in its own tab calls it when it closes, and every command that sends an @ mention (`:ClaudeCodeSend`, `:ClaudeCodeAdd`, `:ClaudeCodeTreeAdd`) calls it after a send to a connected Claude. Those commands call `open` instead with `focus_after_send`, or when Claude is not connected. Of the built-in providers, only `snacks` defines it. `diff.lua` calls it with the diff's tab when a window of the terminal in that tab is a float, unless `diff_opts.keep_terminal_focus` is set [terminal/snacks.lua:M.hide_float] [terminal.lua:M.hide_float] - `_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] @@ -39,34 +40,52 @@ 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. 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 - `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. When the tracked window is gone or shows another buffer (for example after `:e file` in it), it recovers `winid` by scanning windows that show the buffer, and it clears `winid` when none does, so the terminal counts as hidden [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` 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, 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`, `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 it, and take its job id from the buffer's `'channel'` option [terminal/owned.lua:find]. `open`, `simple_toggle`, and `focus_toggle` show a hidden adopted terminal. A buffer whose job has not started yet counts as not ended. They never adopt an unmarked terminal, even when its name contains `claude`. Before they look at the tracked terminal, they run the exit handling of a terminal whose job already ended but whose scheduled exit handling has not run yet, so they start a new Claude instead of showing the dead one. That handling uses the `auto_close` of the config that started (or adopted) the job. `get_active_bufnr` returns `nil` for such a terminal [terminal/native.lua:forget_ended_terminal] 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. Before `native` starts a terminal, and when `close` finds none to adopt, it stops a live 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] -- `snacks.is_available` requires snacks.nvim to load and `Snacks.terminal` to exist [terminal/snacks.lua:M.is_available] +- `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 + it, and take its job id from the buffer's `'channel'` option [terminal/owned.lua:find]. `open`, `simple_toggle`, and `focus_toggle` show a hidden adopted terminal. A buffer whose job has not started + yet counts as not ended. They never adopt an unmarked terminal, even when its name contains `claude`. Before they look at the tracked terminal, they run the exit handling of a terminal whose job + already ended but whose scheduled exit handling has not run yet, so they start a new Claude instead of showing the dead one. That handling uses the `auto_close` of the config that started (or + adopted) the job. `get_active_bufnr` returns `nil` for such a terminal [terminal/native.lua:forget_ended_terminal]. 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. Before `native` starts a terminal, and when `close` finds none to adopt, it stops a live + 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] +- `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. 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 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` [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` 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] - `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] - The dispatcher's `setup` accepts a `provider` that is a table or one of `auto`, `snacks`, `native`, `external`, and `none`. It rejects any other value with a WARN notification, `Invalid value for provider: . Keeping .`, and keeps the provider it had. That is `auto` until a valid value is set, and `auto` uses `snacks` when Snacks is available, else `native`. `get_provider` falls back to `native` with a warning for a stored value that `setup` would have rejected, which only a write to `terminal.defaults.provider` can leave [terminal.lua:M.setup] [terminal.lua:get_provider] @@ -74,16 +93,20 @@ 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 gone (wiped), it closes no window. 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. 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` runs `checktime` and logs an ERROR on a nonzero exit, except when `close` stopped the job or the `native` provider stopped it directly (the stop clears the mark first). 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. With `auto_close`, a tracked terminal whose job already exited is forgotten before `close` looks at it, so `close` finds nothing to stop. 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. With `auto_close`, a tracked terminal whose job already exited is forgotten before `close` looks at it, so `close` finds nothing to stop. Hide keeps the job running + [terminal/snacks.lua:M.close] - `native` and `snacks` call `vim.keymap.set` once for the Claude terminal buffer, in terminal mode, for `` [utils.lua:M.map_terminal_suspend]. `:suspend` stops the Neovim TUI process, and a shell resumes it with `fg` - `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 4a44d0be3..589948657 100644 --- a/lua/claudecode/tools/README.md +++ b/lua/claudecode/tools/README.md @@ -11,9 +11,9 @@ - `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.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 runs the tool with `params.arguments`, or with `{}` when the call has none. It expects the params shape that the server's `tools/call` handler checked [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] @@ -39,30 +39,48 @@ 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] +- 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"` [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. 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] -- `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 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 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] -- `getLatestSelection` returns a `success=false` JSON result when no selection exists [tools/get_latest_selection.lua:handler] +- `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] -- `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, and when no diff has that name [tools/close_tab.lua:handler] +- 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 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. 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 - `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. Neovim refuses `:w` of a buffer opened before its file existed once the file exists (E13), and a write of an `acwrite` buffer skips that check. So for such a buffer the step also sets 'readonly', unless it is on already, and turns it off afterwards when it is still on. A write of it without `!` during the step then fails with E45 where Neovim gives E13. With 'confirm', Neovim asks "'readonly' option is set for ... Do you wish to write anyway?" where it would ask "Overwrite existing file ...?", and openFile answers Claude after the user does: yes writes. With 'writeany' or `cpo+=O`, Neovim would write that buffer, but the step still fails with E45. Autocommands of the step see 'readonly' on, one that sets 'readonly' itself is undone, and with 'shortmess' lacking `F` the file message that `:edit` shows during the step names `[readonly]`. A buffer renamed with `:file`, or one read with errors, gets 'readonly' only when openFile also tracks it as opened before its file existed, because no Neovim API tells such a buffer apart. Otherwise it gets no 'readonly' in the only step that runs for it, the one that 'eventignore' or 'eventignorewin' starts, and there the acwrite step skips the E13 that Neovim gives it outside the step. A write that 'readonly' does not stop goes to BufWriteCmd: without `++nested`, or without a user's BufWriteCmd autocommand that matches the file, none runs and the write fails with E676. In or out of openFile, a user's BufWriteCmd autocommand that matches the file, such as `BufWriteCmd *` that writes ``, writes the buffer over Claude's file when a write reaches it, because Neovim runs that autocommand before it checks whether the file changed. A write with `!` reaches it, and so does any write of a buffer whose file existed when the buffer was opened. That is how Neovim works, and openFile does not change it. Without such an autocommand, a write of a buffer with unsaved changes whose file Claude changed is a normal write outside the step. 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 fails and the buffer has no unsaved changes, 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 does not end Visual mode. This holds for an error in any autocommand of the reload, including BufReadPre, which stops the BufReadPre autocommands after it, and FileChangedShell, which Neovim runs before it reads. A buffer with unsaved changes keeps its text. 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] - `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`. 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, 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] ## 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` +- 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 diff --git a/plugin/README.md b/plugin/README.md index 63f8634ed..e33cccc1c 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,37 @@ 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, 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 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 + 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 +74,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 "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)' + ``` diff --git a/scripts/README.md b/scripts/README.md index 98c8df3c9..efe986894 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, and a property test runs the Unreleased update on generated drafts and checks that every older release stays a top-level heading. 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. For each file it sets `VIMINIT` and `CLAUDECODE_MINIMAL_INIT` (the absolute path of `tests/minimal_init.lua`) so the Neovim that plenary starts runs that file, and gives the Neovim it starts and the one plenary starts for the file one pair of `XDG_STATE_HOME` and `XDG_CONFIG_HOME` temp folders to share (the Neovim it starts takes `-i NONE`, so only one of them writes ShaDa) [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`. 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 -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`, and `commonmark` must be on that path too or the runner fails at start-up, before any GitHub call [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`, and `commonmark` must be on that path too or the runner fails at start-up, before any GitHub call. 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 by the runner and its 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 and two `mktemp -d` folders per test (the output, the process group id, and the `XDG_STATE_HOME` and `XDG_CONFIG_HOME` that the file's two Neovims share) and deletes all four after the file ran. When it gets SIGTERM it kills the process group of the running file, deletes them and exits 143. It sets `VIMINIT`, `CLAUDECODE_MINIMAL_INIT`, `XDG_STATE_HOME` and `XDG_CONFIG_HOME` for that file's Neovims and the processes they start only, as a prefix of one command, and leaves `XDG_DATA_HOME` alone because plenary lives there. A run writes nothing under `~/.local/state/nvim`, and the developer's `ftplugin` and `after` files under `~/.config/nvim` do not run [run_integration_tests_individually.sh:temp_output, group_file, state_dir, config_dir]. - `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].