# LSP navigation English | [中文](lsp.zh.md) The LSP seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: Service Definition ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic Service Provider ([dsh-lsp-stdio](../../packages/lsp/lsp-stdio), a configured stdio language-server host), and Consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation. Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) ## Operations and coordinates The seam and model expose exactly four semantic queries; the union is closed, so adding one is a compile-enforced change across the seam, providers, and the tool. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing tool owns the one-based cursor convention and converts on the way in and out. ```ts type-equiv /** * The four semantic queries the seam and model expose. A closed union: adding an operation is a * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are * not operations here; they need different schemas. */ type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' ``` ```ts type-equiv /** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ interface LspPosition { /** Zero-based line. */ readonly line: number /** Zero-based UTF-16 code-unit offset within the line. */ readonly character: number } ``` ```ts type-equiv /** A zero-based UTF-16 half-open range `[start, end)`. */ interface LspRange { readonly start: LspPosition readonly end: LspPosition } ``` ## Request Every field is required: `workspaceRoot` is caller-supplied, `languageId` comes from the provider's registration (not the request), and consumers own timeouts and result limits — so no field needs implementation defaulting and there is no `resolve()` step. The provider receives the caller's request plus the derived `languageId`, which only synchronizes the transient document and never participates in selection. ```ts type-equiv /** * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, * `languageId` comes from the provider registration (not here), and consumers own timeouts and * result limits — so no field needs implementation defaulting and there is no `resolve()` step. */ interface LspQueryRequest { /** Which semantic query to run. */ readonly operation: LspOperation /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ readonly filePath: string /** The zero-based UTF-16 cursor position to query at. */ readonly position: LspPosition /** The workspace root the provider resolves against and indexes; required, never defaulted. */ readonly workspaceRoot: string } ``` ```ts type-equiv /** * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` * the seam derived from the provider's extension mapping. The language id only synchronizes the * transient document; it does not participate in selection. */ interface LspProviderQuery extends LspQueryRequest { /** The LSP language id for `filePath`, from this provider's extension mapping. */ readonly languageId: string } ``` ## Result A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `findReferences` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceUri`, the provider's canonical workspace `file:` URI. A caller relativizing location URIs uses that coordinate rather than applying host-platform path rules to the possibly-symlinked request root. ```ts type-equiv /** One resolved location: a document URI and the range within it. */ interface LspLocation { /** The target document URI (`file:` or otherwise), verbatim from the server. */ readonly uri: string /** The range within the target document. */ readonly range: LspRange } ``` ```ts type-equiv /** Normalized hover content, or `null` for no hover at the position. */ interface LspHover { /** The normalized hover text (markdown or plaintext, provider-joined). */ readonly contents: string /** The range the hover applies to, when the server supplied one. */ readonly range?: LspRange } ``` ```ts type-equiv /** * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. * * The `locations` variant carries `resolvedWorkspaceUri`: the provider's canonical `file:` URI for * the request's workspace root. A caller that relativizes location URIs MUST use this, not parse the * request's possibly symlinked process path with host-platform rules; the execution platform may * differ from the caller's. */ type LspQueryResult = | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceUri: string } | { readonly kind: 'hover'; readonly hover: LspHover | null } ``` ## Provider and service A provider owns a stable branded `id` and an exclusive lowercase leading-dot extension map. `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` `LSP_UNAVAILABLE`. The seam exposes no protocol types, process/document controls, or generic JSON-RPC escape hatch. ```ts type-equiv /** * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). * `findReferences` always includes declarations — the provider enforces this internally; callers * get no flag. */ interface LspProvider { /** Stable provider identity, reserved atomically with the extension mappings. */ readonly id: LspProviderId /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ readonly extensionToLanguage: Readonly> /** * Run one query. The seam has already selected this provider and derived `languageId`. * @param request - the resolved provider query (caller request + derived language id). * @param signal - optional cancellation; the provider stops its own work when it aborts. * @returns the normalized, closed-union result. */ query(request: LspProviderQuery, signal?: AbortSignal): Promise } ``` ```ts type-equiv /** * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query * execution; exposes exactly the four operations and no protocol escape hatch. */ interface LspService { /** * Register a provider, atomically reserving its id and every normalized extension. Any conflict * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all * reservations. Disposed with the calling fiber. * @param provider - the backend to register. * @returns a synchronous disposer releasing the id and all extension reservations. */ registerProvider(provider: LspProvider): () => void /** * Select a provider by the file's extension and run one query. Selection is per-query and * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. * @param request - the normalized query. * @param signal - optional cancellation forwarded to the selected provider. * @returns the normalized, closed-union result. */ query(request: LspQueryRequest, signal?: AbortSignal): Promise } ``` `LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. ## Cordis API Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). ### `ctx.lsp` — `LspService` The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query execution; exposes exactly the four operations and no protocol escape hatch. ```ts cordis-catalog /** * Register a provider, atomically reserving its id and every normalized extension. Any conflict * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all * reservations. Disposed with the calling fiber. * @param provider - the backend to register. * @returns a synchronous disposer releasing the id and all extension reservations. */ registerProvider(provider: LspProvider): () => void /** * Select a provider by the file's extension and run one query. Selection is per-query and * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. * @param request - the normalized query. * @param signal - optional cancellation forwarded to the selected provider. * @returns the normalized, closed-union result. */ query(request: LspQueryRequest, signal?: AbortSignal): Promise ``` Source: [`packages/lsp/lsp/src/types.ts:113`](../../packages/lsp/lsp/src/types.ts)