LSP (Language Server Protocol) integration gives the model semantic code navigation that raw text grep cannot: precise definitions, references, implementations, and hover documentation resolved by a real language server that understands the language, not just the bytes on disk. dsh exposes this through one capability seam, ctx.lsp, split across three packages with the standard seam + provider + consumer structure.
| Package | Role | ctx key |
|---|---|---|
packages/lsp/lsp | Service definition: LspService + provider registry + closed result types | ctx.lsp |
packages/lsp/lsp-stdio | Service provider: generic stdio language-server host | registers providers on ctx.lsp |
packages/lsp/tool-lsp | Consumer: the model-facing lsp tool + system-prompt guidance | registers on ctx.tools |
The payoff: a provider swap does not change how the model asks for navigation, because the tool schema stays stable behind the seam.
Why an agent harness wants LSP
Text-based navigation is ambiguous — the symbol List appears in many files, but only one is the definition the compiler knows. A language server provides:
- goToDefinition — where a symbol is declared;
- findReferences — every use (always includes the declaration);
- goToImplementation — the concrete impls of an interface/abstract symbol;
- hover — type signatures and doc comments at a position.
Because these are precise and cheap to do correctly, the system prompt positions LSP as a complement to search/read: use search/read for ordinary navigation; use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.
The seam: ctx.lsp and the contract
LspService (packages/lsp/lsp/src/types.ts) exposes exactly four operations as a closed union, so adding one is a compile-enforced change across the seam, providers, and tool:
type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'Positions and ranges are zero-based UTF-16, matching the LSP wire convention; the model-facing tool owns the one-based cursor convention and converts on the way in and out.
interface LspQueryRequest {
operation: LspOperation
filePath: string // source file, relative to workspaceRoot or absolute
position: LspPosition // { line, character } zero-based UTF-16
workspaceRoot: string // required, never defaulted
}The result is a second closed discriminated union — consumers switch on kind so a new arm breaks compilation until handled:
type LspQueryResult =
| { kind: 'locations'; locations: readonly LspLocation[]; resolvedWorkspaceUri: string }
| { kind: 'hover'; hover: LspHover | null }resolvedWorkspaceUri is the provider's canonical file: URI for the workspace root; a caller relativizing location URIs must use this coordinate rather than parsing the possibly-symlinked request path with host-platform rules.
Providers and selection
A LspProvider owns a stable branded id and an exclusive lowercase leading-dot extension map (e.g. { '.ts': 'typescript' }). registerProvider reserves the id and every extension atomically — an invalid or conflicting registration publishes nothing — and its disposer releases all reservations. Selection is per-query and order-independent; no match throws LspError with a stable code such as LSP_UNAVAILABLE, LSP_INVALID_PROVIDER, LSP_CONFLICT, LSP_DISPOSED, LSP_UNSUPPORTED_OPERATION, or LSP_MALFORMED_RESPONSE. The seam surfaces no protocol types, process/document controls, or generic JSON-RPC escape hatch — callers reason only in the four operations.
findReferences always includes the declaration — the provider enforces this internally, so callers get no flag.
The provider: dsh-lsp-stdio
lsp-stdio (packages/lsp/lsp-stdio/src/index.ts) is the generic stdio backend. One plugin instance configures a table of server commands, and registers one isolated provider per entry. Every provider:
- lazily single-flights one server process per canonical workspace target (sources read through
ctx.fs, launches throughctx.subprocess, so local and remote implementations share one host); - serves transient-open queries through it;
- replaces a selected transport that fails before or during the next read-only query.
Config shape — servers is a record of provider id → local server config:
plugins:
lsp-stdio:
servers:
typescript:
command: typescript-language-server
args: ['--stdio']
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescript' }LspLocalServerConfig key | Default | Notes |
|---|---|---|
command | — | executable (absolute, or resolved on PATH at load) |
extensionToLanguage | — | required map, lowercase leading-dot keys |
args / env | [] / {} | no shell; env merged over scrubbed ambient env |
initializationOptions / configuration | null | static initialize options / every workspace/configuration answer |
maxMessageBytes | 16 000 000 | largest single framed message accepted from the server |
maxStderrBytes | 1 000 000 | stderr tail retained for diagnostics |
maxDocumentBytes | 4 000 000 | largest source file the host opens |
shutdownTimeoutMs | 5 000 | graceful shutdown/exit budget before escalation |
killGraceMs | 2 000 | request-cancel and SIGTERM→SIGKILL grace |
Before publishing any provider, the plugin resolves every executable at load (after credential scrubbing); each process then launches lazily on its first matching query. Lifecycle is effect-scoped: disposal unregisters from ctx.lsp and tears down every live server. Supporting modules include framing.ts (JSON-RPC message encode/decode), protocol.ts, translate.ts (normalization, position encoding negotiation, supportsOperation), instance.ts, and connection.ts.
The consumer: dsh-tool-lsp
tool-lsp registers the single read-only lsp tool over ctx.lsp:
| Parameter | Notes |
|---|---|
operation | required: goToDefinition | findReferences | goToImplementation | hover |
file_path | source file, relative to workspace or absolute |
line / character | one-based UTF-16 cursor coordinates (the tool converts to the seam's zero-based positions) |
It requires a session workspace cwd with no fallback (LSP_WORKSPACE_REQUIRED otherwise), caps and renders results, and attaches a configurable timeout budget (timeoutMs, default 60 000, ≤ MAX_TIMER_DELAY_MS) that dsh-tool-call-timeout-policy enforces over the queued open/query/close lifecycle.
interface Config {
maxLocations?: number // default 100 — cap before an omission marker
maxResultChars?: number // default 16000 — largest rendered result
timeoutMs?: number // default 60000 — tool-call timeout budget
}Without any registered provider, a query returns the structured LSP_UNAVAILABLE error rather than changing the schema — the model-visible contract stays stable across compositions.
Lifecycle sketch (ASCII)
model: lsp({ operation: "goToDefinition", file_path, line, character })
│ (1-based → 0-based UTF-16)
▼
ctx.lsp.query(LspQueryRequest) // select provider by .ext → language id
▼
lsp-stdio provider (lazily spawned server for workspace)
│ transient-open document, run request, close
▼
LspQueryResult = { kind: 'locations', locations, resolvedWorkspaceUri }
| { kind: 'hover', hover }
│ (provider normalizes; tool re-renders caps)
▼
model sees formatted locations / hover textPackages
| Package |
|---|
@deepseek-ai/dsh-lsp |
@deepseek-ai/dsh-lsp-stdio |
@deepseek-ai/dsh-tool-lsp |
Further reading
- Code runtime — where
lspsits alongsidesearch/readnavigation - Tool presentation —
timeoutMsand the tool-call timeout policy - Sandbox & subprocess — how
ctx.subprocesslaunches language servers docs/subsystems/lsp.md— the official LSP navigation referencepackages/lsp/lsp/src/types.ts—LspOperation,LspQueryRequest,LspQueryResultpackages/lsp/lsp-stdio/src/index.ts— stdio server hosting and config defaults