Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Bug Fixes

- The Claude terminal now adds the loopback hosts (`localhost`, `127.0.0.1`, `::1`) to `no_proxy`/`NO_PROXY`, so a configured `http_proxy`/`all_proxy` no longer tunnels Claude's `ws://127.0.0.1` IDE connection and causes queued @ mentions to time out. Existing `no_proxy` exclusions are preserved. ([#70](https://github.com/coder/claudecode.nvim/issues/70))
- `focus_after_send = true` no longer fails silently with `terminal.provider = "none"`/`"external"`: those providers run Claude outside Neovim, so focus cannot move there. A one-time warning is now emitted at setup pointing to the new `User ClaudeCodeSendComplete` autocmd, which you can hook to focus your own terminal. (`focus_after_send` still only auto-focuses the in-editor providers.) ([#228](https://github.com/coder/claudecode.nvim/issues/228))
- Rejecting a Claude diff with `:q` (or `:close` / `<C-w>c` / closing the tab) now resolves it as rejected, matching the documented behavior. The proposed buffer is a scratch buffer that `:q` only hides, so the existing `BufDelete`/`BufUnload`/`BufWipeout` autocmds never fired; a `WinClosed` autocmd now handles window-close rejection. ([#238](https://github.com/coder/claudecode.nvim/issues/238))
- Push quickly-made visual selections to Claude reliably. Selections made and released faster than the selection-tracking debounce were never broadcast, and any selection was wiped shortly after leaving visual mode when Claude runs in an external terminal (the `/ide` flow) — so single-line selections in particular often never reached Claude. Selections are now flushed synchronously on visual-mode exit (from the `'<`/`'>` marks) and persist until the cursor actually moves; a single-line linewise `V` made right after a charwise selection is also no longer mis-extracted to a single character. ([#246](https://github.com/coder/claudecode.nvim/issues/246))
Expand Down
154 changes: 154 additions & 0 deletions fixtures/issue-70/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Issue #70 — "Sending files, current buffer, or lines to claude doesn't work"

> Source: https://github.com/coder/claudecode.nvim/issues/70
>
> Symptom: `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions`

## The one fact behind every report

`:ClaudeCodeSend` (and the file/buffer/tree senders) call `send_at_mention`. When
Claude is not connected, the mention is **queued** and a `connection_timeout`
timer (default **10s**, `lua/claudecode/init.lua`) is armed. If Claude has not
opened a WebSocket back to the plugin's server by then, the queue is cleared with
the error above (`start_connection_timeout_if_needed`).

So the bug is never really "send is broken" — it is always **"the Claude CLI that
the plugin launched never connected back to the plugin's WebSocket server."** The
interesting part is _why_ Claude doesn't connect, and the issue thread contains
several distinct causes that all surface as this one error.

## How the plugin expects Claude to connect

1. The plugin starts a WebSocket server on `127.0.0.1:<port>` and writes
`~/.claude/ide/<port>.lock` containing `{ pid, workspaceFolders, ideName:
"Neovim", transport: "ws", authToken }` (`lua/claudecode/lockfile.lua`).
2. The terminal provider launches Claude with `CLAUDE_CODE_SSE_PORT=<port>` and
`ENABLE_IDE_INTEGRATION=true` in its environment (`lua/claudecode/terminal.lua`).
3. Claude reads `CLAUDE_CODE_SSE_PORT`, looks up the matching lock file for the
auth token, and connects to `ws://127.0.0.1:<port>`.

## What was reproduced (claude 2.1.168, nvim 0.13, macOS)

Driven with the real `claude` CLI in a PTY (agent-tty) against the real plugin
server. A headless probe (`scripts/repro_issue_70_probe.lua`) reports whether
Claude actually opened a socket.

| # | environment | result |
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Baseline** | `CLAUDE_CODE_SSE_PORT` set, no proxy | **connects** (`client_count: 1`), `@` mention delivered |
| **Proxy (the bug)** | `http_proxy`/`all_proxy` set, **no** `no_proxy` | **never connects**; `/ide` → _"Failed to connect to Neovim"_; plugin shows `Connection timeout - clearing 1 queued @ mentions` |
| **Proxy + fix** | same proxy **+** `no_proxy=localhost,127.0.0.1,::1` | **connects** again |

The baseline connects even with several _other_ `~/.claude/ide/*.lock` files
present, so `CLAUDE_CODE_SSE_PORT` reliably disambiguates — the proxy is the only
thing that changes the outcome. This matches a `no_proxy` workaround reported in
the issue thread:

```sh
export no_proxy=localhost,127.0.0.1,::1 # their fix
```

Claude's WebSocket client honors the lowercase `http_proxy`/`all_proxy`
(`proxy-from-env` semantics) and, with no localhost exclusion, tunnels even
`ws://127.0.0.1:<port>` through the proxy — which cannot reach a loopback server.

### Secondary cause seen in the thread (multi-instance / discovery)

Most thread reports ("the 2nd instance fails", "the send is caught by the other
terminal", "only works when Claude is opened in another terminal") come from the
_discovery fallback_ used when `CLAUDE_CODE_SSE_PORT` does **not** reach Claude
(external-terminal provider, shell/tmux wrappers that reset env, older versions):

- With the env var **absent**, current Claude falls back to scanning
`~/.claude/ide/*.lock` and **filters by workspace** (`/ide` literally prints
_"Found N other running IDE(s). However, their workspace/project directories do
not match the current cwd."_).
- With **exactly one** workspace-matching lock file it auto-connects; with **two**
(two Neovim instances in the same project, or a stale lock file) it connects to
**neither** — reproducing the timeout.
- Auto-connect via the env var (`CLAUDE_CODE_SSE_PORT`) or a _single_ unambiguous
workspace match still happens automatically; but the ambiguous-discovery path
surfaces a `/ide` tip — _"You can enable auto-connect to IDE in /config or with
the --ide flag"_ — i.e. discovery-only auto-connect is heuristic/opt-in. A
Claude-side change to that heuristic is a plausible (unverified) explanation for
the "worked, then a Claude update broke it" / "lock file exists but no socket"
reports (one such report cites CC 2.0.42), and is a question for the
deep-research follow-up.

These discovery paths touch global `~/.claude/ide` state shared with other live
sessions, so they are documented here rather than automated. The deterministic,
side-effect-free repro below targets the proxy cause.

## Reproduce it

### Automated (proxy cause, real Claude)

```sh
# from repo root; needs nvim + a logged-in `claude` + agent-tty + jq
bash scripts/repro_issue_70.sh
```

Expected tail:

```
[A_baseline] PASS (... expected=connect, got=connect)
[B_proxy] PASS (... expected=noconnect, got=noconnect)
[C_no_proxy] PASS (... expected=connect, got=connect)

PASS issue #70 reproduced: a proxy with no localhost exclusion blocks Claude's
IDE WebSocket (B), while baseline (A) and the no_proxy fix (C) connect.
```

### Interactive (in the real plugin)

> **Note on branch state:** this fixture loads the plugin from the repo, so its
> behavior depends on whether the fix is present. On a branch/commit that
> **includes the fix**, the plugin injects `no_proxy` into the Claude terminal, so
> the steps below now **connect** (the `@` mention is delivered, no timeout) — that
> is the fix working. To watch the **original failure**, run the same steps against
> **pre-fix code** (check out the parent commit, or temporarily revert the
> `lua/claudecode/terminal.lua` change).

```sh
# proxy set, localhost NOT excluded
export http_proxy=http://127.0.0.1:1 https_proxy=http://127.0.0.1:1 all_proxy=http://127.0.0.1:1
unset no_proxy NO_PROXY

source fixtures/nvim-aliases.sh && vv issue-70 # or the explicit form below
# NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" nvim fixtures/issue-70/sample.txt
```

Then run `:Issue70Send` (or `<leader>s`). The plugin launches Claude and queues
`sample.txt`.

- **Pre-fix:** Claude cannot connect through the dead proxy; after ~10s the queue
clears with `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions`.
- **With the fix:** the plugin adds `localhost` to `no_proxy`, so Claude connects and
the `@` mention is delivered — no timeout.

Unlike this interactive path, the automated `scripts/repro_issue_70.sh` is
**unaffected by the fix**: it launches Claude with its own environment, bypassing the
plugin's env injection, so it reproduces the root cause at the Claude level on any
checkout (and `export no_proxy=localhost,127.0.0.1,::1` is what makes it connect).

> Set `ISSUE70_LOG=/path/to/log` before launching to also tee the plugin's
> notifications (including the ERROR) to a file for scripted assertions.

## Workarounds (today, no code change)

- `export no_proxy=localhost,127.0.0.1,::1` (and `NO_PROXY=...`) in the
environment Neovim is launched from.
- Avoid two Neovim instances sharing one project dir; clean stale
`~/.claude/ide/*.lock` files.
- Ensure `CLAUDE_CODE_SSE_PORT` actually reaches Claude (avoid env-stripping
terminal wrappers).

## Pointers for a fix (deep-research follow-up)

- The plugin could inject a localhost `NO_PROXY`/`no_proxy` into the env table it
passes to the Claude terminal (`get_claude_command_and_env` in
`lua/claudecode/terminal.lua`) so the loopback IDE socket is never proxied —
with care not to clobber a user's existing `no_proxy`.
- The `Connection timeout` error is generic; surfacing _why_ (proxy set / no
client handshake / multiple matching lock files) would make this class of
report self-diagnosing.
102 changes: 102 additions & 0 deletions fixtures/issue-70/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
-- Fixture for issue #70:
-- "[BUG] Sending files, current buffer, or lines to claude doesn't work."
-- symptom: [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions
-- https://github.com/coder/claudecode.nvim/issues/70
--
-- The symptom is downstream of ONE thing: the Claude CLI that the plugin launches
-- never opens a WebSocket connection back to the plugin's server, so every queued
-- @ mention sits in the queue until `connection_timeout` (default 10s) elapses and
-- the queue is cleared with the error above.
--
-- This fixture launches the REAL plugin with the native terminal provider so the
-- Claude CLI runs inside Neovim. Because it loads the plugin from the repo, the
-- outcome depends on the branch state (see fixtures/issue-70/README.md):
--
-- export http_proxy=http://127.0.0.1:1 all_proxy=http://127.0.0.1:1
-- unset no_proxy NO_PROXY
-- ISSUE70_LOG=/tmp/issue70.log \
-- NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" \
-- nvim fixtures/issue-70/sample.txt
-- :Issue70Send " launches Claude and queues sample.txt
--
-- PRE-FIX (parent commit / terminal.lua reverted): Claude cannot connect through the
-- dead proxy -> after ~10s the Connection timeout ERROR notification appears.
-- WITH THE FIX (this branch): the plugin injects `no_proxy=localhost,...` so Claude
-- connects and the @ mention is delivered -- no timeout. That contrast is the bug/fix.

local config_dir = vim.fn.stdpath("config")
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
vim.opt.rtp:prepend(repo_root)

vim.g.mapleader = " "
vim.g.maplocalleader = "\\"
vim.o.laststatus = 2

-- Tee every vim.notify (the plugin logs through it) to ISSUE70_LOG so the
-- "Connection timeout" ERROR can be asserted deterministically from a script,
-- not just scraped off the TUI.
local log_path = vim.env.ISSUE70_LOG
if log_path and log_path ~= "" then
local orig_notify = vim.notify
vim.notify = function(msg, level, opts) -- luacheck: ignore
pcall(function()
local fh = io.open(log_path, "a")
if fh then
fh:write(("[notify lvl=%s] %s\n"):format(tostring(level), tostring(msg)))
fh:close()
end
end)
return orig_notify(msg, level, opts)
end
end

local ok, claudecode = pcall(require, "claudecode")
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))

claudecode.setup({
auto_start = true, -- start the WebSocket server immediately
log_level = "debug",
terminal = {
provider = "native", -- run Claude inside Neovim so one PTY drives everything
auto_close = false,
},
-- defaults: connection_timeout = 10000, queue_timeout = 5000
})

local banner = {
"claudecode.nvim -- issue #70 reproduction fixture",
"",
"Symptom: [ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions",
"",
"Run :Issue70Send (or <leader>s) to launch Claude and queue this file as an",
"@ mention. If the launched Claude cannot connect back to the plugin's server",
"(e.g. a proxy is set with no localhost exclusion), the queue clears after",
"~10s with the Connection timeout ERROR.",
"",
"Server port: (see :ClaudeCodeStatus)",
}
vim.api.nvim_buf_set_lines(0, 0, -1, false, banner)
vim.bo.modifiable = false
vim.bo.modified = false

-- Queue THIS fixture's sample file as an @ mention via the real public API.
local function issue70_send()
local sample = repo_root .. "/fixtures/issue-70/sample.txt"
if vim.fn.filereadable(sample) == 0 then
sample = vim.fn.expand("%:p")
end
local okk, err = require("claudecode").send_at_mention(sample, nil, nil, "issue70")
vim.api.nvim_echo({
{
("Issue70Send: queued %s (ok=%s%s)"):format(
vim.fn.fnamemodify(sample, ":t"),
tostring(okk),
err and (" err=" .. err) or ""
),
"MoreMsg",
},
}, false, {})
end

vim.api.nvim_create_user_command("Issue70Send", issue70_send, { desc = "Repro #70: queue sample.txt as @ mention" })
vim.keymap.set("n", "<leader>s", issue70_send, { desc = "Repro #70 send" })
7 changes: 7 additions & 0 deletions fixtures/issue-70/sample.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
sample file for issue #70 reproduction

These lines are what the fixture sends to Claude as an @ mention via
:Issue70Send. When the launched Claude cannot connect back to the plugin's
WebSocket server, this mention is queued and then cleared after ~10s with:

[ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions
47 changes: 47 additions & 0 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,41 @@ local function is_terminal_visible(bufnr)
return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0
end

---Builds a no_proxy value that is guaranteed to exclude the loopback hosts
---(localhost, 127.0.0.1, ::1) from any proxy, merging the given existing values
---(each a comma-separated list, nils allowed) order-preserving and de-duplicated.
---See issue #70: Claude must never proxy its loopback IDE WebSocket connection.
---@param ... string? Existing no_proxy/NO_PROXY values to merge ahead of the loopback hosts
---@return string combined The merged no_proxy value with loopback hosts guaranteed present
local function no_proxy_with_loopback(...)
local entries = {}
local seen = {}

local function add_entry(entry)
entry = entry:gsub("^%s+", ""):gsub("%s+$", "")
if entry ~= "" and not seen[entry] then
seen[entry] = true
entries[#entries + 1] = entry
end
end

-- select() (not ipairs over {...}) so a nil source does not truncate the rest.
for i = 1, select("#", ...) do
local value = select(i, ...)
if type(value) == "string" then
for entry in value:gmatch("[^,]+") do
add_entry(entry)
end
end
end

for _, host in ipairs({ "localhost", "127.0.0.1", "::1" }) do
add_entry(host)
end

return table.concat(entries, ",")
end

---Gets the claude command string and necessary environment variables
---@param cmd_args string? Optional arguments to append to the command
---@return string cmd_string The command string
Expand Down Expand Up @@ -322,6 +357,18 @@ local function get_claude_command_and_env(cmd_args)
env_table[key] = value
end

-- Issue #70: Claude honors http_proxy/all_proxy (proxy-from-env semantics) and, without a
-- localhost exclusion, tunnels even its ws://127.0.0.1:<port> IDE connection through the
-- proxy, so the handshake never reaches our server and queued @ mentions time out. Guarantee
-- the loopback hosts bypass the proxy. This runs LAST -- after the config merge above and
-- regardless of the inherited env (termopen layers env_table over the parent env) -- so the
-- loopback exclusion always holds. We merge, rather than clobber, every existing source: the
-- inherited shell no_proxy/NO_PROXY and any value the user set via the `env` config option.
local combined_no_proxy =
no_proxy_with_loopback(os.getenv("no_proxy"), os.getenv("NO_PROXY"), env_table["no_proxy"], env_table["NO_PROXY"])
env_table["no_proxy"] = combined_no_proxy
env_table["NO_PROXY"] = combined_no_proxy

return cmd_string, env_table
end

Expand Down
Loading
Loading