|
| 1 | +# Issue #70 — "Sending files, current buffer, or lines to claude doesn't work" |
| 2 | + |
| 3 | +> Source: https://github.com/coder/claudecode.nvim/issues/70 |
| 4 | +> |
| 5 | +> Symptom: `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing N queued @ mentions` |
| 6 | +
|
| 7 | +## The one fact behind every report |
| 8 | + |
| 9 | +`:ClaudeCodeSend` (and the file/buffer/tree senders) call `send_at_mention`. When |
| 10 | +Claude is not connected, the mention is **queued** and a `connection_timeout` |
| 11 | +timer (default **10s**, `lua/claudecode/init.lua`) is armed. If Claude has not |
| 12 | +opened a WebSocket back to the plugin's server by then, the queue is cleared with |
| 13 | +the error above (`start_connection_timeout_if_needed`). |
| 14 | + |
| 15 | +So the bug is never really "send is broken" — it is always **"the Claude CLI that |
| 16 | +the plugin launched never connected back to the plugin's WebSocket server."** The |
| 17 | +interesting part is _why_ Claude doesn't connect, and the issue thread contains |
| 18 | +several distinct causes that all surface as this one error. |
| 19 | + |
| 20 | +## How the plugin expects Claude to connect |
| 21 | + |
| 22 | +1. The plugin starts a WebSocket server on `127.0.0.1:<port>` and writes |
| 23 | + `~/.claude/ide/<port>.lock` containing `{ pid, workspaceFolders, ideName: |
| 24 | +"Neovim", transport: "ws", authToken }` (`lua/claudecode/lockfile.lua`). |
| 25 | +2. The terminal provider launches Claude with `CLAUDE_CODE_SSE_PORT=<port>` and |
| 26 | + `ENABLE_IDE_INTEGRATION=true` in its environment (`lua/claudecode/terminal.lua`). |
| 27 | +3. Claude reads `CLAUDE_CODE_SSE_PORT`, looks up the matching lock file for the |
| 28 | + auth token, and connects to `ws://127.0.0.1:<port>`. |
| 29 | + |
| 30 | +## What was reproduced (claude 2.1.168, nvim 0.13, macOS) |
| 31 | + |
| 32 | +Driven with the real `claude` CLI in a PTY (agent-tty) against the real plugin |
| 33 | +server. A headless probe (`scripts/repro_issue_70_probe.lua`) reports whether |
| 34 | +Claude actually opened a socket. |
| 35 | + |
| 36 | +| # | environment | result | |
| 37 | +| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | |
| 38 | +| **Baseline** | `CLAUDE_CODE_SSE_PORT` set, no proxy | **connects** (`client_count: 1`), `@` mention delivered | |
| 39 | +| **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` | |
| 40 | +| **Proxy + fix** | same proxy **+** `no_proxy=localhost,127.0.0.1,::1` | **connects** again | |
| 41 | + |
| 42 | +The baseline connects even with several _other_ `~/.claude/ide/*.lock` files |
| 43 | +present, so `CLAUDE_CODE_SSE_PORT` reliably disambiguates — the proxy is the only |
| 44 | +thing that changes the outcome. This matches a `no_proxy` workaround reported in |
| 45 | +the issue thread: |
| 46 | + |
| 47 | +```sh |
| 48 | +export no_proxy=localhost,127.0.0.1,::1 # their fix |
| 49 | +``` |
| 50 | + |
| 51 | +Claude's WebSocket client honors the lowercase `http_proxy`/`all_proxy` |
| 52 | +(`proxy-from-env` semantics) and, with no localhost exclusion, tunnels even |
| 53 | +`ws://127.0.0.1:<port>` through the proxy — which cannot reach a loopback server. |
| 54 | + |
| 55 | +### Secondary cause seen in the thread (multi-instance / discovery) |
| 56 | + |
| 57 | +Most thread reports ("the 2nd instance fails", "the send is caught by the other |
| 58 | +terminal", "only works when Claude is opened in another terminal") come from the |
| 59 | +_discovery fallback_ used when `CLAUDE_CODE_SSE_PORT` does **not** reach Claude |
| 60 | +(external-terminal provider, shell/tmux wrappers that reset env, older versions): |
| 61 | + |
| 62 | +- With the env var **absent**, current Claude falls back to scanning |
| 63 | + `~/.claude/ide/*.lock` and **filters by workspace** (`/ide` literally prints |
| 64 | + _"Found N other running IDE(s). However, their workspace/project directories do |
| 65 | + not match the current cwd."_). |
| 66 | +- With **exactly one** workspace-matching lock file it auto-connects; with **two** |
| 67 | + (two Neovim instances in the same project, or a stale lock file) it connects to |
| 68 | + **neither** — reproducing the timeout. |
| 69 | +- Auto-connect via the env var (`CLAUDE_CODE_SSE_PORT`) or a _single_ unambiguous |
| 70 | + workspace match still happens automatically; but the ambiguous-discovery path |
| 71 | + surfaces a `/ide` tip — _"You can enable auto-connect to IDE in /config or with |
| 72 | + the --ide flag"_ — i.e. discovery-only auto-connect is heuristic/opt-in. A |
| 73 | + Claude-side change to that heuristic is a plausible (unverified) explanation for |
| 74 | + the "worked, then a Claude update broke it" / "lock file exists but no socket" |
| 75 | + reports (one such report cites CC 2.0.42), and is a question for the |
| 76 | + deep-research follow-up. |
| 77 | + |
| 78 | +These discovery paths touch global `~/.claude/ide` state shared with other live |
| 79 | +sessions, so they are documented here rather than automated. The deterministic, |
| 80 | +side-effect-free repro below targets the proxy cause. |
| 81 | + |
| 82 | +## Reproduce it |
| 83 | + |
| 84 | +### Automated (proxy cause, real Claude) |
| 85 | + |
| 86 | +```sh |
| 87 | +# from repo root; needs nvim + a logged-in `claude` + agent-tty + jq |
| 88 | +bash scripts/repro_issue_70.sh |
| 89 | +``` |
| 90 | + |
| 91 | +Expected tail: |
| 92 | + |
| 93 | +``` |
| 94 | + [A_baseline] PASS (... expected=connect, got=connect) |
| 95 | + [B_proxy] PASS (... expected=noconnect, got=noconnect) |
| 96 | + [C_no_proxy] PASS (... expected=connect, got=connect) |
| 97 | +
|
| 98 | +PASS issue #70 reproduced: a proxy with no localhost exclusion blocks Claude's |
| 99 | + IDE WebSocket (B), while baseline (A) and the no_proxy fix (C) connect. |
| 100 | +``` |
| 101 | + |
| 102 | +### Interactive (in the real plugin) |
| 103 | + |
| 104 | +> **Note on branch state:** this fixture loads the plugin from the repo, so its |
| 105 | +> behavior depends on whether the fix is present. On a branch/commit that |
| 106 | +> **includes the fix**, the plugin injects `no_proxy` into the Claude terminal, so |
| 107 | +> the steps below now **connect** (the `@` mention is delivered, no timeout) — that |
| 108 | +> is the fix working. To watch the **original failure**, run the same steps against |
| 109 | +> **pre-fix code** (check out the parent commit, or temporarily revert the |
| 110 | +> `lua/claudecode/terminal.lua` change). |
| 111 | +
|
| 112 | +```sh |
| 113 | +# proxy set, localhost NOT excluded |
| 114 | +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 |
| 115 | +unset no_proxy NO_PROXY |
| 116 | + |
| 117 | +source fixtures/nvim-aliases.sh && vv issue-70 # or the explicit form below |
| 118 | +# NVIM_APPNAME=issue-70 XDG_CONFIG_HOME="$PWD/fixtures" nvim fixtures/issue-70/sample.txt |
| 119 | +``` |
| 120 | + |
| 121 | +Then run `:Issue70Send` (or `<leader>s`). The plugin launches Claude and queues |
| 122 | +`sample.txt`. |
| 123 | + |
| 124 | +- **Pre-fix:** Claude cannot connect through the dead proxy; after ~10s the queue |
| 125 | + clears with `[ClaudeCode] [queue] [ERROR] Connection timeout - clearing 1 queued @ mentions`. |
| 126 | +- **With the fix:** the plugin adds `localhost` to `no_proxy`, so Claude connects and |
| 127 | + the `@` mention is delivered — no timeout. |
| 128 | + |
| 129 | +Unlike this interactive path, the automated `scripts/repro_issue_70.sh` is |
| 130 | +**unaffected by the fix**: it launches Claude with its own environment, bypassing the |
| 131 | +plugin's env injection, so it reproduces the root cause at the Claude level on any |
| 132 | +checkout (and `export no_proxy=localhost,127.0.0.1,::1` is what makes it connect). |
| 133 | + |
| 134 | +> Set `ISSUE70_LOG=/path/to/log` before launching to also tee the plugin's |
| 135 | +> notifications (including the ERROR) to a file for scripted assertions. |
| 136 | +
|
| 137 | +## Workarounds (today, no code change) |
| 138 | + |
| 139 | +- `export no_proxy=localhost,127.0.0.1,::1` (and `NO_PROXY=...`) in the |
| 140 | + environment Neovim is launched from. |
| 141 | +- Avoid two Neovim instances sharing one project dir; clean stale |
| 142 | + `~/.claude/ide/*.lock` files. |
| 143 | +- Ensure `CLAUDE_CODE_SSE_PORT` actually reaches Claude (avoid env-stripping |
| 144 | + terminal wrappers). |
| 145 | + |
| 146 | +## Pointers for a fix (deep-research follow-up) |
| 147 | + |
| 148 | +- The plugin could inject a localhost `NO_PROXY`/`no_proxy` into the env table it |
| 149 | + passes to the Claude terminal (`get_claude_command_and_env` in |
| 150 | + `lua/claudecode/terminal.lua`) so the loopback IDE socket is never proxied — |
| 151 | + with care not to clobber a user's existing `no_proxy`. |
| 152 | +- The `Connection timeout` error is generic; surfacing _why_ (proxy set / no |
| 153 | + client handshake / multiple matching lock files) would make this class of |
| 154 | + report self-diagnosing. |
0 commit comments