LSP diagnostics
Language-server diagnostics appended to write and edit results, for the languages you already have tooling for.
On this page
Turning it on
Set lsp to "on" in .arcturn/config.json (default "off" — zero behavior change until
you opt in):
{ "lsp": "on" }There is no --lsp command-line flag; it's config-only. See
Configuration for where this key sits among
the rest.
Supported servers
Arcturn doesn't bundle any language server — it spawns whichever binary is already on
PATH, keyed by file extension:
| Extensions | Server command |
|---|---|
.ts, .tsx, .js, .jsx |
typescript-language-server --stdio |
.py |
pyright-langserver --stdio |
.go |
gopls |
.rs |
rust-analyzer |
If the binary for an extension isn't installed (or, more precisely, not resolvable on
PATH), that extension's diagnostics are silently unavailable — never a hard failure.
Install whichever ones you need:
npm install -g typescript-language-server typescript
pip install pyright
go install golang.org/x/tools/gopls@latest
rustup component add rust-analyzerBinary lookups are cached per process, so the first write/edit to a given language pays
the PATH scan once.
When diagnostics fire
Only write and edit are wrapped — every other tool passes through unchanged. After a
call to either succeeds (an error result skips the diagnostics step entirely), Arcturn:
- Reads the file back from disk.
- Opens (or updates) it with the matching language server, keyed by extension.
- Waits up to a per-file diagnostics budget — 3 seconds by default, overridable per
call site — for
publishDiagnosticsnotifications to arrive. - Appends whatever arrived to the tool result, under an
lsp diagnostics:header.
lsp diagnostics:
src/agent.ts:12:5 error: Cannot find name 'foo'.
src/agent.ts:20:1 warning: 'bar' is declared but never used.Output is one-based (line:column), formatted path:line:col severity: message, and capped
at 10 lines, collapsing anything past that into a trailing … N more. A server stays
alive across edits to the same working directory — it's opened once via the LSP
initialize/initialized handshake and textDocument/didOpen, then sent
textDocument/didChange notifications on every subsequent edit, not respawned per call —
so the second edit to a file is fast even though the first paid the server's startup cost.
This never turns a successful write/edit into a failure: a missing server, a spawn
failure, a malformed response, or a timeout is swallowed — diagnosticsFor returns null
rather than throwing — and the original tool result is returned unchanged. Worst case, it
adds the diagnostics timeout's worth of latency to the call.
Symbols
The same lsp: "on" gate also turns on a symbols tool: a structural map of code via the
language servers Arcturn already spawns for diagnostics, rather than a grep-based guess at
where a class or function lives.
Provide exactly one of:
file— document symbols for one file (classes, functions, methods, variables, ...), viatextDocument/documentSymbol.query— a workspace-wide symbol search by name (or substring), viaworkspace/symbol, fanned out to every language server already spawned this session.
class Foo src/agent.ts:12
method bar src/agent.ts:20
function helper src/agent.ts:40Output is compact kind name path:line lines, one-based, capped at 50 entries,
collapsing anything past that into a trailing … N more.
A few behaviors worth knowing:
textDocument/documentSymbolcan come back from a server as either a hierarchicalDocumentSymbol[](with nestedchildren) or a flatSymbolInformation[]— both shapes are handled, and a hierarchical response is flattened before rendering.- A workspace-wide
queryonly reaches servers already spawned this session (i.e. some file of that language has already been opened via diagnostics or a priorfilelookup). It never spawns a new server on its own, so the very first symbol search after startup may only cover the languages already touched. - Like diagnostics, this is read-only and fails closed: no server for the relevant language, a spawn failure, or a timeout all resolve to a friendly "no language server available" message rather than an error — never a hard failure.
fileandqueryare mutually exclusive; providing both, or neither, is an error result.
Under the hood
The LSP client is a small, hand-rolled JSON-RPC implementation — no LSP SDK dependency.
It speaks only what this feature needs: the initialize/initialized handshake,
textDocument/didOpen / textDocument/didChange, collecting publishDiagnostics, and a
shutdown/exit teardown on process exit. Message framing
(Content-Length: <n>\r\n\r\n<body>) is parsed and written by hand rather than pulled in
from a library.
One LspManager is created per working directory and lazily spawns one client per
language as files in that language are touched — a session editing both .ts and .py
files runs two server processes, not one per file.
Interaction with the verify loop
LSP diagnostics and the verify config key (see
Configuration) both hook the same two tools — write and
edit — and append their findings to the result the model sees, but they're independent
and can be combined:
lspgives fast, incremental, single-file feedback straight from a running language server — no process spawn beyond the server itself.verifyruns a project-level command (pnpm typecheck,pnpm test, a linter) after an edit, which catches cross-file breakage an LSP diagnostic on one file can't see, at the cost of actually running that command on every matching edit.
Both are additive to the same tool result and both fail closed: a broken verify command or
an unreachable language server degrades to "no extra feedback," never to a failed
write/edit.