Skip to content

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.

PackageRolectx key
packages/lsp/lspService definition: LspService + provider registry + closed result typesctx.lsp
packages/lsp/lsp-stdioService provider: generic stdio language-server hostregisters providers on ctx.lsp
packages/lsp/tool-lspConsumer: the model-facing lsp tool + system-prompt guidanceregisters 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:

ts
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.

ts
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:

ts
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 through ctx.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:

yaml
plugins:
  lsp-stdio:
    servers:
      typescript:
        command: typescript-language-server
        args: ['--stdio']
        extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescript' }
LspLocalServerConfig keyDefaultNotes
commandexecutable (absolute, or resolved on PATH at load)
extensionToLanguagerequired map, lowercase leading-dot keys
args / env[] / {}no shell; env merged over scrubbed ambient env
initializationOptions / configurationnullstatic initialize options / every workspace/configuration answer
maxMessageBytes16 000 000largest single framed message accepted from the server
maxStderrBytes1 000 000stderr tail retained for diagnostics
maxDocumentBytes4 000 000largest source file the host opens
shutdownTimeoutMs5 000graceful shutdown/exit budget before escalation
killGraceMs2 000request-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:

ParameterNotes
operationrequired: goToDefinition | findReferences | goToImplementation | hover
file_pathsource file, relative to workspace or absolute
line / characterone-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.

ts
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 text

Packages

Package
@deepseek-ai/dsh-lsp
@deepseek-ai/dsh-lsp-stdio
@deepseek-ai/dsh-tool-lsp

Further reading

  • Code runtime — where lsp sits alongside search/read navigation
  • Tool presentationtimeoutMs and the tool-call timeout policy
  • Sandbox & subprocess — how ctx.subprocess launches language servers
  • docs/subsystems/lsp.md — the official LSP navigation reference
  • packages/lsp/lsp/src/types.tsLspOperation, LspQueryRequest, LspQueryResult
  • packages/lsp/lsp-stdio/src/index.ts — stdio server hosting and config defaults