Expand description
§2 module 28 lsp (COMPOSABLE-HARNESS-DESIGN.md line 478): “D1 LSP
diagnostics in edit path + query tool” — this module ships the
WEAKEST FORM that satisfies D1: server LIFECYCLE for a HANDFUL of
user-configured language servers, and diagnostics surfaced in the
edit/write TOOL RESULT via the shared D-5 write-path seam
(crate::tools::WriteObserver, P5-9/P5-11).
§Honest, deliberate gaps (design §4.4 “LSP-in-edit at fleet scale”)
- No auto-spawn/auto-download fleet. opencode auto-provisions ~38
language servers. This module only ever spawns a server the user
EXPLICITLY configured under
[capabilities.lsp.servers.<name>]— no network fetch, no bundled binaries, nothing runs that wasn’t named in config. - No
/find/symbolsymbol-indexing query tool. A real symbol index (workspace/symbol, textDocument/definition, …) is D-4-sized work this module does not attempt — shipping alsp.querytool that silently no-ops would be a worse outcome than not shipping it (build brief: “if you expose any query surface, it must work or not exist”), so none is exposed.[capabilities.lsp]carries exactly two real, wired knobs:enabledandservers(plus the boundsmax_diagnostics/timeout_secs) — noquery/symbolskey is ever parsed, so there is no declared-but-dead knob for either gap.
§Wire protocol — hand-rolled, no new dependency
LSP frames a JSON-RPC message behind a tiny HTTP-style header
(Content-Length: N\r\n\r\n<N bytes of JSON>). That framing is a dozen
lines over tokio::io::AsyncBufReadExt/AsyncReadExt — pulling in a
dedicated lsp-types/lsp-server crate for it would be the heavy,
over-built option for a module scoped to lifecycle + diagnostics over a
handful of servers (no symbol index, no code actions, no incremental
sync deltas — just initialize/initialized/didOpen/didChange/
publishDiagnostics/shutdown/exit), so write_message/
read_message below hand-roll it instead. cargo deny check has
nothing new to license-audit as a result.
§Process lifecycle (no orphaned language servers, incl. grandchildren)
A server is a long-lived child process, spawned lazily
(LspManager::diagnostics_after_write, on the first write to a file
extension it’s configured for) and kept alive in LspManager for
reuse across writes. A real configured server (rust-analyzer,
typescript-language-server, gopls, …) commonly spawns its OWN
persistent worker subprocesses (a proc-macro/build server, tsserver,
go, …) — so .kill_on_drop(true)/Child::start_kill alone (which
only ever signal the ONE directly-tracked pid) are not enough; this is
the SAME grandchild-orphan class crate::agent::kill_job_process_group
was built to close for background shell jobs (P5-6), and the fix here
reuses that exact mechanism: LspClient::spawn puts the server in its
OWN process group (Command::process_group(0), unix), and
LspClient::kill SIGKILLs the WHOLE group (kill_process_group),
not just the leader — .kill_on_drop(true) remains as a second,
independent backstop for the leader pid specifically. On non-unix
targets, no portable process-group primitive is wired up (same posture
as kill_job_process_group’s own #[cfg(not(unix))] arm) — this falls
back to the pre-fix direct-child-only kill, a documented residual, not
silently claimed fixed there.
LspManager::kill_all_sync (this module’s group-kill, above) is
called from impl Drop for crate::Agent — the ONLY production teardown
path today, provable/traceable rather than relying solely on
kill_on_drop(true)’s implicit runtime behavior. LspManager::shutdown_all
(a graceful LSP shutdown/exit handshake, letting a well-behaved
server reap its own children before this module force-kills the group)
is NOT wired into any automatic path — Agent::run_loop runs once PER
TURN, not once per session, so calling it there would tear down and
respawn a reused server every turn, defeating the “kept alive for reuse
across writes” design above; there is no separate session-level
clean-exit hook distinct from Drop in this codebase today. It remains
available as public API (exercised directly by this module’s own tests)
for a caller that manages its own Agent lifecycle and wants to drain
gracefully before dropping it — but nothing calls it automatically, and
that is the honest, current state (not an aspirational claim about a
code path that doesn’t exist).
Structs§
- LspClient
- A live connection to one configured language server — one spawned child process, kept alive for reuse across writes to files it handles.
- LspDiagnostics
Observer - The
crate::tools::WriteObserver[capabilities.lsp]installs —before_writeis a true no-op (LSP has nothing to capture before a mutation);after_writedelegates straight toLspManager::diagnostics_after_write. - LspManager
- Session-scoped registry of configured language servers and the live
connections spawned so far — the
crate::tools::WriteObserverthis module installs (LspDiagnosticsObserver) is a thin wrapper around a sharedArc<LspManager>;crate::agent::build_tool_contextkeeps its ownArcclone too, socrate::Agent’sDropimpl can reachSelf::kill_all_syncregardless of how many observer clones exist. - LspServer
Spec - One
[capabilities.lsp.servers.<name>]entry — a user-configured language server this module is allowed to spawn.command/argsare config-borne code execution (D-10) — seecrate::configfile::sanitize_for_project/crate::userconfig’s project-strip, which refuses this table from an untrusted project layer exactly likehooks/mcp.servers.
Constants§
- DEFAULT_
LSP_ MAX_ DIAGNOSTICS - Default cap on the number of diagnostics rendered into a single tool
result (bounded-context requirement — build brief: “a flood mustn’t
blow context”). Overridable via
[capabilities.lsp] max_diagnostics. - DEFAULT_
LSP_ TIMEOUT_ SECS - Default wait for a configured server to publish diagnostics after a
didOpen/didChangebefore giving up gracefully (never blocking the tool call indefinitely). Overridable via[capabilities.lsp] timeout_secs. - LSP_
MAX_ MESSAGE_ BYTES - Hardening cap (same rationale as
crate::mcp::MCP_MAX_RESPONSE_BYTES): the largest single Content-Length-framed message this client will buffer before treating the server as hostile/broken and erroring out — bounds how much memory a misbehaving configured language server can force this process to allocate for one message.
Functions§
- manager_
for_ config - Build the
LspManagera freshcrate::Agentshould install, given a resolvedcrate::Config— called once, fromcrate::agent::build_tool_context.Config::lsp_enabledis the ONE gate:false(the default) returnsNoneWITHOUT spawning anything — the default-off byte-identity guarantee.truewith an EMPTYConfig::lsp_serversstill returns a (harmless, does-nothing) manager, but warns once — an enabled module with no configured servers is very likely a config mistake, not silent-by-design.