lsp-daemon -- Shared Per-User LSP Daemon
August 24, 2026 · View on GitHub
Generated: 2026-08-24 / f3642fcda
OVERVIEW
Vendored, Node-targeted MCP-layer package (@code-yeongyu/lsp-daemon). Runs ONE long-lived LSP process per user and fans many short-lived agent sessions into it over a unix socket (Windows named pipe). The runtime contract is harness-neutral so Codex, OpenCode, and Senpi can converge on the same authenticated daemon. Sessions launch a thin stdio MCP proxy that forwards to the warm daemon. Reuses @code-yeongyu/lsp-tools-mcp for the actual LSP manager + MCP request handler - this package only adds the daemon/proxy/transport layer. Built with npm + vitest + biome (NOT Bun); engines.node >= 20.
KEY FILES
| File | Role |
|---|---|
cli.ts | Bin omo-lsp-daemon. mcp (default) → runMcpStdioProxy(); daemon → runDaemon() |
proxy.ts | runMcpStdioProxy(): reads JSON-RPC lines from stdin; tools/call → daemon via client; other LSP MCP requests handled locally; startup watchdog + aborts on stdin close |
daemon-server.ts | startDaemonServer(): net.createServer on the socket, owns the LSP manager, idle auto-shutdown, pid/endpoint files, SIGTERM/SIGINT cleanup |
daemon-client.ts | callToolViaDaemon() / callDiagnosticsViaDaemon(): connect to socket, send tool call, await response; honors AbortSignal, sends $/cancelRequest on abort/timeout |
ensure-daemon.ts | ensureDaemonRunning(): probe → lock → spawn detached daemon → poll until reachable (DI'd deps for tests); accepts AbortSignal, abort cancels pending startup |
request-routing.ts | handleDaemonMessage(): strips _context (cwd/env) from args, runs request inside that RequestContext |
runtime-contract.ts | Exact three-variable runtime override contract + typed validation errors |
paths.ts | OMO-owned versioned socket/lock/pid/log path resolution |
lock.ts | Single-flight file lock + unlinkQuietly |
socket-jsonrpc.ts | Newline-delimited JSON-RPC framing over the socket |
run-daemon.ts | daemon subcommand entry (boots the server) |
index.ts | Barrel: runMcpStdioProxy, ensureDaemonRunning, callToolViaDaemon, callDiagnosticsViaDaemon, daemonPaths, disposeDefaultLspManager |
FLOW
session → omo-lsp-daemon (mcp proxy, stdio)
├─ ensureDaemonRunning(): probe socket
│ ├─ reachable → reuse
│ └─ down → tryAcquireLock → spawn detached `cli.js daemon` → poll until reachable
├─ tools/call (+ _context {cwd,env}) → daemon-client → unix socket
│ └─ daemon: handleDaemonMessage → runWithRequestContext(cwd/env) → lsp-tools-mcp handler
└─ non tool-call LSP MCP request → handled locally in the proxy
NOTES
- Startup watchdog: the proxy exits if no MCP request arrives within
startupTimeoutMs(defaultDEFAULT_STARTUP_TIMEOUT_MS= 10 s). The first handler invocation clears the watchdog; on expiry it destroys stdin and writes a[lsp-daemon]diagnostic to stderr, then swallows the resultingERR_STREAM_PREMATURE_CLOSE. - Cancellation lifecycle: the proxy owns an
AbortControlleraborted on stdinend/close. That signal threads throughcallToolViaDaemonintoensureDaemonAvailable(rejectsDaemonRequestCancelledError) andsendToolCall, which sends a$/cancelRequest(auth envelope + request id) to the daemon and rejects.callToolViaDaemonretries only on auth refresh; it breaks onDaemonRequestCancelledErrorand on aDaemonRequestErrorthat was already written or names a non-retryable tool (rename/lsp_rename). - QA smoke scripts:
scripts/qa/cancellation-smoke.mjsdrives one deterministic socket-to-LSP cancellation end-to-end;scripts/qa/commit-barrier-smoke.mjsproves an abortedtextDocument/renameemits$/cancelRequestbefore any workspace edit applies (an abort mid-apply still commits exactly one edit, reported as a late abort). Both take the repo root asargv[2]and are wired into the codex-qa/opencode-qalsp-e2e.shdrivers. - Per-request context threading: the proxy injects
_context(cwd + env allowlist) into eachtools/call; the daemon runs that request insiderunWithRequestContextso one shared process correctly serves many working directories. - Idle shutdown: daemon self-exits after 30 min (
DEFAULT_IDLE_SHUTDOWN_MS) once there are no live connections ANDgetLspManager().clientCount() === 0. Live LSP clients keep it warm. - State root:
$OMO_LSP_DAEMON_DIRwhen it is already absolute, otherwise~/.omo/lsp-daemon; every runtime is isolated underv<version>. - Runtime overrides are paired:
$OMO_LSP_DAEMON_CLIand$OMO_LSP_DAEMON_VERSIONmust be both absent or both present. A singleton pair fails before path creation or spawn. Explicit CLI paths must be absolute existing regular files; versions must match[A-Za-z0-9][A-Za-z0-9._+-]{0,127}. - Socket path: Unix uses
<version-dir>/daemon.sockand keeps the short hashedtmpdir()fallback when the natural path reaches 100 characters. Windows binds the pipe digest to the canonical version directory plus the current user discriminator; request authentication remains the security boundary. - OpenCode bootstrap: dist/bootstrap resolves the package
./cliexport instead of deep-running generated files. Source mode runs the actualsrc/cli.tswith Bun and sets the pairedOMO_LSP_DAEMON_CLI/OMO_LSP_DAEMON_VERSIONoverride to that source CLI plus the package version. The OpenCode adapter supplies only the threeLSP_TOOLS_MCP_*translator inputs for request context:.opencode/lsp.json,.omo/lsp.json,.omo/lsp-client.json, then the OpenCode user config and install-decision paths. - Legacy cleanup input:
test/fixtures/legacy-path-vectors.jsonfreezes the pre-migration natural Unix, hashed Unix, and Windows named-pipe paths for the installer cleanup work. Do not regenerate those paths from the new resolver. - Clean builds:
scripts/clean-dist.mjsremoves the complete olddisttree before TypeScript and Bun emit new artifacts, so deleted generated files cannot survive a build. - Spawn is detached + log-redirected: child runs
node cli.js daemonwithstdio: ["ignore", logFd, logFd](→daemon.log) andunref(), so the parent session never blocks on it. On Windows every detached/background spawn setswindowsHide: true(daemon spawn, version reaper) so periodic helpers never flash a conhost window. The launcher preservesprocess.execPathwhile it exists, falls back to an existing absoluteprocess.argv0, then to PATH-resolvednode; spawn errors are recorded in the log instead of escaping as an uncaught child-process event. - Build before use:
bun run build:lsp-daemon(npm ci+npm run build) before anything needingdist/. Shipped via the rootpackage.jsonfilesarray (packages/lsp-daemon/{package.json,dist}).