A Claude Code plugin that runs clojure-lsp behind a small Babashka proxy.
Claude Code's native LSP client talks to the proxy over stdio; the proxy
talks to clojure-lsp. The proxy exists to keep navigation correct after
files change outside Claude's Edit and Write tools (shell commands,
formatters, git commands that move the working tree), and to offer a rename
that runs in the same long-lived clojure-lsp.
Status: all five phases of the plan are implemented: the transparent proxy with a traffic log (C1), progress ownership (C2), change injection with gating (C3), change reports (C4), the control socket (C5), the hooks that bracket every Bash command (C6) and rename (C7), plus format (C8) and clean-ns (C9).
The repository is the plugin. .claude-plugin/plugin.json is the manifest,
.lsp.json the LSP server configuration, bin/ the executables Claude Code
and hooks run, src/ the Babashka code, test/ the suites (bb test).
bin/clojure-lsp-proxy-server: the LSP entry point. Starts the server named byCLOJURE_LSP_PROXY_SERVER(defaultclojure-lspon PATH) with the proxy's own arguments and proxies stdio.bin/clojure-lsp-proxy: the command line over the control socket (status,changed [--wait] <path>...,rename,format,clean-ns).skills/clojure-rename/SKILL.md,skills/clojure-format/SKILL.md,skills/clojure-clean-ns/SKILL.md: tell Claude to use the rename, format and clean-ns commands instead of editing by hand.src/clojure_lsp_proxy/rename.clj,format.clj: rename and formatting through the running clojure-lsp;command.clj: clojure-lsp refactor commands (workspace/executeCommand, so far clean-ns) and theworkspace/applyEditthe server sends for them;edit.cljapplies the resulting workspace edit, reports the touched files and waits for the analysis.bin/on-bash-start,bin/on-bash-end,hooks/hooks.json: the Claude Code hooks around every Bash command (PreToolUse, PostToolUse and PostToolUseFailure).src/clojure_lsp_proxy/detect.clj: which files a Bash command changed, from a git snapshot before it and a check after it (adapted from rule-fairy).src/clojure_lsp_proxy/hooks.clj: the hook scripts' logic.src/clojure_lsp_proxy/main.clj: process lifecycle and the two pumps.src/clojure_lsp_proxy/gate.clj: progress ownership, the send queue and the gate (C2, C3).src/clojure_lsp_proxy/changes.clj: which paths are reported and how (C4).src/clojure_lsp_proxy/control.clj,client.clj,cli.clj: the socket's server side, its client and the command line (C5).src/clojure_lsp_proxy/framing.clj,transport.clj,log.clj,project.clj: Content-Length framing, locked writes with logging, the traffic log and the per-project directory under~/.cache/clojure-lsp-proxy/.
Clone the repository anywhere; <clone> below stands for that
directory. It needs Babashka (bb on PATH, or in ~/.local/bin,
/opt/homebrew/bin or /usr/local/bin) and a clojure-lsp (see "The
server").
git clone https://github.com/Cyrik/clojure-lsp-proxy <clone>
The repository is its own marketplace (.claude-plugin/marketplace.json,
one plugin with source ./). Installed once with
claude plugin marketplace add <clone>
claude plugin install clojure-lsp-proxy@clojure-lsp-proxy
every new session loads it, whatever started Claude Code. A relative-path
plugin of a directory marketplace runs in place: the proxy's start event
logs CLAUDE_PLUGIN_ROOT as <clone>, so git pull (or an edit in the
clone) is live in the next session; claude plugin update only acts on a
version change. For a one-off session, claude --plugin-dir <clone>
works too and registers before any installed plugin.
The proxy needs clojure-lsp 2026.10.15 or newer (clojure-lsp --version),
the first release that reports watched-file analysis progress
(clojure-lsp#2474).
With an older one, navigation works but every reported change holds
requests for the 30 s deadline, and the log says so (see "The server").
The marketplace can also come straight from GitHub,
claude plugin marketplace add Cyrik/clojure-lsp-proxy, with the same
install command. That installs a copy in Claude Code's plugin cache
instead of running in place, so it upgrades only when the plugin's
version changes (claude plugin marketplace update clojure-lsp-proxy,
then claude plugin update clojure-lsp-proxy@clojure-lsp-proxy).
Only one plugin may serve .clj: Claude Code gives each extension to the
first LSP server registered and logs the losers under --debug
("extension .clj already handled by ..."). Any other plugin that
registers an LSP server for .clj (for example clojure-lsp-cc) has to
be disabled with claude plugin disable <plugin>@<marketplace>.
.lsp.json sets startupTimeout to five minutes because clojure-lsp runs
the whole project analysis before it answers initialize.
The gate needs a clojure-lsp that reports watched-file analysis as
work-done progress
(clojure-lsp#2474):
release 2026.10.15 or newer, or any nightly built from master since
2026-10-05. A release clojure-lsp on PATH is the default and needs no
configuration; check it with clojure-lsp --version. Package managers
such as Homebrew may ship a new release some time after it is published.
With an older clojure-lsp, navigation works but no analysis progress ever
arrives: every reported change holds the client's requests for the full
deadline (default 30 s), the log says so (send-dropped with a hint), and
rename and format are refused after the first change report because no
analysis confirms it.
The proxy runs CLOJURE_LSP_PROXY_SERVER when that variable is set (a
path, or a command on PATH), else clojure-lsp on PATH. A path that is
not an executable file is ignored: the proxy logs server-fallback and
runs clojure-lsp from PATH, so a stale setting degrades instead of
failing to start. Claude Code sets the variable for every session from
the env block of ~/.claude/settings.json:
"env": { "CLOJURE_LSP_PROXY_SERVER": "/Users/you/.local/share/clojure-lsp-nightly/clojure-lsp" }
The variable is optional. One use is running a build newer than the latest release: clojure-lsp publishes a nightly (a native image) for every commit on master, and its install script fetches the newest one, without sudo when given a directory:
curl -O https://raw.githubusercontent.com/clojure-lsp/clojure-lsp/master/install
chmod +x install && ./install --version nightly --dir ~/.local/share/clojure-lsp-nightly
Point the variable at the clojure-lsp it installs, as in the example
above (a directory off PATH leaves the release in place for other
tools); rerunning the script upgrades.
The traffic log is a JSONL file at
~/.cache/clojure-lsp-proxy/<sha1 of the project root>/<proxy pid>.log, or
at CLAUDE_PLUGIN_LSP_LOG_FILE when that variable is set (the test suites
set it; the current CLI has no flag for it). Every line has t and ms (wall-clock
time), a dir (c->s, s->c for forwarded messages; p->s, s->p for
messages the proxy itself sends or consumes; proxy for its own events;
server-stderr for the server's stderr lines) and, for messages, method,
id and the full body.
The project root is CLAUDE_PROJECT_DIR when set (Claude Code sets it for
plugin LSP servers and hooks), else the current directory. The control
socket sits next to the log as <proxy pid>.sock.
Proxy events in the log (dir proxy, field event): gate-close,
send-start, send-queued, hold, send-analysis-begin, send-end
(with its reason: analysis-end, deadline or shutdown), gate-open
(with closed-ms and the number of released messages), anomaly (a
progress create that cannot belong to the send in flight), send-dropped
(no analysis by the deadline, with a hint that the server may predate the
progress reports, or no end), resend-unconfirmed, rename-applied,
format-applied, clean-ns-applied, apply-edit-consumed (a server
workspace/applyEdit the proxy answered itself during its own command,
with the command; an anomaly of kind
consumed-edit-differs-from-result when that edit is not the command's
result),
server-fallback (a CLOJURE_LSP_PROXY_SERVER path that is not an
executable file), plus the
lifecycle events start, client-eof, server-exit, terminated,
exit. A released message is logged with its held-ms.
CLOJURE_LSP_PROXY_SEND_DEADLINE_MS (default 30000) bounds how long one
send can keep the gate closed; a value that is not a positive integer is
ignored with a warning.
Claude's Edit and Write tools need nothing: Claude Code's LSP client
sends didOpen, didChange and didSave for them. For the Bash tool, the
plugin's PreToolUse hook sends bash-started and the proxy checks the
project's git state (a marker time, git status, HEAD) against the previous
checkpoint, queues changes made between commands, and saves the new
snapshot for the bracket; the PostToolUse and PostToolUseFailure hooks
send bash-finished with the bashEditDiff
Claude Code recorded, and the proxy unions that with its own check against
the snapshot: paths new to the status listing, paths rewritten or removed
since the marker, paths that left the listing because a stash, checkout or
restore rewrote them, the old names of renames (and an old name that a
revert of the rename brought back), and what the commits between the two
HEADs touched (for paths that were clean before). The
result goes through the C4 filter and into the send queue, and the hook
returns as soon as the report is queued. Snapshots are keyed by Claude
Code's tool_use_id, since parallel Bash calls interleave their hooks; a
finish whose start never arrived uses the previous check's snapshot, or
the one taken at startup. Checks at Bash start, Bash finish and before
rename share a checkpoint. Each scan and report is serialized with the
checkpoint update, so overlapping brackets cannot advance it past an
unreported interval. Analysis runs independently of these checks.
Content fingerprints of reported files suppress duplicate reports from
overlapping checks; the proxy does not hash the whole project.
Set bashEditDiffEnabled to true in ~/.claude/settings.json so that
Claude Code records its diff in every permission mode; without it the git
check catches git-visible changes made between commands by the next Bash
start or rename, including writes from editors and background commands.
Known gaps: native LSP queries before that next check can still see stale
analysis; automatic disk checks do not cover projects outside git;
files git ignores are never discovered by the git check, even a
gitignored .clj under a source path that clojure-lsp
analyzed at startup; nested repositories show up as one untracked
directory; a file written in the same second as a snapshot may be reported
once more than necessary.
Reporting a change by hand, from the project:
/path/to/clojure-lsp-proxy/bin/clojure-lsp-proxy changed --wait src/my/ns.clj
/path/to/clojure-lsp-proxy/bin/clojure-lsp-proxy status
clojure-lsp-proxy rename <file> <line> <col> <new-name> [--apply]
clojure-lsp-proxy rename --symbol <ns/name> <new-name> [--apply]
clojure-lsp-proxy rename --symbol <ns> <new.ns> [--apply]
The proxy asks the running clojure-lsp for the rename; for a namespace it
also asks workspace/willRenameFiles for the ns form and :require
edits that go with the file move, as an editor does. Lines and columns
are one-based. --symbol resolves vars and namespaces through
workspace/symbol; keywords and locals have no workspace symbol and take
the position form. Before asking for the rename the proxy asks
clojure/cursorInfo/raw what sits at the position and refuses the key of
a destructuring map (:keys, :syms, :strs, qualified or not), which
clj-kondo marks as such; its rename would be one edit that breaks the
destructuring instead of renaming anything. The same spelling in plain
data, {:api/keys [1 2]}, carries no mark and is renamed. Without
--apply the edits are printed and nothing
changes. With --apply the proxy applies them (text edits from the last
position backwards, then file operations), reports every touched path
through C4, waits for the re-analysis and then replies; the command line
also tells every other proxy of the project about the touched files. A
file rename or creation that would overwrite an existing file fails before
anything is written. Before asking clojure-lsp for the edits, both dry-run
and applied rename check for git-visible disk changes since the shared
checkpoint and queue them. The rename then waits until every change the
proxy reported has been analyzed (the gate's sends are settled; status
shows this as settled), at most two
deadlines, and is refused when they are not. It is also refused, with
nothing sent to clojure-lsp, while clojure-lsp has not finished
initialize (status shows server_ready false). When the wait after
an applied rename runs out before the re-analysis is confirmed, the
command line says the analysis is unconfirmed and still exits 0. An open gate is not enough:
after a deadline released the held messages, the analysis of a reported
change may still be running. A send that got no analysis by its deadline,
or whose analysis never ended, leaves its changes unconfirmed (status:
unconfirmed_paths); the rename re-sends them first and proceeds only
once a send ends on its analysis. With a server that reports no progress
for watched-file changes (clojure-lsp before 2026-10-05, or
:compute-external-file-changes false) nothing ever confirms, so the
rename stays refused after the first change report: a current clojure-lsp
with default settings is required. The disk check is a point-in-time
check: writes made after it, ignored files and files outside git are not
covered by it.
clojure-lsp-proxy format <file> [--apply]
The file's textDocument/formatting edits from the running clojure-lsp,
cljfmt with the project's settings as clojure-lsp reads them. Without
--apply they are printed; with --apply they go through the same path
as a rename: checked before writing, reported, waited for. A file that is
already formatted yields no edits and is not written. Whole files only.
Like rename, format is refused before clojure-lsp has finished
initialize and reports an unconfirmed analysis when the wait runs out.
clojure-lsp-proxy clean-ns <file> [--apply]
clojure-lsp's clean-ns refactoring, run as workspace/executeCommand in
the running clojure-lsp: unused requires, refers and imports removed, the
rest sorted, with the project's clean-ns settings. Without --apply the
new ns form is printed; with --apply it goes through the same path as
a rename: checked before writing, reported, waited for. clojure-lsp
always proposes the whole ns form, so an already clean file still shows
one edit in the dry run; applied, that edit leaves the text as it is, so
the file is not written and no path is reported. A file without an ns
form gets no edit from clojure-lsp and is refused ("clean-ns proposed no
edit"). Like rename and format, clean-ns waits for every reported change
to be analyzed first, is refused before clojure-lsp has finished
initialize, and reports an unconfirmed analysis when the wait after
applying runs out.
clojure-lsp hands a command's edit to the client as a
workspace/applyEdit request and returns the same edit as the command's
result. Claude Code does not answer that request, so while one of its own
commands is in flight the proxy answers it (applied: false) instead of
forwarding it, and applies the result itself.
- C1 Transparency. When no change has been reported, every message crosses
the proxy unchanged except the
initializeparams patch (C2's progress capability and, for C7,workspace.workspaceEditwithdocumentChangesand resource operations, which only the proxy's own requests exercise) and the progress ownership of C2, and except a serverworkspace/applyEditthat arrives while one of the proxy's own commands is in flight (C9). If the server process dies unexpectedly, the proxy answers every held request with an InternalError (-32603) response and exits non-zero so that Claude Code'srestartOnCrashrestarts both. After a forwardedexit, or after the client closes stdin, the proxy exits 0: it sendsshutdownandexitto the server if the client did not, waits briefly, kills it if needed. Claude Code 2.1.281 sendsshutdownand then terminates the proxy with SIGTERM instead of sendingexit; the proxy's shutdown hook logs that and the server ends when its stdin closes. - C2 Progress ownership. The proxy sets
capabilities.window.workDoneProgressto true in theinitializeparams it forwards, answers everywindow/workDoneProgress/createrequest from the server itself with a null result, never forwards those requests, and does not forward$/progressnotifications for tokens it accepted. Progress on any other token, including the client'sinitialize.workDoneToken, passes through. Thebegintitle is recorded per token. - C3 Gating. The proxy is the only source of
workspace/didChangeWatchedFilesand keeps at most one send in flight: changes reported while a send is in flight are coalesced and sent as the next notification once the current one has ended. The gate is closed from the moment a change report is accepted until no send is in flight or pending. While closed, the proxy holds every client-to-server message in arrival order except responses,$/cancelRequest,initialize,initialized,shutdownandexit;shutdownopens the gate. A$/cancelRequestfor a held request removes it and answers RequestCancelled (-32800). A send ends when theendof a token arrives whosebegintitle is "Analyzing external file changes" and whosecreatearrived after the send. A deadline (default 30 s) bounds every hold: a held message is released at the latest one deadline after the gate closed, whatever the sends are doing, and a message held after such a release starts a fresh closure with its own bound. A send whose analysis had begun by its deadline stays outstanding until itsend(or ten deadlines later), so the next send starts only after that batch ended and never ends on an earlier send's batch; a send for which no analysis began by the deadline gets none, since nothing else was being analyzed, and is dropped. A dropped send's changes stay unconfirmed: they join the next send and count as analyzed only when a send ends on its analysis; they do not hold the gate.changed --waitreports a timeout when its report was still pending at the release. Reports before the client'sinitializedwait for it. Acreatearriving under 900 ms after a send cannot belong to that send's batch (the server debounces for 1000 ms) and is logged as an anomaly, as is anycreateof that title arriving while no send is outstanding. Reports aftershutdownare dropped. Every hold is logged with its duration and the reason the gate opened. - C4 Change reports. Reported paths are filtered to the server's
watched extensions (
clj cljs cljc cljd edn bb clj_kondo) and to the project root, and sent asfile://URIs of the real (symlink-resolved) path. Paths under clojure-lsp's ignored source paths (its:source-paths-ignore-regex, read from the project's.lsp/config.edn, else the user's clojure-lspconfig.edn, else the defaulttarget.*) are left out too, since clojure-lsp would not analyze them and the gate would wait for its deadline; a regex set throughinitializationOptionsis not seen. Type isdeletedwhen the path no longer exists andchangedotherwise (clojure-lsp treats created and changed alike). Deletions come from listing differences, not from file times: a status entry newlyD, a path that left the listing and no longer exists, and the origin path of a rename or copy entry. A path that left the listing but exists (reverted by stash, restore or checkout) ischanged. An empty report sends nothing and gates nothing. - C5 Control socket. Each proxy listens on a Unix domain socket at
~/.cache/clojure-lsp-proxy/<sha1 of the real project root path>/<pid>.sockand removes it on exit. Protocol: one JSON object per line in each direction, one request per connection. Operations:status,bash-started,bash-finished,changed,rename,format,refactor.bash-startedandbash-finishedreply as soon as the proxy has snapshotted or queued, never after analysis;changedwaits for the gate only when asked. Clients address every socket in the project's directory and delete a socket whose connection is refused. The project root isCLAUDE_PROJECT_DIRwhen set, else the current directory; the command line, when the variable is unset, looks under the current directory and each of its parents up to the git top level. Achanged --waitoutstanding when the proxy exits gets no reply; the command line prints"reply":nullfor that socket and exits 1. - C6 Hooks never fail Claude. Each hook exits 0 and prints nothing on stdout whatever happens; it gives up on a proxy after 5 s. Errors go to stderr.
- C7 Rename. Without
--applythe CLI prints the edits clojure-lsp returned and changes nothing. Either way the rename first re-sends the changes a dropped send left unconfirmed, then is refused unless every change the proxy reported has been analyzed within two deadlines (C3's sends settled: nothing in flight, pending or unconfirmed), so that the edits never come from an analysis behind a reported change. Before that, the rename is refused with nothing sent to the server while clojure-lsp has not finishedinitialize(the client'sinitializedhas not been forwarded;status:server_readyfalse). With--applythe proxy applies the workspace edit to disk, reports the touched paths through C4, waits for that send to end per C3, and then replies; when the wait runs out first the reply carriestimed_outtrue, and the CLI prints that the analysis was not confirmed and still exits 0: clojure-lsp did not confirm its analysis of the written files within the wait. The next edit command first waits for that analysis, re-sending the changes if no analysis is running, and is refused if it is still not confirmed within two deadlines. Every text edit is computed and checked against the file, and every file rename or creation checked for an existing target, before anything is written; a failure while writing leaves the files written so far in place, and those paths are still reported. A rename applied inside a Bash command's bracket is not reported again by that bracket, unless the file was written again afterwards, even with the same content.--symbolnames a var or a namespace; keywords and locals are renamed by position. A rename aimed at the key of a destructuring map (:keys,:syms,:strs, qualified or not, as clj-kondo marks them) is refused before anything is requested; the same spelling in plain data is renamed. - C8 Format.
format <file>asks the running clojure-lsp for the file'stextDocument/formattingedits. Without--applythe CLI prints them and changes nothing; with--applythey go through C7's path: checked before writing, reported through C4, waited for per C3, and not reported again by the Bash bracket around the command. A file without edits is not written, and neither is one whose edits leave its text unchanged (true of every applied edit, not only format's). Like rename, format waits for every reported change to be analyzed first. - C9 Clean ns.
clean-ns <file>runs clojure-lsp'sclean-nscommand throughworkspace/executeCommand(control oprefactor). Without--applythe CLI prints the edit and changes nothing; with--applythe edit goes through C7's path: checked before writing, reported through C4, waited for per C3, and not reported again by the Bash bracket. Like rename, it waits for every reported change to be analyzed first. The proxy runs its own commands one at a time. While one is in flight, aworkspace/applyEditrequest from the server is answered by the proxy withapplied: falseand afailureReason, never forwarded, and the edit applied is the command's result; a consumed edit that differs from the result is logged as an anomaly. Aworkspace/applyEditwhile no own command is in flight crosses to the client unchanged and its response to the server. A file whose edit leaves its text unchanged (an already cleannsform) is not written and not reported. The command is refused when the file does not exist, when clojure-lsp answers with an error, and when it proposes no edit (a file without annsform).
bb test runs the unit suites (framing, change reports, detection against
temporary git repositories) and three end-to-end suites, which start the
proxy as a subprocess in front of test/fake_server.bb and play the client
on its stdio; the gate suite also drives the control socket and the hook
scripts, the rename suite uses canned server answers and checks the files. The fake server emulates clojure-lsp's progress sequence on request
(create after the debounce, begin, end), so the suites take about a
minute of real waiting.