Files
deepseek-harness/docs/core-data-structures/lsp.md
T
Dudu-0223 8e8f90e235 fix(lsp): address codex review round 1
Lifecycle and safety fixes from the external review:
- Observe abort while awaiting the initialize handshake, so a server that never
  replies can't defeat the tool-timeout signal.
- On an aborted request the server won't cancel, tear the instance down after a
  bounded grace instead of releasing the serialized queue with work still live
  (prevents overlapping document lifecycles).
- Re-check provider disposal after the canonicalize/read awaits so a query can't
  spawn an unowned server after disposeAll().
- Read the source through one open handle (stat + read on the same fd) to close
  the realpath-vs-read TOCTOU; decode with a fatal UTF-8 decoder so a legitimate
  U+FFFD is not misclassified as invalid.
- Validate and read the source BEFORE spawning a server (pre-start rejection).
- Require an explicit openClose for option-form textDocumentSync.
- Reject nonpositive teardown budgets and non-executable absolute commands at
  load; surface unsupported operations as structured LSP_UNSUPPORTED_OPERATION.
- Retain the stderr tail (fatal diagnostics land at exit), not the prefix.
- Catalog the seam vocabulary in docs/core-data-structures/lsp.md.
2026-07-16 13:11:07 +08:00

5.9 KiB

LSP navigation

The LSP seam — a capability seam exposing semantic code navigation on one ctx.lsp service, split across packages: interface (dsh-lsp, ctx.lsp + the provider registry), a generic implementation (dsh-lsp-local, a configured stdio language-server host), and consumer (dsh-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. A provider swap does not change how the model asks for navigation.

Source: 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.

type LspOperation = 'definition' | 'references' | 'implementation' | 'hover'
interface LspPosition {
  /** Zero-based line. */
  readonly line: number
  /** Zero-based UTF-16 code-unit offset within the line. */
  readonly character: number
}
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.

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
}
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. references always includes declarations — the provider enforces this internally, so callers get no flag.

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
}
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
}
type LspQueryResult =
  | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] }
  | { 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.

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<Record<string, string>>
  /**
   * 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<LspQueryResult>
}
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<LspQueryResult>
}

LspProviderId is the seam's branded id (Branded<'LspProviderId'> from dsh-brand); LspError extends HarnessError with a stable code (LSP_INVALID_PROVIDER, LSP_CONFLICT, LSP_UNAVAILABLE, LSP_UNSUPPORTED_OPERATION) callers route on instead of parsing message.