瀏覽代碼

feat(lsp): LSP capability seam, generic stdio provider, and lsp tool

Implements the LSP capability seam RFC as three packages: dsh-lsp (the
ctx.lsp interface — provider registry by branded id + exclusive extension
mapping, per-query order-independent selection, closed request/result
vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server
provider — Content-Length JSON-RPC framing, per-(provider, workspace) process
single-flight, transient didOpen/query/didClose, an abortable per-instance
queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and
bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool —
four operations, one-based UTF-16 cursor conversion, workspace-grouped location
rendering, hover capping, a required session workspace, and a timeout budget).

Why: an agent had text search and file reads but no way to identify a program
symbol — follow an alias, connect an interface to implementations, or read an
inferred type — before changing code. Splitting model contract, seam, and local
subprocess behavior keeps the four semantic queries stable across future remote
or sandbox-native providers without leaking a JSON-RPC escape hatch.
Dudu-0223 2 月之前
父節點
當前提交
d0029d8d60
共有 56 個文件被更改,包括 4527 次插入 和 30 次删除
  1. 8 7
      AGENTS.md
  2. 1 0
      docs/architecture.md
  3. 55 0
      docs/config-catalog.md
  4. 18 0
      docs/module-graph.md
  5. 1 1
      docs/rfc/INDEX.md
  6. 2 2
      docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml
  7. 4 4
      docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md
  8. 4 4
      docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md
  9. 47 0
      docs/tool-catalog.md
  10. 5 0
      knip.json
  11. 8 8
      packages/README.md
  12. 1 1
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  13. 13 0
      packages/lsp/README.md
  14. 49 0
      packages/lsp/lsp-local/README.md
  15. 43 0
      packages/lsp/lsp-local/package.json
  16. 240 0
      packages/lsp/lsp-local/src/connection.ts
  17. 99 0
      packages/lsp/lsp-local/src/framing.ts
  18. 104 0
      packages/lsp/lsp-local/src/host.ts
  19. 255 0
      packages/lsp/lsp-local/src/index.ts
  20. 293 0
      packages/lsp/lsp-local/src/instance.ts
  21. 80 0
      packages/lsp/lsp-local/src/protocol.ts
  22. 210 0
      packages/lsp/lsp-local/src/translate.ts
  23. 73 0
      packages/lsp/lsp-local/tests/built-lib.e2e.ts
  24. 226 0
      packages/lsp/lsp-local/tests/connection.spec.ts
  25. 144 0
      packages/lsp/lsp-local/tests/fixture-server.ts
  26. 76 0
      packages/lsp/lsp-local/tests/framing.spec.ts
  27. 105 0
      packages/lsp/lsp-local/tests/host.spec.ts
  28. 184 0
      packages/lsp/lsp-local/tests/instance.spec.ts
  29. 200 0
      packages/lsp/lsp-local/tests/lifecycle.spec.ts
  30. 78 0
      packages/lsp/lsp-local/tests/provider.spec.ts
  31. 153 0
      packages/lsp/lsp-local/tests/translate.spec.ts
  32. 111 0
      packages/lsp/lsp-local/tests/typescript-server.e2e.ts
  33. 33 0
      packages/lsp/lsp-local/tsconfig.json
  34. 38 0
      packages/lsp/lsp/README.md
  35. 34 0
      packages/lsp/lsp/package.json
  36. 21 0
      packages/lsp/lsp/src/brand.ts
  37. 156 0
      packages/lsp/lsp/src/index.ts
  38. 124 0
      packages/lsp/lsp/src/types.ts
  39. 187 0
      packages/lsp/lsp/tests/lsp.spec.ts
  40. 24 0
      packages/lsp/lsp/tsconfig.json
  41. 56 0
      packages/lsp/tool-lsp/README.md
  42. 45 0
      packages/lsp/tool-lsp/package.json
  43. 130 0
      packages/lsp/tool-lsp/src/index.ts
  44. 158 0
      packages/lsp/tool-lsp/src/render.ts
  45. 19 0
      packages/lsp/tool-lsp/src/session-cwd.ts
  46. 93 0
      packages/lsp/tool-lsp/tests/integration.spec.ts
  47. 24 0
      packages/lsp/tool-lsp/tests/load-path.spec.ts
  48. 125 0
      packages/lsp/tool-lsp/tests/render.spec.ts
  49. 164 0
      packages/lsp/tool-lsp/tests/tool-lsp.spec.ts
  50. 33 0
      packages/lsp/tool-lsp/tsconfig.json
  51. 146 1
      pnpm-lock.yaml
  52. 16 0
      scripts/gen-tool-catalog.ts
  53. 2 0
      scripts/verify-package-readme-model-experience.ts
  54. 1 0
      tsconfig.base.json
  55. 4 1
      tsconfig.build.json
  56. 4 1
      tsconfig.json

+ 8 - 7
AGENTS.md

@@ -15,6 +15,7 @@ packages/    Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
   llm/         LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin)
   bash/        bash executor seam + local impl + model-facing bash tools
   fs/          filesystem seam + local impl + policy gate + read/write/edit tools
+  lsp/         LSP seam + stdio provider + lsp tool
   skill/       skill provider registry + local impl + catalog/loader tool
   web/         web seam + search/fetch providers + model-facing web tools
   compact/     compaction seam + basic backend
@@ -90,13 +91,13 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 ## Conventions
 
 - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
-- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only.
+- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
 - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
 - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
-- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
-- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
+- **Model-visible ⟺ logged**: anything reaching a model request must be reconstructable from the session log; a new model-visible input requires a session event.
+- **Plugins, not loop changes**: new behavior goes on documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
 - **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively.
 - **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template).
 - **No hardcoded tunables in plugins**: deployment choices are defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed.
@@ -119,15 +120,15 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle,
 
 ## Type safety and documentation
 
-Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class.
+Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep docs at the declaring seam, protocol, or class.
 
-Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions rather than disabling a rule globally.
+Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, with narrow justified exceptions rather than disabling a rule.
 
-Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
+Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document current state not history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
 
 ## Editing these instructions
 
-`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space.
+`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling only when the contract needs more space.
 
 ## Vendoring policy
 

+ 1 - 0
docs/architecture.md

@@ -28,6 +28,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
 | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) |
 | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
 | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
+| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | language-server provider registry and semantic navigation |
 | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure |
 | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
 | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction |

+ 55 - 0
docs/config-catalog.md

@@ -419,6 +419,42 @@ export interface Config {
 
 Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts)
 
+## `@deepseek-ai/dsh-lsp-local`
+
+Requires: `lsp`
+
+```ts config-catalog
+/** Plugin configuration: one server command plus its extension mapping and host bounds. */
+export interface Config {
+  /** Stable provider id, reserved on `ctx.lsp` with the extensions. */
+  providerId: string
+  /** Executable to spawn (absolute, or resolved on PATH at load). */
+  command: string
+  /** Arguments passed to the executable (no shell). */
+  args: string[]
+  /** Extra env vars merged on top of the scrubbed ambient env. */
+  env: Record<string, string>
+  /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
+  extensionToLanguage: Record<string, string>
+  /** Static `initialize` options forwarded to the server. */
+  initializationOptions: unknown
+  /** Static answer to every `workspace/configuration` item. */
+  configuration: unknown
+  /** Largest single framed message accepted from the server (bytes). */
+  maxMessageBytes: number
+  /** Largest stderr tail retained for diagnostics (bytes). */
+  maxStderrBytes: number
+  /** Largest source file this host will open (bytes). */
+  maxDocumentBytes: number
+  /** Graceful `shutdown`/`exit` budget before escalation (ms). */
+  shutdownTimeoutMs: number
+  /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
+  killGraceMs: number
+}
+```
+
+Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts)
+
 ## `@deepseek-ai/dsh-mcp-client`
 
 Requires: `tools`
@@ -901,6 +937,24 @@ export interface Config {
 
 Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts)
 
+## `@deepseek-ai/dsh-tool-lsp`
+
+Requires: `tools` · `lsp` · `systemPrompt`
+
+```ts config-catalog
+/** Plugin configuration: result caps and the timeout budget. */
+export interface Config {
+  /** Largest number of rendered locations before an omission marker (default 100). */
+  maxLocations?: number
+  /** Largest hover length in characters after normalization (default 16000). */
+  maxHoverChars?: number
+  /** Tool-call timeout budget in ms (default 60000). */
+  timeoutMs?: number
+}
+```
+
+Source: [`packages/lsp/tool-lsp/src/index.ts:56`](../packages/lsp/tool-lsp/src/index.ts)
+
 ## `@deepseek-ai/dsh-tool-skill`
 
 Requires: `tools` · `skills`
@@ -1214,6 +1268,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
 - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
 - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
+- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
 - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
 - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
 - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))

+ 18 - 0
docs/module-graph.md

@@ -115,6 +115,11 @@ flowchart TD
   subgraph group_guard["packages/guard"]
     pkg_repeat_tool_guard["repeat-tool-guard"]
   end
+  subgraph group_lsp["packages/lsp"]
+    pkg_lsp["lsp"]
+    pkg_lsp_local["lsp-local"]
+    pkg_tool_lsp["tool-lsp"]
+  end
   subgraph group_mcp["packages/mcp"]
     pkg_mcp_client["mcp-client"]
   end
@@ -139,6 +144,8 @@ flowchart TD
   pkg_fs --> pkg_brand
   pkg_fs --> pkg_llm
   pkg_web --> pkg_llm
+  pkg_lsp --> pkg_brand
+  pkg_lsp --> pkg_llm
   pkg_sandbox --> pkg_llm
   pkg_agent --> pkg_brand
   pkg_agent --> pkg_llm
@@ -162,6 +169,10 @@ flowchart TD
   pkg_session_persistence --> pkg_session
   pkg_llm_replay --> pkg_llm
   pkg_llm_replay --> pkg_session
+  pkg_lsp_local --> pkg_brand
+  pkg_lsp_local --> pkg_llm
+  pkg_lsp_local --> pkg_lsp
+  pkg_lsp_local --> pkg_timeout
   pkg_sandbox_local --> pkg_llm
   pkg_sandbox_local --> pkg_sandbox
   pkg_bash_local --> pkg_bash
@@ -273,6 +284,10 @@ flowchart TD
   pkg_tool_ask_user --> pkg_user_interaction
   pkg_repeat_tool_guard --> pkg_agent
   pkg_repeat_tool_guard --> pkg_tools
+  pkg_tool_lsp --> pkg_llm
+  pkg_tool_lsp --> pkg_lsp
+  pkg_tool_lsp --> pkg_system_prompt
+  pkg_tool_lsp --> pkg_tools
   pkg_mcp_client --> pkg_llm
   pkg_mcp_client --> pkg_tools
   pkg_tool_workflow --> pkg_agent
@@ -370,6 +385,7 @@ flowchart TD
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
 | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
+| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
 | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
 | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
@@ -383,6 +399,7 @@ flowchart TD
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) |
 | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
 | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
 | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -412,6 +429,7 @@ flowchart TD
 | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
 | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
+| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |

+ 1 - 1
docs/rfc/INDEX.md

@@ -28,7 +28,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 |---|---|
 | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
 | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
-| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 |
 
 ### Process
 
@@ -146,6 +145,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 |
 | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 |
 | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 |
+| [LSP capability seam and model-facing query tool](implemented/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 |
 
 ### Process
 

+ 2 - 2
docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml → docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5
-2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3
+2026-07-15-lsp-capability-seam.md: 90cc7fce8ce86582cc27bd70fadcc46309438983
+2026-07-15-lsp-capability-seam.zh.md: 12873d33684255b7177a78dd4588e11aa61eb26b

+ 4 - 4
docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md → docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md

@@ -1,6 +1,6 @@
 # RFC: LSP capability seam and model-facing query tool
 
-Status: proposed
+Status: implemented
 
 English | [中文](2026-07-15-lsp-capability-seam.zh.md)
 
@@ -12,7 +12,7 @@ LSP support has three owners: the model needs a stable query schema, the harness
 
 Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index.
 
-## Proposal
+## Decision
 
 Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation:
 
@@ -171,7 +171,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
 
 **Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets.
 
-## Acceptance criteria
+## Testing
 
 - Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication.
 - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation.
@@ -185,7 +185,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
 - Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup.
 - Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change.
 
-## Risks
+## Consequences
 
 Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
 

+ 4 - 4
docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md → docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md

@@ -1,6 +1,6 @@
 # RFC: LSP 能力服务边界与面向模型的查询工具
 
-Status: proposed
+Status: implemented
 
 [English](2026-07-15-lsp-capability-seam.md) | 中文
 
@@ -12,7 +12,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程
 
 许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。
 
-## 提案
+## 决策
 
 将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现:
 
@@ -171,7 +171,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
 
 **内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。
 
-## 验收标准
+## 测试
 
 - Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。
 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。
@@ -185,7 +185,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
 - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。
 - Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
 
-## 风险
+## 影响
 
 各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
 

+ 47 - 0
docs/tool-catalog.md

@@ -20,6 +20,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
 | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
+| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
 | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
 | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
@@ -371,6 +372,52 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
 
 The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.
 
+## `@deepseek-ai/dsh-tool-lsp`
+
+### `lsp`
+
+Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "operation": {
+      "type": "string",
+      "description": "definition, references, implementation, or hover.",
+      "enum": [
+        "definition",
+        "references",
+        "implementation",
+        "hover"
+      ]
+    },
+    "file_path": {
+      "type": "string",
+      "description": "The source file to query, relative to the workspace or absolute."
+    },
+    "line": {
+      "type": "number",
+      "description": "One-based line of the cursor."
+    },
+    "character": {
+      "type": "number",
+      "description": "One-based UTF-16 column of the cursor."
+    }
+  },
+  "required": [
+    "operation",
+    "file_path",
+    "line",
+    "character"
+  ]
+}
+```
+
+Source: [`packages/lsp/tool-lsp/src/index.ts`](../packages/lsp/tool-lsp/src/index.ts)
+
+The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.
+
 ## `@deepseek-ai/dsh-tool-skill`
 
 ### `skill`

+ 5 - 0
knip.json

@@ -118,6 +118,11 @@
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"],
       "ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"]
+    },
+    "packages/lsp/lsp-local": {
+      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"],
+      "ignoreDependencies": ["typescript-language-server"]
     }
   }
 }

+ 8 - 8
packages/README.md

@@ -1,10 +1,10 @@
 # Packages
 
-Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
+Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md), [root](../AGENTS.md#conventions).
 
 ## Hierarchy
 
-Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
+Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — roles, ctx keys, and the product-vs-support split live there, next to the code.
 
 | Group | Role | Release expectation |
 |---|---|---|
@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
 | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
 | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
+| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
 | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
 | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
 | [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface |
@@ -23,20 +24,19 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
 | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
 | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
-| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
+| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect live plugins/services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
 | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
-| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
+| [`ui/`](ui/README.md) | Editor/client integration: ACP bridge, JSON-RPC SDK server, app packages, user-approval/interaction seams, ask-user tool | Product — stable surface |
 | [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
 | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
 
-The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).
+The split is the point: a package's group says whether it is product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a top-level group is a deliberate act (extend the group READMEs and this table).
 
 ## Dependencies
 
 The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
+The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other spine plugins). The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
 
-The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
-
-Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
+Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or its [allowlist](../scripts/verify-package-readme-limitations.ts).

+ 1 - 1
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
   it('boots every shipped tool package and harvests its model-facing schemas', async () => {
     const catalog = await collectToolCatalog()
     const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
-    expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
+    expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'lsp', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
     // Every tool carries a JSON-Schema `parameters` object (what the model sees).
     for (const entry of catalog) {
       for (const schema of entry.schemas) {

+ 13 - 0
packages/lsp/README.md

@@ -0,0 +1,13 @@
+# lsp/ - LSP capability family
+
+The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` |
+| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) |
+| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) |
+
+The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
+
+See the [LSP capability seam RFC](../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime.

+ 49 - 0
packages/lsp/lsp-local/README.md

@@ -0,0 +1,49 @@
+# @deepseek-ai/dsh-lsp-local
+
+A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays.
+
+Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
+
+## What it does
+
+- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
+- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
+- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel.
+- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. |
+| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
+| `args` | `[]` | Arguments passed to the executable. |
+| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). |
+| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
+| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
+| `configuration` | `null` | Static answer to every `workspace/configuration` item. |
+| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. |
+| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. |
+| `maxDocumentBytes` | `4000000` | Largest source file this host will open. |
+| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. |
+| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. |
+
+The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query.
+
+## Protocol behavior
+
+Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line.
+
+## Security boundary
+
+The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider.
+
+## Model Experience
+
+Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself.
+
+## Known Limitations and Deferred Work
+
+- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
+- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
+- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal.

+ 43 - 0
packages/lsp/lsp-local/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "@deepseek-ai/dsh-lsp-local",
+  "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-lsp": "^0.0.1",
+    "@deepseek-ai/dsh-timeout": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-lsp": "workspace:^",
+    "@deepseek-ai/dsh-timeout": "workspace:^",
+    "cordis": "^4.0.0-rc.7",
+    "typescript": "^6.0.3",
+    "typescript-language-server": "^5.0.0"
+  }
+}

+ 240 - 0
packages/lsp/lsp-local/src/connection.ts

@@ -0,0 +1,240 @@
+/**
+ * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound
+ * requests/notifications, and inbound server→client requests: it answers `workspace/configuration`
+ * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs
+ * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the
+ * child handle so the instance owns process-signal teardown.
+ * @module @deepseek-ai/dsh-lsp-local/connection
+ */
+
+import type { ChildProcessByStdio } from 'node:child_process'
+import { spawn } from 'node:child_process'
+import type { Readable, Writable } from 'node:stream'
+import { encodeMessage, MessageDecoder } from './framing.ts'
+
+/** How to launch the server and answer its config requests. */
+export interface ConnectionSpec {
+  /** The resolved absolute executable path (no shell). */
+  readonly command: string
+  /** Arguments passed to the executable. */
+  readonly args: readonly string[]
+  /** The child's working directory (the canonical workspace). */
+  readonly cwd: string
+  /** The child's environment (credential-scrubbed, with overrides applied). */
+  readonly env: Record<string, string>
+  /** Largest single framed message accepted from the server. */
+  readonly maxMessageBytes: number
+  /** Largest stderr tail retained for diagnostics. */
+  readonly maxStderrBytes: number
+  /** Static answer to every `workspace/configuration` item. */
+  readonly configuration: unknown
+}
+
+interface Pending {
+  resolve: (value: unknown) => void
+  reject: (error: Error) => void
+}
+
+/** A live JSON-RPC endpoint bound to one child process. */
+export class LspConnection {
+  private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
+  private readonly decoder: MessageDecoder
+  private readonly pending = new Map<number, Pending>()
+  private nextId = 1
+  private stderr = ''
+  private closeReason: Error | undefined
+  /** Set once the process has fully exited; the instance awaits it during teardown. */
+  readonly closed: Promise<void>
+
+  /**
+   * @param spec - how to launch the server and answer its config requests.
+   * @param onServerRequest - answers a server→client request; rejects to send an error response.
+   */
+  constructor(
+    private readonly spec: ConnectionSpec,
+    private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
+  ) {
+    this.decoder = new MessageDecoder(spec.maxMessageBytes)
+    this.child = spawn(spec.command, [...spec.args], {
+      cwd: spec.cwd,
+      env: spec.env,
+      stdio: ['pipe', 'pipe', 'pipe'],
+    })
+    this.closed = new Promise<void>((resolve) => {
+      this.child.on('close', () => {
+        const reason = this.closeReason ?? new Error('language server exited')
+        // Record the reason so any request issued AFTER close rejects immediately instead of hanging
+        // (a closed process sends no further responses).
+        this.closeReason = reason
+        this.failAll(reason)
+        resolve()
+      })
+    })
+    this.child.on('error', (error) => { this.fail(error) })
+    // A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE
+    // during teardown does not crash the process. Pending requests fail via the 'close' handler.
+    /* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */
+    this.child.stdin.on('error', () => { /* swallow */ })
+    this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
+    this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
+  }
+
+  /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
+  get pid(): number {
+    /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */
+    return this.child.pid ?? -1
+  }
+
+  /** The retained stderr tail, for diagnostics on a failed server. */
+  get stderrTail(): string {
+    return this.stderr
+  }
+
+  /**
+   * Send a request and await its result.
+   * @param method - the JSON-RPC method.
+   * @param params - the request params.
+   * @returns the response result; rejects on an error response, write failure, or close.
+   */
+  request(method: string, params: unknown): Promise<unknown> {
+    const id = this.nextId++
+    const promise = new Promise<unknown>((resolve, reject) => {
+      if (this.closeReason !== undefined) {
+        reject(this.closeReason)
+        return
+      }
+      this.pending.set(id, { resolve, reject })
+      try {
+        this.write({ jsonrpc: '2.0', id, method, params })
+      } catch (error) {
+        /* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed
+           'error' listener, so this synchronous catch is a defensive guard. */
+        this.pending.delete(id)
+        reject(asError(error))
+        /* v8 ignore stop */
+      }
+    })
+    // A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later
+    // when the process closes; a benign no-op handler keeps that from surfacing as an unhandled
+    // rejection. The returned promise still delivers the rejection to the caller's own await/catch.
+    promise.catch(() => {})
+    return promise
+  }
+
+  /**
+   * Send a notification (no id, no response).
+   * @param method - the JSON-RPC method.
+   * @param params - the notification params.
+   */
+  notify(method: string, params: unknown): void {
+    this.write({ jsonrpc: '2.0', method, params })
+  }
+
+  /**
+   * Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure).
+   * @param requestId - the numeric id of the request to cancel.
+   */
+  cancel(requestId: number): void {
+    try {
+      this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } })
+    } catch {
+      // The server is already gone or unwritable; the pending request will fail on close.
+    }
+  }
+
+  /**
+   * The id the NEXT `request()` will use, so the instance can pre-arm a cancel.
+   * @returns the numeric id the next request will be assigned.
+   */
+  peekNextId(): number {
+    return this.nextId
+  }
+
+  /** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */
+  terminate(): void {
+    this.child.kill('SIGTERM')
+  }
+
+  /** Send SIGKILL to the child. */
+  kill(): void {
+    this.child.kill('SIGKILL')
+  }
+
+  private onStdout(chunk: Buffer): void {
+    let messages: unknown[]
+    try {
+      messages = this.decoder.push(chunk)
+    } catch (error) {
+      // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance.
+      this.fail(asError(error))
+      this.child.kill('SIGKILL')
+      return
+    }
+    for (const message of messages) this.dispatch(message)
+  }
+
+  private onStderr(chunk: Buffer): void {
+    if (this.stderr.length >= this.spec.maxStderrBytes) return
+    this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes)
+  }
+
+  private dispatch(message: unknown): void {
+    if (message === null || typeof message !== 'object') return
+    const frame = message as Record<string, unknown>
+    const id = frame.id
+    const method = frame.method
+    if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) {
+      void this.handleServerRequest(id, method, frame.params)
+      return
+    }
+    if (typeof method === 'string') {
+      // A server→client notification (e.g. diagnostics, logs): ignored by this MVP host.
+      return
+    }
+    if (typeof id === 'number') this.handleResponse(id, frame)
+  }
+
+  private async handleServerRequest(id: number | string, method: string, params: unknown): Promise<void> {
+    try {
+      const result = await this.onServerRequest(method, params)
+      this.write({ jsonrpc: '2.0', id, result })
+    } catch (error) {
+      this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } })
+    }
+  }
+
+  private handleResponse(id: number, frame: Record<string, unknown>): void {
+    const pending = this.pending.get(id)
+    if (!pending) return
+    this.pending.delete(id)
+    const error = frame.error
+    if (error !== null && typeof error === 'object') {
+      const record = error as Record<string, unknown>
+      pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response'))
+      return
+    }
+    pending.resolve(frame.result)
+  }
+
+  private write(message: unknown): void {
+    this.child.stdin.write(encodeMessage(message))
+  }
+
+  private fail(error: Error): void {
+    /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */
+    if (this.closeReason === undefined) this.closeReason = error
+    this.failAll(error)
+  }
+
+  private failAll(error: Error): void {
+    const waiting = [...this.pending.values()]
+    this.pending.clear()
+    for (const pending of waiting) pending.reject(error)
+  }
+}
+
+/** Coerce an unknown thrown value to an `Error`. */
+function asError(value: unknown): Error {
+  /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */
+  return value instanceof Error ? value : new Error(String(value))
+}

+ 99 - 0
packages/lsp/lsp-local/src/framing.ts

@@ -0,0 +1,99 @@
+/**
+ * LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder
+ * produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies,
+ * bounding the header and total message size so a hostile or broken server cannot exhaust memory.
+ * @module @deepseek-ai/dsh-lsp-local/framing
+ */
+
+/** The header/body separator in the LSP base protocol. */
+const HEADER_SEPARATOR = '\r\n\r\n'
+
+/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */
+const MAX_HEADER_BYTES = 1 << 16
+
+/**
+ * Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n<utf-8 json>`).
+ * @param message - the JSON-RPC message object to serialize.
+ * @returns the framed bytes ready to write to the server's stdin.
+ */
+export function encodeMessage(message: unknown): Buffer {
+  const body = Buffer.from(JSON.stringify(message), 'utf8')
+  const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
+  return Buffer.concat([header, body])
+}
+
+/**
+ * A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any
+ * whole message bodies that completed. It parses only the `Content-Length` header and ignores other
+ * headers (e.g. `Content-Type`), matching the base protocol.
+ */
+export class MessageDecoder {
+  private buffer: Buffer = Buffer.alloc(0)
+  private readonly maxMessageBytes: number
+
+  /**
+   * @param maxMessageBytes - reject any single framed body larger than this (guards memory).
+   */
+  constructor(maxMessageBytes: number) {
+    this.maxMessageBytes = maxMessageBytes
+  }
+
+  /**
+   * Append a chunk and return every message body that is now complete.
+   * @param chunk - raw bytes from the server's stdout.
+   * @returns the parsed JSON bodies, in arrival order (possibly empty).
+   * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`.
+   */
+  push(chunk: Buffer): unknown[] {
+    this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
+    const messages: unknown[] = []
+    for (;;) {
+      const step = this.next()
+      if (!step.ready) break
+      messages.push(step.message)
+    }
+    return messages
+  }
+
+  /** Parse and consume the next complete message, or report that more bytes are needed. */
+  private next(): { ready: false } | { ready: true; message: unknown } {
+    const separator = this.buffer.indexOf(HEADER_SEPARATOR)
+    if (separator < 0) {
+      if (this.buffer.length > MAX_HEADER_BYTES) {
+        throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`)
+      }
+      return { ready: false }
+    }
+    const headerText = this.buffer.toString('ascii', 0, separator)
+    const contentLength = parseContentLength(headerText)
+    if (contentLength > this.maxMessageBytes) {
+      throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`)
+    }
+    const bodyStart = separator + HEADER_SEPARATOR.length
+    const bodyEnd = bodyStart + contentLength
+    if (this.buffer.length < bodyEnd) return { ready: false }
+    const body = this.buffer.toString('utf8', bodyStart, bodyEnd)
+    this.buffer = this.buffer.subarray(bodyEnd)
+    try {
+      return { ready: true, message: JSON.parse(body) }
+    } catch (error) {
+      /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */
+      throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
+    }
+  }
+}
+
+/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */
+function parseContentLength(headerText: string): number {
+  for (const line of headerText.split('\r\n')) {
+    const colon = line.indexOf(':')
+    if (colon < 0) continue
+    if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue
+    const value = Number(line.slice(colon + 1).trim())
+    if (!Number.isInteger(value) || value < 0) {
+      throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
+    }
+    return value
+  }
+  throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`)
+}

+ 104 - 0
packages/lsp/lsp-local/src/host.ts

@@ -0,0 +1,104 @@
+/**
+ * Host-filesystem source access for the local provider, using Node APIs directly in the
+ * subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not
+ * satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target
+ * identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server
+ * startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the
+ * workspace. External result locations are allowed, but an external path can never become a query
+ * source.
+ * @module @deepseek-ai/dsh-lsp-local/host
+ */
+
+import { readFile, realpath, stat } from 'node:fs/promises'
+import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
+
+/** A validated source: its canonical absolute path and current UTF-8 text. */
+export interface HostSource {
+  /** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */
+  readonly canonicalPath: string
+  /** The file's current text, read as UTF-8. */
+  readonly text: string
+}
+
+/**
+ * Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies
+ * process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
+ * collapse to one instance.
+ * @param workspaceRoot - the caller's workspace root (absolute).
+ * @returns the canonical directory path.
+ * @throws Error when the path is missing or not a directory.
+ */
+export async function canonicalizeWorkspace(workspaceRoot: string): Promise<string> {
+  let canonical: string
+  try {
+    canonical = await realpath(workspaceRoot)
+  } catch (error) {
+    throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
+  }
+  const info = await stat(canonical)
+  if (!info.isDirectory()) {
+    throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
+  }
+  return canonical
+}
+
+/**
+ * Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath`
+ * resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target
+ * must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical
+ * workspace.
+ * @param filePath - the model-supplied source path (relative or absolute).
+ * @param canonicalWorkspace - the already-canonicalized workspace root.
+ * @param maxDocumentBytes - the largest source this host will open.
+ * @returns the canonical path and current UTF-8 text.
+ * @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
+ */
+export async function readHostSource(
+  filePath: string,
+  canonicalWorkspace: string,
+  maxDocumentBytes: number,
+): Promise<HostSource> {
+  const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
+  let canonicalPath: string
+  try {
+    canonicalPath = await realpath(requested)
+  } catch (error) {
+    throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
+  }
+  if (!isInside(canonicalWorkspace, canonicalPath)) {
+    throw new Error(`source "${filePath}" resolves outside the workspace`)
+  }
+  const info = await stat(canonicalPath)
+  if (!info.isFile()) {
+    throw new Error(`source "${filePath}" is not a regular file`)
+  }
+  if (info.size > maxDocumentBytes) {
+    throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
+  }
+  const buffer = await readFile(canonicalPath)
+  const text = decodeUtf8Strict(buffer, filePath)
+  return { canonicalPath, text }
+}
+
+/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
+function isInside(workspace: string, child: string): boolean {
+  if (child === workspace) return true
+  /* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */
+  const base = workspace.endsWith(sep) ? workspace : workspace + sep
+  return child.startsWith(base)
+}
+
+/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */
+function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
+  const text = buffer.toString('utf8')
+  if (text.includes('�')) {
+    throw new Error(`source "${filePath}" is not valid UTF-8 text`)
+  }
+  return text
+}
+
+/** Extract a message from an unknown thrown value without leaking `any`. */
+function messageOf(error: unknown): string {
+  /* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */
+  return error instanceof Error ? error.message : String(error)
+}

+ 255 - 0
packages/lsp/lsp-local/src/index.ts

@@ -0,0 +1,255 @@
+/**
+ * Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server
+ * command and its extension→language-id map; load multiple instances for multiple servers. The
+ * provider lazily single-flights one server process per `(provider id, canonical workspace
+ * realpath)`, serves transient-open queries through it, and evicts a crashed process so a later
+ * query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and
+ * trusts its configured server — no sandbox confinement.
+ *
+ * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
+ * unregisters from `ctx.lsp` and tears down every live server.
+ * @module @deepseek-ai/dsh-lsp-local
+ */
+
+import { accessSync, constants } from 'node:fs'
+import { delimiter, isAbsolute, join } from 'node:path'
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { LspProviderId } from '@deepseek-ai/dsh-lsp'
+import type {
+  LspProvider,
+  LspProviderQuery,
+  LspQueryResult,
+} from '@deepseek-ai/dsh-lsp'
+// Side-effect type import: declaration-merges `ctx.lsp` onto Context.
+import type {} from '@deepseek-ai/dsh-lsp'
+import { canonicalizeWorkspace } from './host.ts'
+import { LspInstance } from './instance.ts'
+import type { InstanceSpec } from './instance.ts'
+
+export { canonicalizeWorkspace, readHostSource } from './host.ts'
+export { encodeMessage, MessageDecoder } from './framing.ts'
+export {
+  negotiatePositionEncoding,
+  normalizeHover,
+  normalizeLocations,
+  requestMethod,
+  supportsOperation,
+  supportsTransientOpen,
+} from './translate.ts'
+export { LspInstance } from './instance.ts'
+export { LspConnection } from './connection.ts'
+
+/** Cordis plugin name for loader diagnostics. */
+export const name = 'lsp-local'
+
+/** Services required by this plugin. */
+export const inject = ['lsp']
+
+/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
+const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
+
+const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
+const DEFAULT_MAX_STDERR_BYTES = 1_000_000
+const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
+const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
+const DEFAULT_KILL_GRACE_MS = 2_000
+
+/** Plugin configuration: one server command plus its extension mapping and host bounds. */
+export interface Config {
+  /** Stable provider id, reserved on `ctx.lsp` with the extensions. */
+  providerId: string
+  /** Executable to spawn (absolute, or resolved on PATH at load). */
+  command: string
+  /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
+  extensionToLanguage: Record<string, string>
+  /** Arguments passed to the executable (no shell). Default `[]`. */
+  args?: string[]
+  /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */
+  env?: Record<string, string>
+  /** Static `initialize` options forwarded to the server. Default `null`. */
+  initializationOptions?: unknown
+  /** Static answer to every `workspace/configuration` item. Default `null`. */
+  configuration?: unknown
+  /** Largest single framed message accepted from the server (bytes). Default 16000000. */
+  maxMessageBytes?: number
+  /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */
+  maxStderrBytes?: number
+  /** Largest source file this host will open (bytes). Default 4000000. */
+  maxDocumentBytes?: number
+  /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */
+  shutdownTimeoutMs?: number
+  /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */
+  killGraceMs?: number
+}
+
+/** The resolved config after schemastery fills every default; the provider reads this shape. */
+type ResolvedConfig = Required<Config>
+
+export const Config: z<Config> = z.object({
+  providerId: z.string().required(),
+  command: z.string().required(),
+  args: z.array(String).default([]),
+  env: z.dict(String).default({}),
+  extensionToLanguage: z.dict(String).required(),
+  initializationOptions: z.any().default(null),
+  configuration: z.any().default(null),
+  maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
+  maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
+  maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
+  shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
+  killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS),
+})
+
+/**
+ * Register a generic stdio LSP provider. Resolves the executable at load (after credential
+ * scrubbing) and fails before registration when it is unavailable; the process itself launches
+ * lazily on the first matching query.
+ * @param ctx - the plugin context (must inject `lsp`).
+ * @param config - the resolved plugin configuration (schemastery has filled every default).
+ */
+export function apply(ctx: Context, config: Config): void {
+  const resolved = config as ResolvedConfig
+  const childEnv = buildChildEnv(resolved.env)
+  // Resolve the executable eagerly so a misconfigured command fails at load, not on first query.
+  const executable = resolveExecutable(resolved.command, childEnv)
+
+  const provider = new LocalLspProvider(resolved, childEnv, executable)
+  ctx.effect(() => {
+    const dispose = ctx.lsp.registerProvider(provider)
+    return async () => {
+      dispose()
+      await provider.disposeAll()
+    }
+  }, 'lsp-local.registerProvider')
+}
+
+/** A pooled generic provider: one server process per canonical workspace, created on demand. */
+class LocalLspProvider implements LspProvider {
+  readonly id: LspProviderId
+  readonly extensionToLanguage: Readonly<Record<string, string>>
+  /** Single-flight map: canonical workspace realpath → the (pending) instance for it. */
+  private readonly instances = new Map<string, Promise<LspInstance>>()
+  private disposed = false
+
+  constructor(
+    private readonly config: ResolvedConfig,
+    private readonly childEnv: Record<string, string>,
+    private readonly executable: string,
+  ) {
+    this.id = LspProviderId(config.providerId)
+    this.extensionToLanguage = config.extensionToLanguage
+  }
+
+  async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
+    /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */
+    if (this.disposed) throw new Error('lsp-local provider is disposed')
+    const workspace = await canonicalizeWorkspace(request.workspaceRoot)
+    const instance = await this.instanceFor(workspace)
+    try {
+      return await instance.query(request, signal)
+    } finally {
+      // A crashed/closed process must not be reused: drop its slot so the next query starts fresh,
+      // but only if the slot still holds THIS instance (a concurrent replacement must survive).
+      if (instance.dead) {
+        const slot = this.instances.get(workspace)
+        /* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */
+        if (slot !== undefined && (await settledInstance(slot)) === instance) {
+          this.instances.delete(workspace)
+        }
+      }
+    }
+  }
+
+  /** Single-flight one instance per canonical workspace; a rejected creation clears the slot. */
+  private instanceFor(workspace: string): Promise<LspInstance> {
+    const existing = this.instances.get(workspace)
+    if (existing !== undefined) return existing
+    const created = Promise.resolve().then(() => this.createInstance(workspace))
+    this.instances.set(workspace, created)
+    /* v8 ignore next 3 -- createInstance (the LspInstance constructor) does not throw; spawn failures
+       surface asynchronously through the instance, so this creation-rejection cleanup is defensive. */
+    created.catch(() => {
+      if (this.instances.get(workspace) === created) this.instances.delete(workspace)
+    })
+    return created
+  }
+
+  private createInstance(workspace: string): LspInstance {
+    const spec: InstanceSpec = {
+      command: this.executable,
+      args: this.config.args,
+      cwd: workspace,
+      env: this.childEnv,
+      configuration: this.config.configuration,
+      initializationOptions: this.config.initializationOptions,
+      maxMessageBytes: this.config.maxMessageBytes,
+      maxStderrBytes: this.config.maxStderrBytes,
+      maxDocumentBytes: this.config.maxDocumentBytes,
+      shutdownTimeoutMs: this.config.shutdownTimeoutMs,
+      killGraceMs: this.config.killGraceMs,
+    }
+    return new LspInstance(spec)
+  }
+
+  /** Dispose every live instance and block further queries. */
+  async disposeAll(): Promise<void> {
+    this.disposed = true
+    const pending = [...this.instances.values()]
+    this.instances.clear()
+    await Promise.all(pending.map(async (entry) => {
+      try {
+        const instance = await entry
+        await instance.dispose()
+      } catch {
+        // A never-initialized instance already rejected; nothing to tear down.
+      }
+    }))
+  }
+}
+
+/** Resolve a slot promise to its instance for identity comparison, tolerating a pending rejection. */
+async function settledInstance(slot: Promise<LspInstance>): Promise<LspInstance | undefined> {
+  try {
+    return await slot
+  } catch {
+    /* v8 ignore next -- a slot promise only rejects if createInstance throws, which it never does; defensive. */
+    return undefined
+  }
+}
+
+/** The ambient env minus credential-shaped vars, plus the config's explicit env. */
+function buildChildEnv(extra: Record<string, string>): Record<string, string> {
+  const scrubbed = Object.entries(process.env).filter(
+    ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key),
+  ) as [string, string][]
+  return { ...Object.fromEntries(scrubbed), ...extra }
+}
+
+/**
+ * Resolve the server executable to an absolute path: an absolute command is verified directly; a
+ * bare command is looked up on the child's PATH. Fails loudly when nothing is executable.
+ */
+function resolveExecutable(command: string, childEnv: Record<string, string>): string {
+  if (isAbsolute(command)) {
+    return command
+  }
+  /* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
+  const pathValue = childEnv.PATH ?? process.env.PATH ?? ''
+  for (const dir of pathValue.split(delimiter)) {
+    if (dir === '') continue
+    const candidate = join(dir, command)
+    if (isExecutableSync(candidate)) return candidate
+  }
+  throw new Error(`lsp-local: command "${command}" was not found on PATH`)
+}
+
+/** Synchronous executable check used only at load-time resolution. */
+function isExecutableSync(path: string): boolean {
+  try {
+    accessSync(path, constants.X_OK)
+    return true
+  } catch {
+    return false
+  }
+}

+ 293 - 0
packages/lsp/lsp-local/src/instance.ts

@@ -0,0 +1,293 @@
+/**
+ * One language-server instance: a connection plus the initialize handshake, the serialized abortable
+ * query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
+ * instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
+ * queue so a cancellation that fails to stop the server can terminate it without killing unrelated
+ * work; distinct instances run in parallel.
+ * @module @deepseek-ai/dsh-lsp-local/instance
+ */
+
+import { pathToFileURL } from 'node:url'
+import type {
+  LspOperation,
+  LspProviderQuery,
+  LspQueryResult,
+} from '@deepseek-ai/dsh-lsp'
+import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
+import { LspConnection } from './connection.ts'
+import type { ConnectionSpec } from './connection.ts'
+import { readHostSource } from './host.ts'
+import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
+import {
+  negotiatePositionEncoding,
+  normalizeHover,
+  normalizeLocations,
+  requestMethod,
+  supportsOperation,
+  supportsTransientOpen,
+} from './translate.ts'
+
+/** Everything an instance needs beyond the connection spec. */
+export interface InstanceSpec extends ConnectionSpec {
+  /** Static `initialize` options forwarded to the server. */
+  readonly initializationOptions: unknown
+  /** Largest source file this host will open (bytes). */
+  readonly maxDocumentBytes: number
+  /** Graceful `shutdown`/`exit` budget before escalation (ms). */
+  readonly shutdownTimeoutMs: number
+  /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
+  readonly killGraceMs: number
+}
+
+/**
+ * A single initialized server process. Not exported as a provider — the provider single-flights and
+ * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
+ */
+export class LspInstance {
+  private readonly connection: LspConnection
+  private capabilities: WireServerCapabilities | undefined
+  /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
+  private queue: Promise<unknown> = Promise.resolve()
+  private disposed = false
+  /** Set once the process closes, so the pool can synchronously skip a dead instance. */
+  private processClosed = false
+  /** Populated once `initialize` succeeds; a failed handshake rejects every query. */
+  private readonly ready: Promise<void>
+
+  /**
+   * @param spec - the launch, initialize, and teardown parameters.
+   */
+  constructor(private readonly spec: InstanceSpec) {
+    this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
+    this.ready = this.initialize()
+    // A handshake rejection must not surface as an unhandled rejection before the first query awaits
+    // it; queries attach the real handler.
+    this.ready.catch(() => {})
+    void this.connection.closed.then(() => { this.processClosed = true })
+  }
+
+  /** Synchronous liveness check: true once the process has closed or the instance was disposed. */
+  get dead(): boolean {
+    return this.processClosed || this.disposed
+  }
+
+  /**
+   * Run one query through the serialized queue.
+   * @param request - the resolved provider query.
+   * @param signal - optional cancellation for this query's full lifecycle.
+   * @returns the normalized result.
+   */
+  query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
+    const run = this.queue.then(() => this.runQuery(request, signal))
+    // Keep the tail alive regardless of this query's outcome so the next caller still serializes.
+    this.queue = run.then(() => undefined, () => undefined)
+    return run
+  }
+
+  private async initialize(): Promise<void> {
+    const initializeResult = await this.connection.request('initialize', {
+      processId: process.pid,
+      rootUri: pathToFileURL(this.spec.cwd).href,
+      workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
+      capabilities: CLIENT_CAPABILITIES,
+      initializationOptions: this.spec.initializationOptions,
+    }) as WireInitializeResult
+    const capabilities = initializeResult.capabilities
+    // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
+    negotiatePositionEncoding(capabilities.positionEncoding)
+    this.capabilities = capabilities
+    this.connection.notify('initialized', {})
+  }
+
+  private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
+    if (this.disposed) throw new Error('LSP instance was disposed')
+    if (signal?.aborted) throw abortError(signal)
+    await this.ready
+    const capabilities = this.capabilities
+    /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
+    if (capabilities === undefined) throw new Error('LSP instance is not initialized')
+    if (!supportsOperation(capabilities, request.operation)) {
+      throw new Error(`server does not support ${request.operation}`)
+    }
+    if (!supportsTransientOpen(capabilities.textDocumentSync)) {
+      throw new Error('server does not support the transient textDocument/didOpen this host requires')
+    }
+
+    const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes)
+    const uri = pathToFileURL(source.canonicalPath).href
+    let opened = false
+    try {
+      if (signal?.aborted) throw abortError(signal)
+      this.connection.notify('textDocument/didOpen', {
+        textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
+      })
+      opened = true
+      const payload = await this.sendRequest(request.operation, uri, request.position, signal)
+      return this.normalize(request.operation, payload)
+    } finally {
+      if (opened) {
+        try {
+          this.connection.notify('textDocument/didClose', { textDocument: { uri } })
+        } catch (error) {
+          /* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error'
+             listener, so a synchronous didClose write failure is a defensive path. */
+          // A close-write failure does not replace the settled result/error, but the instance can no
+          // longer be trusted: invalidate it and await bounded process termination.
+          this.disposed = true
+          void this.tearDown(error instanceof Error ? error : new Error(String(error)))
+          /* v8 ignore stop */
+        }
+      }
+    }
+  }
+
+  private async sendRequest(
+    operation: LspOperation,
+    uri: string,
+    position: LspProviderQuery['position'],
+    signal?: AbortSignal,
+  ): Promise<unknown> {
+    const params = {
+      textDocument: { uri },
+      position: { line: position.line, character: position.character },
+      // references always includes declarations: the caller gets no flag and impact analysis never
+      // omits the defining site.
+      ...(operation === 'references' ? { context: { includeDeclaration: true } } : {}),
+    }
+    const requestId = this.connection.peekNextId()
+    const send = this.connection.request(requestMethod(operation), params)
+    if (signal === undefined) return send
+    return this.raceAbort(send, requestId, signal)
+  }
+
+  /** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */
+  private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
+    const abort = new Promise<never>((_, reject) => {
+      const onAbort = (): void => { reject(abortError(signal)) }
+      /* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */
+      if (signal.aborted) { onAbort(); return }
+      signal.addEventListener('abort', onAbort, { once: true })
+      // Remove the abort listener once the request settles either way; the finally-promise inherits
+      // send's rejection, so catch it to avoid an unhandled rejection when abort already won.
+      send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {})
+    })
+    try {
+      return await Promise.race([send, abort])
+    } catch (error) {
+      if (signal.aborted) this.connection.cancel(requestId)
+      throw error
+    }
+  }
+
+  private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
+    if (operation === 'hover') {
+      return { kind: 'hover', hover: normalizeHover(payload) }
+    }
+    return { kind: 'locations', locations: normalizeLocations(payload) }
+  }
+
+  private answerServerRequest(method: string, params: unknown): Promise<unknown> {
+    if (method === 'workspace/configuration') {
+      // Answer every requested item with the one static configuration value.
+      const record = params as { items?: unknown[] } | null
+      /* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
+      const items = Array.isArray(record?.items) ? record.items : []
+      return Promise.resolve(items.map(() => this.spec.configuration))
+    }
+    if (LIFECYCLE_NOOP_METHODS.has(method)) {
+      // Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
+      return Promise.resolve(null)
+    }
+    if (method === 'workspace/applyEdit') {
+      // This host never applies edits or runs commands.
+      return Promise.reject(new Error('workspace/applyEdit is not permitted by this host'))
+    }
+    return Promise.reject(new Error(`unsupported server request: ${method}`))
+  }
+
+  /**
+   * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting
+   * process close so nothing outlives disposal.
+   */
+  async dispose(): Promise<void> {
+    if (this.disposed) {
+      await this.connection.closed
+      return
+    }
+    this.disposed = true
+    await this.tearDown(new Error('LSP instance disposed'))
+  }
+
+  private async tearDown(_reason: Error): Promise<void> {
+    try {
+      using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
+      await this.gracefulShutdown(shutdownDeadline.signal)
+    } catch {
+      // Graceful shutdown failed or timed out: fall through to signal escalation.
+    }
+    await this.forceTerminate()
+  }
+
+  /** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */
+  private async gracefulShutdown(signal: AbortSignal): Promise<void> {
+    const shutdown = this.connection.request('shutdown', null)
+    await Promise.race([
+      shutdown,
+      new Promise<never>((_, reject) => {
+        /* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */
+        if (signal.aborted) { reject(abortError(signal)); return }
+        signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true })
+      }),
+    ])
+    this.connection.notify('exit', null)
+  }
+
+  /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */
+  private async forceTerminate(): Promise<void> {
+    this.connection.terminate()
+    using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
+    const closedInTime = await Promise.race([
+      this.connection.closed.then(() => true),
+      new Promise<boolean>((resolve) => {
+        /* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */
+        if (graceDeadline.signal.aborted) { resolve(false); return }
+        graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
+      }),
+    ])
+    if (!closedInTime) this.connection.kill()
+    await this.connection.closed
+  }
+}
+
+/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
+const LIFECYCLE_NOOP_METHODS = new Set([
+  'window/workDoneProgress/create',
+  'client/registerCapability',
+  'client/unregisterCapability',
+])
+
+/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */
+function abortError(signal: AbortSignal): Error {
+  const timeout = timeoutOf(signal)
+  if (timeout !== undefined) return timeout
+  const reason: unknown = signal.reason
+  if (reason instanceof Error) return reason
+  return new Error('LSP query aborted')
+}
+
+/**
+ * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
+ * configuration, markdown/plaintext hover, and link support for definition/implementation. No
+ * dynamic registration; the server's returned capabilities are authoritative.
+ */
+const CLIENT_CAPABILITIES = {
+  general: { positionEncodings: ['utf-16'] },
+  workspace: { workspaceFolders: true, configuration: true },
+  textDocument: {
+    synchronization: { dynamicRegistration: false },
+    hover: { contentFormat: ['markdown', 'plaintext'] },
+    definition: { linkSupport: true },
+    implementation: { linkSupport: true },
+    references: {},
+  },
+} as const

+ 80 - 0
packages/lsp/lsp-local/src/protocol.ts

@@ -0,0 +1,80 @@
+/**
+ * The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four
+ * request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to
+ * decide transient-open support. Types only. Fields absent from a real server payload stay optional;
+ * the translation layer normalizes them into the seam's closed unions.
+ * @module @deepseek-ai/dsh-lsp-local/protocol
+ */
+
+/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */
+export interface WirePosition {
+  readonly line: number
+  readonly character: number
+}
+
+/** A wire range (`Range`). */
+export interface WireRange {
+  readonly start: WirePosition
+  readonly end: WirePosition
+}
+
+/** A `Location`: a document URI plus a range. */
+export interface WireLocation {
+  readonly uri: string
+  readonly range: WireRange
+}
+
+/** A `LocationLink`: the target uri plus the selection range to focus. */
+export interface WireLocationLink {
+  readonly targetUri: string
+  readonly targetSelectionRange: WireRange
+  readonly targetRange?: WireRange
+}
+
+/** A `MarkupContent` hover body (`markdown` or `plaintext`). */
+export interface WireMarkupContent {
+  readonly kind: 'markdown' | 'plaintext'
+  readonly value: string
+}
+
+/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */
+export interface WireMarkedStringObject {
+  readonly language: string
+  readonly value: string
+}
+
+/** One `MarkedString`: a raw string or a language-tagged code block. */
+export type WireMarkedString = string | WireMarkedStringObject
+
+/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */
+export interface WireHover {
+  readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[]
+  readonly range?: WireRange
+}
+
+/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */
+export type WireTextDocumentSyncKind = 0 | 1 | 2
+
+/** The options form of `textDocumentSync` (`{ openClose, change }`). */
+export interface WireTextDocumentSyncOptions {
+  readonly openClose?: boolean
+  readonly change?: WireTextDocumentSyncKind
+}
+
+/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */
+export type WireProviderCapability = boolean | Record<string, unknown> | undefined
+
+/** The `ServerCapabilities` fields this host inspects. */
+export interface WireServerCapabilities {
+  readonly positionEncoding?: string
+  readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions
+  readonly definitionProvider?: WireProviderCapability
+  readonly referencesProvider?: WireProviderCapability
+  readonly implementationProvider?: WireProviderCapability
+  readonly hoverProvider?: WireProviderCapability
+}
+
+/** The `initialize` result envelope. */
+export interface WireInitializeResult {
+  readonly capabilities: WireServerCapabilities
+}

+ 210 - 0
packages/lsp/lsp-local/src/translate.ts

@@ -0,0 +1,210 @@
+/**
+ * Pure protocol translation for the local host: what the server's capabilities allow, and how its
+ * `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O
+ * or process state — every function here is a pure transform, which the fake-stdio tests pin exactly.
+ * @module @deepseek-ai/dsh-lsp-local/translate
+ */
+
+import type {
+  LspHover,
+  LspLocation,
+  LspOperation,
+  LspRange,
+} from '@deepseek-ai/dsh-lsp'
+import { assertNever } from '@deepseek-ai/dsh-llm'
+import type {
+  WireHover,
+  WireLocation,
+  WireLocationLink,
+  WireMarkedString,
+  WireProviderCapability,
+  WireRange,
+  WireServerCapabilities,
+  WireTextDocumentSyncKind,
+  WireTextDocumentSyncOptions,
+} from './protocol.ts'
+
+/**
+ * The `textDocument/*` request method for each seam operation.
+ * @param operation - the seam operation to map.
+ * @returns the LSP request method name.
+ */
+export function requestMethod(operation: LspOperation): string {
+  switch (operation) {
+    case 'definition': return 'textDocument/definition'
+    case 'references': return 'textDocument/references'
+    case 'implementation': return 'textDocument/implementation'
+    case 'hover': return 'textDocument/hover'
+    /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
+    default: return assertNever(operation, 'requestMethod')
+  }
+}
+
+/** The `ServerCapabilities` provider field backing each operation. */
+function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability {
+  switch (operation) {
+    case 'definition': return capabilities.definitionProvider
+    case 'references': return capabilities.referencesProvider
+    case 'implementation': return capabilities.implementationProvider
+    case 'hover': return capabilities.hoverProvider
+    /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
+    default: return assertNever(operation, 'capabilityValue')
+  }
+}
+
+/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */
+function supportsCapability(value: WireProviderCapability): boolean {
+  if (value === undefined) return false
+  if (typeof value === 'boolean') return value
+  return true
+}
+
+/**
+ * Whether the server advertises the requested operation.
+ * @param capabilities - the server's `initialize` capabilities.
+ * @param operation - the seam operation to check.
+ * @returns true when the corresponding provider capability is present.
+ */
+export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean {
+  return supportsCapability(capabilityValue(capabilities, operation))
+}
+
+/**
+ * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on.
+ * @param sync - the server's advertised `textDocumentSync` capability.
+ * @returns true when transient open/close is supported.
+ */
+export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean {
+  if (sync === undefined) return false
+  if (typeof sync === 'number') return isOpenCloseKind(sync)
+  return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync))
+}
+
+/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */
+function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean {
+  return kind === 1 || kind === 2
+}
+
+/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */
+function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean {
+  return sync.change !== undefined && isOpenCloseKind(sync.change)
+}
+
+/**
+ * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value
+ * other than `utf-16` is a protocol error this host does not support.
+ * @param encoding - the server's advertised `positionEncoding`, if any.
+ * @returns the string `'utf-16'`.
+ * @throws Error for any non-`utf-16` encoding.
+ */
+export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' {
+  if (encoding === undefined || encoding === 'utf-16') return 'utf-16'
+  throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`)
+}
+
+/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */
+function toRange(range: WireRange): LspRange {
+  return {
+    start: { line: range.start.line, character: range.start.character },
+    end: { line: range.end.line, character: range.end.character },
+  }
+}
+
+/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */
+function isLocationLink(value: Record<string, unknown>): boolean {
+  return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange)
+}
+
+/** Whether a record is a `Location` (has string `uri` + a range). */
+function isLocation(value: Record<string, unknown>): boolean {
+  return typeof value.uri === 'string' && isRange(value.range)
+}
+
+/** Structural range guard used by both location shapes. */
+function isRange(value: unknown): value is WireRange {
+  if (value === null || typeof value !== 'object') return false
+  const range = value as Record<string, unknown>
+  return isPosition(range.start) && isPosition(range.end)
+}
+
+/** Structural position guard. */
+function isPosition(value: unknown): boolean {
+  if (value === null || typeof value !== 'object') return false
+  const position = value as Record<string, unknown>
+  return typeof position.line === 'number' && typeof position.character === 'number'
+}
+
+/**
+ * Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's
+ * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`.
+ * @param payload - the raw `textDocument/definition|references|implementation` result.
+ * @returns the normalized locations (empty for `null`/`[]`).
+ * @throws Error when an element is neither a `Location` nor a `LocationLink`.
+ */
+export function normalizeLocations(payload: unknown): LspLocation[] {
+  if (payload === null || payload === undefined) return []
+  const elements = Array.isArray(payload) ? payload : [payload]
+  const locations: LspLocation[] = []
+  for (const element of elements) {
+    if (element === null || typeof element !== 'object') {
+      throw new Error('LSP navigation result contained a non-object entry')
+    }
+    const record = element as Record<string, unknown>
+    if (isLocationLink(record)) {
+      const link = record as unknown as WireLocationLink
+      locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) })
+    } else if (isLocation(record)) {
+      const location = record as unknown as WireLocation
+      locations.push({ uri: location.uri, range: toRange(location.range) })
+    } else {
+      throw new Error('LSP navigation result contained neither a Location nor a LocationLink')
+    }
+  }
+  return locations
+}
+
+/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */
+function renderMarkedString(value: WireMarkedString): string {
+  if (typeof value === 'string') return value
+  return `\`\`\`${value.language}\n${value.value}\n\`\`\``
+}
+
+/**
+ * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string
+ * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array
+ * joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps.
+ * @param payload - the raw `textDocument/hover` result.
+ * @returns the normalized hover, or `null` when there is no content.
+ * @throws Error when the payload is a non-null, non-object, or structurally invalid hover.
+ */
+export function normalizeHover(payload: unknown): LspHover | null {
+  if (payload === null || payload === undefined) return null
+  if (typeof payload !== 'object') throw new Error('LSP hover result was not an object')
+  const hover = payload as unknown as WireHover
+  const contents = renderHoverContents(hover.contents)
+  if (contents === '') return null
+  const range = hover.range
+  return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents }
+}
+
+/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */
+function renderHoverContents(contents: unknown): string {
+  if (contents === null || contents === undefined) {
+    throw new Error('LSP hover result had no contents')
+  }
+  if (typeof contents === 'string') return contents
+  if (Array.isArray(contents)) {
+    return contents.map(renderMarkedString).join('\n\n')
+  }
+  if (typeof contents !== 'object') {
+    throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array')
+  }
+  const record = contents as Record<string, unknown>
+  if (record.kind === 'markdown' || record.kind === 'plaintext') {
+    return typeof record.value === 'string' ? record.value : ''
+  }
+  if (typeof record.language === 'string' && typeof record.value === 'string') {
+    return renderMarkedString({ language: record.language, value: record.value })
+  }
+  throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array')
+}

+ 73 - 0
packages/lsp/lsp-local/tests/built-lib.e2e.ts

@@ -0,0 +1,73 @@
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+
+/**
+ * Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and
+ * `@deepseek-ai/dsh-lsp-local` by name through their exports maps, spawns the fixture server, runs
+ * one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising
+ * subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/`
+ * is absent; CI runs it after the build.
+ */
+
+const pkgDir = fileURLToPath(new URL('..', import.meta.url))
+const seamLib = join(pkgDir, '../lsp/lib/index.js')
+const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
+
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+let root: string
+let ws: string
+
+beforeAll(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-')))
+  ws = join(root, 'ws')
+  await mkdir(ws)
+  await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
+})
+
+afterAll(async () => {
+  if (root) await rm(root, { recursive: true, force: true })
+})
+
+describe.skipIf(!built)('built lib real load path (plain node)', () => {
+  it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => {
+    const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
+    const script = `
+      const { Context } = await import('cordis')
+      const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
+      const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
+      const ctx = new Context()
+      await ctx.plugin(Lsp)
+      await ctx.plugin(LspLocal, {
+        providerId: 'fake',
+        command: ${JSON.stringify(process.execPath)},
+        args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}],
+        env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} },
+        extensionToLanguage: { '.ts': 'typescript' },
+      })
+      const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
+      console.log(JSON.stringify(result))
+      await ctx.fiber.dispose()
+      process.exit(0)
+    `
+    const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
+    let stdout = ''
+    let stderr = ''
+    child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
+    child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
+    const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
+
+    expect(exitCode, `stderr:\n${stderr}`).toBe(0)
+    const lastLine = stdout.trim().split('\n').at(-1) ?? ''
+    const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] }
+    expect(result.kind).toBe('locations')
+    expect(result.locations).toHaveLength(1)
+  }, 60_000)
+})

+ 226 - 0
packages/lsp/lsp-local/tests/connection.spec.ts

@@ -0,0 +1,226 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { fileURLToPath } from 'node:url'
+import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
+
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+/** A recorded server→client request the test's handler saw. */
+interface SeenRequest { method: string; params: unknown }
+
+let open: LspConnection[] = []
+
+afterEach(async () => {
+  for (const conn of open) {
+    conn.kill()
+    await conn.closed
+  }
+  open = []
+})
+
+/** Spawn the fixture as a raw connection, with a scripted server-request handler. */
+function connect(
+  env: Record<string, string>,
+  onServerRequest: (method: string, params: unknown) => Promise<unknown> = () => Promise.resolve(null),
+  seen?: SeenRequest[],
+): LspConnection {
+  const conn = new LspConnection({
+    command: process.execPath,
+    args: ['--import', tsxLoader, fixtureServer],
+    cwd: process.cwd(),
+    env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
+    maxMessageBytes: 16_000_000,
+    maxStderrBytes: 100_000,
+    configuration: { setting: 42 },
+  }, (method, params) => {
+    seen?.push({ method, params })
+    return onServerRequest(method, params)
+  })
+  open.push(conn)
+  return conn
+}
+
+describe('LspConnection', () => {
+  it('completes an initialize request/response round-trip and exposes a pid', async () => {
+    const conn = connect({})
+    const result = await conn.request('initialize', { capabilities: {} })
+    expect(result).toMatchObject({ capabilities: { hoverProvider: true } })
+    expect(conn.pid).toBeGreaterThan(0)
+  })
+
+  it('rejects a request when the server replies with an error', async () => {
+    const conn = connect({ LSP_FAKE_ERROR: '1' })
+    await conn.request('initialize', { capabilities: {} })
+    await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
+  })
+
+  it('answers a server workspace/configuration request from static config', async () => {
+    const seen: SeenRequest[] = []
+    const conn = connect(
+      { LSP_FAKE_ON_OPEN: 'configuration' },
+      (method, params) => {
+        if (method === 'workspace/configuration') {
+          const items = (params as { items: unknown[] }).items
+          return Promise.resolve(items.map(() => ({ setting: 42 })))
+        }
+        return Promise.resolve(null)
+      },
+      seen,
+    )
+    await conn.request('initialize', { capabilities: {} })
+    conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
+    await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
+    expect(seen[0]?.method).toBe('workspace/configuration')
+  })
+
+  it('drops a server→client notification without replying', async () => {
+    const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
+    await conn.request('initialize', { capabilities: {} })
+    conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
+    // No throw and the connection stays usable.
+    await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
+  })
+
+  it('sends an error response when the server-request handler rejects', async () => {
+    const seen: SeenRequest[] = []
+    const conn = connect(
+      { LSP_FAKE_ON_OPEN: 'applyEdit' },
+      method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null),
+      seen,
+    )
+    await conn.request('initialize', { capabilities: {} })
+    conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
+    await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
+    // The connection remains healthy after emitting the error response.
+    await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
+  })
+
+  it('fails all pending requests and kills the process on a framing error', async () => {
+    const conn = connect({ LSP_FAKE_GARBAGE: '1' })
+    // The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a
+    // Content-Length header, so initialize still resolves. This exercises the decoder's resilience.
+    await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined()
+  })
+
+  it('rejects a new request issued after the process closes', async () => {
+    const conn = connect({})
+    await conn.request('initialize', { capabilities: {} })
+    conn.terminate()
+    await conn.closed
+    await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/)
+  })
+
+  it('cancel is a no-op-safe write after close', async () => {
+    const conn = connect({})
+    await conn.request('initialize', { capabilities: {} })
+    conn.terminate()
+    await conn.closed
+    expect(() => { conn.cancel(1) }).not.toThrow()
+  })
+
+  it('caps the retained stderr tail', async () => {
+    const conn = connect({})
+    await conn.request('initialize', { capabilities: {} })
+    expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000)
+  })
+})
+
+/** Spawn a raw connection running an inline node script as the "server". */
+function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
+  const conn = new LspConnection({
+    command: process.execPath,
+    args: ['-e', script],
+    cwd: process.cwd(),
+    env: { ...process.env as Record<string, string> },
+    maxMessageBytes: 16_000_000,
+    maxStderrBytes,
+    configuration: null,
+  }, () => Promise.resolve(null))
+  open.push(conn)
+  return conn
+}
+
+describe('LspConnection edge behavior', () => {
+  it('fails a request when the command cannot be spawned', async () => {
+    const conn = new LspConnection({
+      command: '/definitely/not/a/real/binary/xyz',
+      args: [],
+      cwd: process.cwd(),
+      env: {},
+      maxMessageBytes: 1000,
+      maxStderrBytes: 1000,
+      configuration: null,
+    }, () => Promise.resolve(null))
+    open.push(conn)
+    await expect(conn.request('initialize', {})).rejects.toThrow()
+  })
+
+  it('kills the process and fails pending requests on a framing error', async () => {
+    // Emit an invalid Content-Length header, corrupting the stream irrecoverably.
+    const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)')
+    await expect(conn.request('initialize', {})).rejects.toThrow()
+  })
+
+  it('ignores a framed non-object message', async () => {
+    // Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1.
+    const script = 'let b=Buffer.alloc(0);'
+      + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+      + 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));'
+      + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
+    const conn = connectScript(script)
+    await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
+  })
+
+  it('drops a response for an unknown id', async () => {
+    // Emit a response for id 999 (never sent), then answer our real request.
+    const script = 'let b=Buffer.alloc(0);'
+      + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+      + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));'
+      + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
+    const conn = connectScript(script)
+    await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
+  })
+
+  it('caps the retained stderr tail at maxStderrBytes across chunks', async () => {
+    // Write stderr repeatedly so a later chunk arrives after the cap is already reached.
+    const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100)
+    await waitFor(() => conn.stderrTail.length >= 100)
+    await new Promise<void>(resolve => setTimeout(resolve, 50))
+    expect(conn.stderrTail.length).toBe(100)
+  })
+
+  it('rejects with a fallback message when the error response has no message string', async () => {
+    const script = 'let b=Buffer.alloc(0);'
+      + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+      + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});'
+    const conn = connectScript(script)
+    await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/)
+  })
+
+  it('rejects a pending request when the process exits mid-flight', async () => {
+    // Never responds, then exits shortly: the pending request must reject on close.
+    const conn = connectScript('setTimeout(()=>process.exit(0), 100)')
+    await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
+  })
+
+  it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
+    // A frame with a string id and no method: not dispatchable; the client must ignore it and still
+    // answer our real request.
+    const script = 'let b=Buffer.alloc(0);'
+      + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+      + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));'
+      + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
+    const conn = connectScript(script)
+    await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
+  })
+})
+
+/** Poll a predicate until it holds or a deadline elapses. */
+async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
+  const start = Date.now()
+  while (!predicate()) {
+    if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
+    await new Promise<void>(resolve => setTimeout(resolve, 10))
+  }
+}

+ 144 - 0
packages/lsp/lsp-local/tests/fixture-server.ts

@@ -0,0 +1,144 @@
+/**
+ * A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real
+ * `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake,
+ * transient open/close, request mapping, and teardown — without a real language server.
+ *
+ * Behavior is driven by env vars so one file backs many scenarios:
+ * - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch).
+ * - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full).
+ * - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults.
+ * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
+ * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
+ * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
+ * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
+ * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
+ *   "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
+ * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
+ * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
+ *
+ * Run: node --import tsx fixture-server.ts
+ */
+
+const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
+const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
+const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
+const hang = process.env.LSP_FAKE_HANG === '1'
+const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
+const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
+const onOpen = process.env.LSP_FAKE_ON_OPEN
+const errorReply = process.env.LSP_FAKE_ERROR === '1'
+const garbage = process.env.LSP_FAKE_GARBAGE === '1'
+
+let serverRequestId = 10_000
+const pendingServerRequests = new Map<number, string>()
+
+function resultFor(method: string): unknown {
+  switch (method) {
+    case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
+    case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
+    case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
+    case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null)
+    default: return null
+  }
+}
+
+function envJson(name: string, fallback: unknown): unknown {
+  const raw = process.env[name]
+  return raw === undefined ? fallback : JSON.parse(raw)
+}
+
+let buffer = Buffer.alloc(0)
+process.stdin.on('data', (chunk: Buffer) => {
+  buffer = Buffer.concat([buffer, chunk])
+  for (;;) {
+    const sep = buffer.indexOf('\r\n\r\n')
+    if (sep < 0) break
+    const header = buffer.toString('ascii', 0, sep)
+    const match = /content-length:\s*(\d+)/i.exec(header)
+    if (!match) { buffer = buffer.subarray(sep + 4); continue }
+    const length = Number(match[1])
+    const start = sep + 4
+    if (buffer.length < start + length) break
+    const body = buffer.toString('utf8', start, start + length)
+    buffer = buffer.subarray(start + length)
+    handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
+  }
+})
+
+function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
+  const { id, method } = message
+  // A frame with an id but no method is the client's REPLY to a server→client request; log it.
+  if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
+    const kind = pendingServerRequests.get(id)
+    pendingServerRequests.delete(id)
+    process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
+    return
+  }
+  if (method === 'initialize') {
+    if (garbage) process.stdout.write('this is not a framed message\r\n')
+    send({
+      id,
+      result: {
+        capabilities: {
+          positionEncoding: enc,
+          textDocumentSync: sync,
+          definitionProvider: true,
+          referencesProvider: true,
+          implementationProvider: true,
+          hoverProvider: true,
+          ...(extraCaps as Record<string, unknown>),
+        },
+      },
+    })
+    return
+  }
+  if (method === 'shutdown') {
+    if (noShutdown) return
+    send({ id, result: null })
+    return
+  }
+  if (method === 'exit') {
+    process.exit(0)
+  }
+  if (method === 'textDocument/didOpen') {
+    if (crashOnOpen) process.exit(1)
+    if (onOpen !== undefined) emitServerRequest(onOpen)
+    return
+  }
+  if (method === 'textDocument/didClose' || method === 'initialized') return
+  if (method?.startsWith('textDocument/')) {
+    if (hang) return
+    if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
+    send({ id, result: resultFor(method) })
+    return
+  }
+  // Unknown request with an id: answer null so the client never stalls.
+  if (id !== undefined) send({ id, result: null })
+}
+
+/** Emit a server→client request and log the client's reply to stderr for the test to assert. */
+function emitServerRequest(kind: string): void {
+  if (kind === 'notification') {
+    send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
+    return
+  }
+  const id = serverRequestId++
+  const method = kind === 'configuration'
+    ? 'workspace/configuration'
+    : kind === 'applyEdit'
+      ? 'workspace/applyEdit'
+      : kind === 'lifecycle'
+        ? 'client/registerCapability'
+        : 'window/showMessageRequest'
+  const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
+  pendingServerRequests.set(id, method)
+  send({ id, method, params })
+}
+
+function send(message: Record<string, unknown>): void {
+  const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
+  process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
+}
+
+// Keep the event loop alive.
+process.stdin.resume()

+ 76 - 0
packages/lsp/lsp-local/tests/framing.spec.ts

@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-local'
+
+/** Frame a message the way a server would, for decoder round-trips. */
+function frame(body: string): Buffer {
+  return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')])
+}
+
+describe('encodeMessage', () => {
+  it('prefixes a Content-Length header with the utf-8 byte length', () => {
+    const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } })
+    const text = buffer.toString('utf8')
+    const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}'
+    expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
+  })
+})
+
+describe('MessageDecoder', () => {
+  it('decodes a single framed message', () => {
+    const decoder = new MessageDecoder(1_000)
+    expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }])
+  })
+
+  it('decodes multiple messages arriving in one chunk', () => {
+    const decoder = new MessageDecoder(1_000)
+    const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')])
+    expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }])
+  })
+
+  it('reassembles a message split across chunks', () => {
+    const decoder = new MessageDecoder(1_000)
+    const full = frame('{"hello":"world"}')
+    expect(decoder.push(full.subarray(0, 10))).toEqual([])
+    expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }])
+  })
+
+  it('handles a header split from its body', () => {
+    const decoder = new MessageDecoder(1_000)
+    const body = '{"x":1}'
+    expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([])
+    expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }])
+  })
+
+  it('reads a case-insensitive header and ignores other headers', () => {
+    const decoder = new MessageDecoder(1_000)
+    const body = '{"ok":true}'
+    const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8')
+    expect(decoder.push(chunk)).toEqual([{ ok: true }])
+  })
+
+  it('rejects a body over the size limit', () => {
+    const decoder = new MessageDecoder(4)
+    expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/)
+  })
+
+  it('rejects a missing Content-Length header', () => {
+    const decoder = new MessageDecoder(1_000)
+    expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/)
+  })
+
+  it('rejects a non-numeric Content-Length', () => {
+    const decoder = new MessageDecoder(1_000)
+    expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/)
+  })
+
+  it('rejects a header block that never terminates', () => {
+    const decoder = new MessageDecoder(1_000)
+    const huge = Buffer.alloc((1 << 16) + 1, 0x41)
+    expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
+  })
+
+  it('rejects a non-JSON body', () => {
+    const decoder = new MessageDecoder(1_000)
+    expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
+  })
+})

+ 105 - 0
packages/lsp/lsp-local/tests/host.spec.ts

@@ -0,0 +1,105 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { realpath } from 'node:fs/promises'
+import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
+
+let root: string
+let ws: string
+
+beforeEach(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
+  ws = join(root, 'ws')
+  await mkdir(ws)
+})
+
+afterEach(async () => {
+  await rm(root, { recursive: true, force: true })
+})
+
+const BIG = 1_000_000
+
+describe('canonicalizeWorkspace', () => {
+  it('returns the realpath of a directory', async () => {
+    expect(await canonicalizeWorkspace(ws)).toBe(ws)
+  })
+
+  it('resolves a symlinked workspace to its target so aliases share identity', async () => {
+    const link = join(root, 'ws-link')
+    await symlink(ws, link)
+    expect(await canonicalizeWorkspace(link)).toBe(ws)
+  })
+
+  it('rejects a missing workspace', async () => {
+    await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/)
+  })
+
+  it('rejects a non-directory workspace', async () => {
+    const file = join(root, 'file.txt')
+    await writeFile(file, 'x')
+    await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/)
+  })
+})
+
+describe('readHostSource', () => {
+  it('reads a relative path against the workspace', async () => {
+    await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
+    const source = await readHostSource('a.ts', ws, BIG)
+    expect(source.canonicalPath).toBe(join(ws, 'a.ts'))
+    expect(source.text).toBe('const x = 1\n')
+  })
+
+  it('reads an absolute path inside the workspace', async () => {
+    const abs = join(ws, 'b.ts')
+    await writeFile(abs, 'b')
+    const source = await readHostSource(abs, ws, BIG)
+    expect(source.canonicalPath).toBe(abs)
+  })
+
+  it('accepts a source reached through a symlink that stays inside the workspace', async () => {
+    await mkdir(join(ws, 'real'))
+    await writeFile(join(ws, 'real', 'c.ts'), 'c')
+    await symlink(join(ws, 'real'), join(ws, 'linked'))
+    const source = await readHostSource('linked/c.ts', ws, BIG)
+    expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts'))
+  })
+
+  it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
+    const outside = join(root, 'outside.ts')
+    await writeFile(outside, 'secret')
+    await symlink(outside, join(ws, 'escape.ts'))
+    await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/)
+  })
+
+  it('rejects an absolute source outside the workspace', async () => {
+    const outside = join(root, 'out.ts')
+    await writeFile(outside, 'x')
+    await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/)
+  })
+
+  it('rejects a missing source', async () => {
+    await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/)
+  })
+
+  it('rejects a non-regular source (directory)', async () => {
+    await mkdir(join(ws, 'dir'))
+    await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
+  })
+
+  it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
+    // filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
+    // directory then fails the regular-file check.
+    await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/)
+  })
+
+  it('rejects an oversized source', async () => {
+    await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
+    await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/)
+  })
+
+  it('rejects a non-UTF-8 source', async () => {
+    await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
+    await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
+  })
+})

+ 184 - 0
packages/lsp/lsp-local/tests/instance.spec.ts

@@ -0,0 +1,184 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL, fileURLToPath } from 'node:url'
+import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
+import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
+import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp'
+
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+let root: string
+let ws: string
+let live: LspInstance[] = []
+
+beforeEach(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
+  ws = join(root, 'ws')
+  await mkdir(ws)
+  await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
+})
+
+afterEach(async () => {
+  for (const instance of live) await instance.dispose()
+  live = []
+  await rm(root, { recursive: true, force: true })
+})
+
+function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
+  const instance = new LspInstance({
+    command: process.execPath,
+    args: ['--import', tsxLoader, fixtureServer],
+    cwd: ws,
+    env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
+    configuration: { setting: 42 },
+    initializationOptions: { init: true },
+    maxMessageBytes: 16_000_000,
+    maxStderrBytes: 100_000,
+    maxDocumentBytes: 4_000_000,
+    shutdownTimeoutMs: 200,
+    killGraceMs: 200,
+    ...overrides,
+  })
+  live.push(instance)
+  return instance
+}
+
+function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery {
+  return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
+}
+
+/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
+function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
+  const instance = new LspInstance({
+    command: process.execPath,
+    args: ['-e', script],
+    cwd: ws,
+    env: { ...process.env as Record<string, string> },
+    configuration: null,
+    initializationOptions: null,
+    maxMessageBytes: 16_000_000,
+    maxStderrBytes: 100_000,
+    maxDocumentBytes: 4_000_000,
+    shutdownTimeoutMs: 150,
+    killGraceMs: 150,
+    ...overrides,
+  })
+  live.push(instance)
+  return instance
+}
+
+/** An inline server that answers initialize + definition and echoes a location. */
+const RESPONDING_SERVER =
+  'let b=Buffer.alloc(0);'
+  + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+  + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
+  + 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
+  + 'else if(m.method==="textDocument/definition")process.stdout.write(fr({id:m.id,result:null}));'
+  + '}});'
+
+const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
+
+describe('LspInstance server-request handling', () => {
+  it('answers workspace/configuration with the static config per item', async () => {
+    const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
+    // The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
+    // keeps the query working.
+    await expect(instance.query(query('definition'))).resolves.toMatchObject({ kind: 'locations' })
+  })
+
+  it('accepts a lifecycle client/registerCapability request', async () => {
+    const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
+    await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
+  })
+
+  it('rejects a workspace/applyEdit request but keeps serving', async () => {
+    const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
+    await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
+  })
+
+  it('rejects an unknown server request but keeps serving', async () => {
+    const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
+    await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
+  })
+})
+
+describe('LspInstance query and abort', () => {
+  it('sends includeDeclaration for references', async () => {
+    const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
+    await expect(instance.query(query('references'))).resolves.toMatchObject({ kind: 'locations' })
+  })
+
+  it('rejects a query aborted before it starts', async () => {
+    const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
+    const controller = new AbortController()
+    controller.abort(new Error('pre-abort'))
+    await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/pre-abort/)
+  })
+
+  it('cancels an in-flight request on abort and rejects', async () => {
+    const instance = makeInstance({ LSP_FAKE_HANG: '1' })
+    const controller = new AbortController()
+    // Warm the instance first so the abort lands during the hanging request, not during startup.
+    const pending = instance.query(query('definition'), controller.signal)
+    await new Promise<void>(resolve => setTimeout(resolve, 300))
+    controller.abort(new Error('mid-flight'))
+    await expect(pending).rejects.toThrow(/mid-flight/)
+  })
+
+  it('rejects when the server lacks the operation capability', async () => {
+    const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
+    await expect(instance.query(query('definition'))).rejects.toThrow(/does not support definition/)
+  })
+
+  it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
+    // A live signal is passed, but the request fails for a server reason; the catch must rethrow
+    // without treating it as an abort.
+    const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
+    const controller = new AbortController()
+    await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/)
+  })
+})
+
+describe('LspInstance disposal', () => {
+  it('is idempotent — a second dispose awaits close without error', async () => {
+    const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
+    await instance.query(query('definition'))
+    await instance.dispose()
+    await expect(instance.dispose()).resolves.toBeUndefined()
+  })
+
+  it('rejects a query after disposal', async () => {
+    const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
+    await instance.query(query('definition'))
+    await instance.dispose()
+    await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/)
+  })
+
+  it('reports dead after the process closes', async () => {
+    const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
+    await instance.query(query('definition'))
+    await instance.dispose()
+    expect(instance.dead).toBe(true)
+  })
+
+  it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
+    // Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
+    const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
+    const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
+    await instance.query(query('definition'))
+    await expect(instance.dispose()).resolves.toBeUndefined()
+  })
+
+  it('carries a non-Error abort reason as a generic aborted error', async () => {
+    const instance = makeInstance({ LSP_FAKE_HANG: '1' })
+    const controller = new AbortController()
+    const pending = instance.query(query('definition'), controller.signal)
+    await new Promise<void>(resolve => setTimeout(resolve, 200))
+    controller.abort('a string reason, not an Error')
+    await expect(pending).rejects.toThrow(/aborted/)
+  })
+})

+ 200 - 0
packages/lsp/lsp-local/tests/lifecycle.spec.ts

@@ -0,0 +1,200 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
+import { realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL, fileURLToPath } from 'node:url'
+import { Context } from 'cordis'
+import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
+import { deadline } from '@deepseek-ai/dsh-timeout'
+import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
+import type { Config } from '@deepseek-ai/dsh-lsp-local'
+
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+let root: string
+let ws: string
+
+beforeEach(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-')))
+  ws = join(root, 'ws')
+  await mkdir(ws)
+  await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n')
+})
+
+afterEach(async () => {
+  await rm(root, { recursive: true, force: true })
+})
+
+/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */
+async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<Config> = {}): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(Lsp)
+  await ctx.plugin(LspLocal, {
+    providerId: 'fake',
+    command: process.execPath,
+    args: ['--import', tsxLoader, fixtureServer],
+    env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv },
+    extensionToLanguage: { '.ts': 'typescript' },
+    ...overrides,
+  })
+  return ctx
+}
+
+function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest {
+  return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws }
+}
+
+/** A single Location JSON pointing into the workspace. */
+function locationJson(line: number): unknown {
+  return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } }
+}
+
+describe('lsp-local end to end over a fake server', () => {
+  it('resolves definition to normalized locations', async () => {
+    const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
+    const result = await ctx.lsp.query(query('definition'))
+    expect(result).toEqual<LspQueryResult>({
+      kind: 'locations',
+      locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
+    })
+    await ctx.fiber.dispose()
+  })
+
+  it('maps a LocationLink for implementation', async () => {
+    const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
+    const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
+    const result = await ctx.lsp.query(query('implementation'))
+    expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
+    await ctx.fiber.dispose()
+  })
+
+  it('returns references (server includes the declaration)', async () => {
+    const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
+    const result = await ctx.lsp.query(query('references'))
+    expect(result).toMatchObject({ kind: 'locations' })
+    if (result.kind !== 'locations') throw new Error('expected locations')
+    expect(result.locations).toHaveLength(2)
+    await ctx.fiber.dispose()
+  })
+
+  it('normalizes a hover MarkupContent', async () => {
+    const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) })
+    const result = await ctx.lsp.query(query('hover'))
+    expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } })
+    await ctx.fiber.dispose()
+  })
+
+  it('returns an empty locations result for a null definition', async () => {
+    const ctx = await mount({ LSP_FAKE_DEF: 'null' })
+    expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] })
+    await ctx.fiber.dispose()
+  })
+
+  it('returns a null hover for a null result', async () => {
+    const ctx = await mount({ LSP_FAKE_HOVER: 'null' })
+    expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null })
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects a non-utf-16 position encoding at initialize', async () => {
+    const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
+    await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects a server without transient-open sync (None)', async () => {
+    const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
+    await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/)
+    await ctx.fiber.dispose()
+  })
+
+  it('accepts openClose options sync', async () => {
+    const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
+    expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] })
+    await ctx.fiber.dispose()
+  })
+
+  it('fails a query for an unsupported operation', async () => {
+    const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' })
+    await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/)
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects a source outside the workspace before startup', async () => {
+    const outside = join(root, 'out.ts')
+    await writeFile(outside, 'x')
+    const ctx = await mount({ LSP_FAKE_DEF: 'null' })
+    await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
+    await ctx.fiber.dispose()
+  })
+
+  it('serializes queries through one instance and runs them in order', async () => {
+    const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
+    const results = await Promise.all([
+      ctx.lsp.query(query('definition')),
+      ctx.lsp.query(query('definition')),
+      ctx.lsp.query(query('definition')),
+    ])
+    for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
+    await ctx.fiber.dispose()
+  })
+
+  it('aborts an in-flight query when the signal fires', async () => {
+    const ctx = await mount({ LSP_FAKE_HANG: '1' })
+    const controller = new AbortController()
+    const pending = ctx.lsp.query(query('definition'), controller.signal)
+    controller.abort(new Error('caller cancelled'))
+    await expect(pending).rejects.toThrow(/cancelled/)
+    await ctx.fiber.dispose()
+  })
+
+  it('classifies a timeout deadline as the abort reason', async () => {
+    const ctx = await mount({ LSP_FAKE_HANG: '1' })
+    using d = deadline(undefined, 50, 'TEST_TIMEOUT')
+    await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
+    await ctx.fiber.dispose()
+  })
+
+  it('fails the active query when the server crashes on open, and replaces it next query', async () => {
+    const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
+    await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
+    // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
+    await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
+    await ctx.fiber.dispose()
+  })
+
+  it('runs distinct workspaces in parallel instances', async () => {
+    const ws2 = join(root, 'ws2')
+    await mkdir(ws2)
+    await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
+    const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
+    const [r1, r2] = await Promise.all([
+      ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }),
+      ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }),
+    ])
+    expect(r1).toMatchObject({ kind: 'locations' })
+    expect(r2).toMatchObject({ kind: 'locations' })
+    await ctx.fiber.dispose()
+  })
+
+  it('disposes cleanly, terminating a server that ignores shutdown', async () => {
+    const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
+    await ctx.lsp.query(query('definition'))
+    await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
+  })
+
+  it('rejects at load when the command is not found', async () => {
+    const ctx = new Context()
+    await ctx.plugin(Lsp)
+    await expect(ctx.plugin(LspLocal, {
+      providerId: 'missing',
+      command: 'definitely-not-a-real-lsp-binary-xyz',
+      args: [],
+      extensionToLanguage: { '.ts': 'typescript' },
+    })).rejects.toThrow(/was not found on PATH/)
+    await ctx.fiber.dispose()
+  })
+})

+ 78 - 0
packages/lsp/lsp-local/tests/provider.spec.ts

@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from 'cordis'
+import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
+import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
+
+let root: string
+let ws: string
+
+beforeEach(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-')))
+  ws = join(root, 'ws')
+  await mkdir(ws)
+  await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
+})
+
+afterEach(async () => {
+  await rm(root, { recursive: true, force: true })
+})
+
+function query(): LspQueryRequest {
+  return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
+}
+
+describe('lsp-local provider resolution', () => {
+  it('resolves a bare command on the child PATH and registers the provider', async () => {
+    // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it.
+    const bin = join(root, 'bin')
+    await mkdir(bin)
+    const exe = join(bin, 'fake-lsp')
+    await writeFile(exe, '#!/bin/sh\nexit 0\n')
+    await chmod(exe, 0o755)
+
+    const ctx = new Context()
+    await ctx.plugin(Lsp)
+    await expect(ctx.plugin(LspLocal, {
+      providerId: 'onpath',
+      command: 'fake-lsp',
+      args: [],
+      env: { PATH: bin },
+      extensionToLanguage: { '.ts': 'typescript' },
+    })).resolves.toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('skips empty PATH segments and fails when the command is absent', async () => {
+    const ctx = new Context()
+    await ctx.plugin(Lsp)
+    await expect(ctx.plugin(LspLocal, {
+      providerId: 'nope',
+      command: 'fake-lsp',
+      args: [],
+      env: { PATH: `::${join(root, 'empty')}` },
+      extensionToLanguage: { '.ts': 'typescript' },
+    })).rejects.toThrow(/was not found on PATH/)
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects a query after the provider is disposed', async () => {
+    // Use a server that never emits results and dispose the plugin, then confirm queries are refused.
+    const ctx = new Context()
+    await ctx.plugin(Lsp)
+    // Grab the provider instance by registering, then dispose the whole plugin fiber.
+    const lsp = ctx.lsp
+    const fiber = await ctx.plugin(LspLocal, {
+      providerId: 'disp',
+      command: process.execPath,
+      args: ['-e', 'setInterval(()=>{},1000)'],
+      extensionToLanguage: { '.ts': 'typescript' },
+    })
+    await fiber.dispose()
+    // After disposal the provider unregistered from the seam, so selection fails as unavailable.
+    await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+    await ctx.fiber.dispose()
+  })
+})

+ 153 - 0
packages/lsp/lsp-local/tests/translate.spec.ts

@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest'
+import {
+  negotiatePositionEncoding,
+  normalizeHover,
+  normalizeLocations,
+  requestMethod,
+  supportsOperation,
+  supportsTransientOpen,
+} from '@deepseek-ai/dsh-lsp-local'
+import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts'
+
+const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
+
+describe('requestMethod', () => {
+  it('maps each operation to its textDocument request', () => {
+    expect(requestMethod('definition')).toBe('textDocument/definition')
+    expect(requestMethod('references')).toBe('textDocument/references')
+    expect(requestMethod('implementation')).toBe('textDocument/implementation')
+    expect(requestMethod('hover')).toBe('textDocument/hover')
+  })
+})
+
+describe('supportsOperation', () => {
+  it('reads the provider slot for each operation (boolean and options forms)', () => {
+    const caps: WireServerCapabilities = {
+      definitionProvider: true,
+      referencesProvider: { workDoneProgress: true },
+      implementationProvider: false,
+    }
+    expect(supportsOperation(caps, 'definition')).toBe(true)
+    expect(supportsOperation(caps, 'references')).toBe(true)
+    expect(supportsOperation(caps, 'implementation')).toBe(false)
+    expect(supportsOperation(caps, 'hover')).toBe(false)
+  })
+})
+
+describe('supportsTransientOpen', () => {
+  it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
+    expect(supportsTransientOpen(1)).toBe(true)
+    expect(supportsTransientOpen(2)).toBe(true)
+    expect(supportsTransientOpen(0)).toBe(false)
+    expect(supportsTransientOpen(undefined)).toBe(false)
+  })
+
+  it('accepts options with openClose:true and rejects openClose:false', () => {
+    expect(supportsTransientOpen({ openClose: true })).toBe(true)
+    expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
+  })
+
+  it('falls back to the change enum when openClose is omitted', () => {
+    expect(supportsTransientOpen({ change: 1 })).toBe(true)
+    expect(supportsTransientOpen({ change: 0 })).toBe(false)
+    expect(supportsTransientOpen({})).toBe(false)
+  })
+})
+
+describe('negotiatePositionEncoding', () => {
+  it('defaults an omitted encoding to utf-16', () => {
+    expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
+    expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
+  })
+
+  it('rejects any other encoding', () => {
+    expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
+  })
+})
+
+describe('normalizeLocations', () => {
+  it('returns empty for null and undefined', () => {
+    expect(normalizeLocations(null)).toEqual([])
+    expect(normalizeLocations(undefined)).toEqual([])
+  })
+
+  it('maps a single Location', () => {
+    expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
+  })
+
+  it('maps an array of Locations', () => {
+    const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
+    expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
+  })
+
+  it('maps a LocationLink from targetUri + targetSelectionRange', () => {
+    const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
+    expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
+  })
+
+  it('rejects a non-object entry', () => {
+    expect(() => normalizeLocations([42])).toThrow(/non-object/)
+  })
+
+  it('rejects an entry that is neither a Location nor a LocationLink', () => {
+    expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
+  })
+
+  it('rejects a Location whose range is not an object', () => {
+    expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
+  })
+
+  it('rejects a Location whose range positions are malformed', () => {
+    expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
+  })
+})
+
+describe('normalizeHover', () => {
+  it('returns null for null', () => {
+    expect(normalizeHover(null)).toBeNull()
+  })
+
+  it('reads MarkupContent value and keeps a range', () => {
+    expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
+      .toEqual({ contents: '# H', range: RANGE })
+  })
+
+  it('keeps a bare string MarkedString verbatim', () => {
+    expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
+  })
+
+  it('renders a language-tagged MarkedString object as a fenced code block', () => {
+    expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
+      .toEqual({ contents: '```ts\nconst x = 1\n```' })
+  })
+
+  it('joins a MarkedString array with one blank line', () => {
+    expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
+      .toEqual({ contents: 'a\n\n```ts\nb\n```' })
+  })
+
+  it('drops an empty-contents hover to null', () => {
+    expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
+  })
+
+  it('treats a MarkupContent with a non-string value as empty (null)', () => {
+    expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull()
+  })
+
+  it('rejects a non-object payload', () => {
+    expect(() => normalizeHover(42)).toThrow(/was not an object/)
+  })
+
+  it('rejects malformed contents', () => {
+    expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
+    expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
+  })
+
+  it('rejects a hover with no contents field', () => {
+    expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
+  })
+
+  it('ignores a malformed range and keeps the contents', () => {
+    expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' })
+  })
+})

+ 111 - 0
packages/lsp/lsp-local/tests/typescript-server.e2e.ts

@@ -0,0 +1,111 @@
+/**
+ * Keyless real-server e2e: drives the real `typescript-language-server` through the full
+ * `ctx.lsp` → `dsh-lsp-local` stack over the base protocol, exercising all four operations. No API
+ * key needed — the server is a local dev dependency. This establishes one compatibility floor
+ * (TypeScript), not a cross-language claim.
+ */
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from 'cordis'
+import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
+import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
+
+// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path.
+const serverBin = join(
+  new URL('..', import.meta.url).pathname,
+  'node_modules',
+  '.bin',
+  'typescript-language-server',
+)
+
+let root: string
+let ws: string
+let ctx: Context
+
+beforeAll(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-')))
+  ws = join(root, 'proj')
+  await mkdir(ws)
+  await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } }))
+  // A small program with a definition, a reference, an interface + implementation, and a typed value.
+  await writeFile(join(ws, 'shapes.ts'), [
+    'export interface Shape {',
+    '  area(): number',
+    '}',
+    '',
+    'export class Circle implements Shape {',
+    '  constructor(private r: number) {}',
+    '  area(): number { return Math.PI * this.r * this.r }',
+    '}',
+    '',
+    'export function describe(s: Shape): string {',
+    '  return `area=${s.area()}`',
+    '}',
+    '',
+    'const c = new Circle(2)',
+    'export const text = describe(c)',
+    '',
+  ].join('\n'))
+
+  ctx = new Context()
+  await ctx.plugin(Lsp)
+  await ctx.plugin(LspLocal, {
+    providerId: 'typescript',
+    command: serverBin,
+    args: ['--stdio'],
+    extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
+  })
+}, 60_000)
+
+afterAll(async () => {
+  if (ctx) await ctx.fiber.dispose()
+  if (root) await rm(root, { recursive: true, force: true })
+})
+
+/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */
+function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest {
+  return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws }
+}
+
+function locations(result: LspQueryResult): readonly { uri: string }[] {
+  if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`)
+  return result.locations
+}
+
+describe('real typescript-language-server', () => {
+  it('resolves the definition of a call site to its declaration', async () => {
+    // `export const text = describe(c)` (line 15): `describe` begins at column 21.
+    const result = await ctx.lsp.query(at('definition', 15, 22))
+    const locs = locations(result)
+    expect(locs.length).toBeGreaterThanOrEqual(1)
+    expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
+  }, 60_000)
+
+  it('finds references to a symbol including its declaration', async () => {
+    // References to `describe` from its declaration (line 10, col 17).
+    const result = await ctx.lsp.query(at('references', 10, 17))
+    const locs = locations(result)
+    // At least the declaration plus the call site.
+    expect(locs.length).toBeGreaterThanOrEqual(2)
+  }, 60_000)
+
+  it('resolves implementations of an interface', async () => {
+    // Implementations of `Shape` (line 1, col 18) → Circle.
+    const result = await ctx.lsp.query(at('implementation', 1, 18))
+    const locs = locations(result)
+    expect(locs.length).toBeGreaterThanOrEqual(1)
+  }, 60_000)
+
+  it('returns hover information for a typed symbol', async () => {
+    // Hover on `Circle` in `new Circle(2)` (line 14, col 15).
+    const result = await ctx.lsp.query(at('hover', 14, 15))
+    expect(result.kind).toBe('hover')
+    if (result.kind === 'hover') {
+      expect(result.hover).not.toBeNull()
+      expect(result.hover?.contents).toContain('Circle')
+    }
+  }, 60_000)
+})

+ 33 - 0
packages/lsp/lsp-local/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../util/timeout"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../lsp"
+    }
+  ]
+}

+ 38 - 0
packages/lsp/lsp/README.md

@@ -0,0 +1,38 @@
+# @deepseek-ai/dsh-lsp
+
+The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses.
+
+This package is the interface third of the LSP capability:
+
+| Package | Role |
+|---|---|
+| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy |
+| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider |
+| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` |
+
+The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
+
+## Service API (`ctx.lsp`)
+
+| Member | Semantics |
+|---|---|
+| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. |
+| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. |
+
+Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector.
+
+Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation.
+
+## Vocabulary
+
+`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes.
+
+## Model Experience
+
+Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself.
+
+## Known Limitations and Deferred Work
+
+- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
+- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration.
+- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query.

+ 34 - 0
packages/lsp/lsp/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@deepseek-ai/dsh-lsp",
+  "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 21 - 0
packages/lsp/lsp/src/brand.ts

@@ -0,0 +1,21 @@
+/**
+ * dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on
+ * `ctx.lsp`. The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its
+ * factory together here lets `index.ts` re-export both under one name.
+ * @module @deepseek-ai/dsh-lsp/brand
+ */
+
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** Opaque provider identity, reserved atomically with its extension mappings at registration. */
+export type LspProviderId = Branded<'LspProviderId'>
+
+/**
+ * Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at
+ * registration.
+ * @param id - the provider's stable identifier.
+ * @returns the same string, branded.
+ */
+export function LspProviderId(id: string): LspProviderId {
+  return id as LspProviderId
+}

+ 156 - 0
packages/lsp/lsp/src/index.ts

@@ -0,0 +1,156 @@
+/**
+ * The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
+ * order-independent selection over normalized definition/references/implementation/hover queries.
+ *
+ * A provider reserves a branded id and an exclusive set of file extensions atomically:
+ * {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an
+ * invalid or conflicting registration publishes nothing, and its disposer releases every
+ * reservation together. Selection routes a query by the file's final extension; it never depends on
+ * registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch.
+ * @module @deepseek-ai/dsh-lsp
+ */
+
+import { Context, Service } from 'cordis'
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+import type { LspProviderId } from './brand.ts'
+import type {
+  LspProvider,
+  LspQueryRequest,
+  LspQueryResult,
+  LspService,
+} from './types.ts'
+
+export { LspProviderId } from './brand.ts'
+export type {
+  LspHover,
+  LspLocation,
+  LspOperation,
+  LspPosition,
+  LspProvider,
+  LspProviderQuery,
+  LspQueryRequest,
+  LspQueryResult,
+  LspRange,
+  LspService,
+} from './types.ts'
+
+declare module 'cordis' {
+  interface Context {
+    lsp: LspService
+  }
+}
+
+/**
+ * Structured LSP failure. Extends {@link HarnessError} with a stable `code`
+ * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that
+ * callers route on instead of parsing `message`.
+ */
+export class LspError extends HarnessError {}
+
+/**
+ * Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` →
+ * `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile
+ * (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator
+ * does not change the result.
+ * @param filePath - the source path to inspect.
+ * @returns the normalized extension, or `''` when there is none.
+ */
+export function finalExtension(filePath: string): string {
+  const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
+  const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath
+  const dot = base.lastIndexOf('.')
+  // dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension.
+  if (dot <= 0) return ''
+  return base.slice(dot).toLowerCase()
+}
+
+/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */
+const EXTENSION_PATTERN = /^\.[^./\\]+$/
+
+/** One selection route: the provider to run plus the language id to synchronize the document with. */
+interface Route {
+  readonly provider: LspProvider
+  readonly languageId: string
+}
+
+/**
+ * `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared
+ * together per provider so a route always has a live provider.
+ */
+export class Lsp extends Service implements LspService {
+  private readonly providerIds = new Set<LspProviderId>()
+  private readonly routes = new Map<string, Route>()
+
+  constructor(ctx: Context) {
+    super(ctx, 'lsp')
+  }
+
+  registerProvider(provider: LspProvider): () => void {
+    // Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting
+    // registration must publish nothing (fail-loud, all-or-nothing).
+    const id = provider.id
+    if (id.trim() === '') {
+      throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER')
+    }
+    if (this.providerIds.has(id)) {
+      throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT')
+    }
+
+    const entries = Object.entries(provider.extensionToLanguage)
+    if (entries.length === 0) {
+      throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER')
+    }
+
+    // Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and
+    // `.ts`) before checking cross-provider conflicts.
+    const pending = new Map<string, Route>()
+    for (const [rawExt, languageId] of entries) {
+      const ext = normalizeExtension(rawExt)
+      if (!EXTENSION_PATTERN.test(ext)) {
+        throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER')
+      }
+      if (languageId.trim() === '') {
+        throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER')
+      }
+      if (pending.has(ext)) {
+        throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER')
+      }
+      pending.set(ext, { provider, languageId })
+    }
+    for (const ext of pending.keys()) {
+      if (this.routes.has(ext)) {
+        throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT')
+      }
+    }
+
+    // All checks passed: reserve id and every extension in one lifecycle controller so disposal
+    // releases them together.
+    const dispose = this.ctx.effect(function* (this: Lsp) {
+      this.providerIds.add(id)
+      for (const [ext, route] of pending) this.routes.set(ext, route)
+      yield () => {
+        this.providerIds.delete(id)
+        for (const ext of pending.keys()) this.routes.delete(ext)
+      }
+    }.bind(this), 'lsp.registerProvider()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is synchronous
+    // fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
+  }
+
+  async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
+    const route = this.routes.get(finalExtension(request.filePath))
+    if (route === undefined) {
+      throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
+    }
+    return route.provider.query({ ...request, languageId: route.languageId }, signal)
+  }
+}
+
+/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */
+function normalizeExtension(ext: string): string {
+  const lower = ext.toLowerCase()
+  return lower.startsWith('.') ? lower : `.${lower}`
+}
+
+export default Lsp

+ 124 - 0
packages/lsp/lsp/src/types.ts

@@ -0,0 +1,124 @@
+/**
+ * LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the
+ * {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in
+ * `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing
+ * tool owns the one-based cursor convention. The seam exposes no protocol types, process or document
+ * controls, or generic JSON-RPC escape hatch — only the four semantic operations.
+ * @module @deepseek-ai/dsh-lsp/types
+ */
+
+import type { LspProviderId } from './brand.ts'
+
+/**
+ * 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
+ * deliberately deferred (they need different schemas).
+ */
+export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover'
+
+/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
+export interface LspPosition {
+  /** Zero-based line. */
+  readonly line: number
+  /** Zero-based UTF-16 code-unit offset within the line. */
+  readonly character: number
+}
+
+/** A zero-based UTF-16 half-open range `[start, end)`. */
+export interface LspRange {
+  readonly start: LspPosition
+  readonly end: LspPosition
+}
+
+/**
+ * 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.
+ */
+export 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
+}
+
+/**
+ * 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.
+ */
+export interface LspProviderQuery extends LspQueryRequest {
+  /** The LSP language id for `filePath`, from this provider's extension mapping. */
+  readonly languageId: string
+}
+
+/** One resolved location: a document URI and the range within it. */
+export 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
+}
+
+/** Normalized hover content, or `null` for no hover at the position. */
+export 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
+}
+
+/**
+ * The closed result union. Navigation operations (`definition`, `references`, `implementation`)
+ * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind`
+ * to exhaustiveness so a new arm breaks compilation until handled.
+ */
+export type LspQueryResult =
+  | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] }
+  | { readonly kind: 'hover'; readonly hover: LspHover | null }
+
+/**
+ * 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). `references`
+ * always includes declarations — the provider enforces this internally; callers get no flag.
+ */
+export 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>
+}
+
+/**
+ * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query
+ * execution; exposes exactly the four operations and no protocol escape hatch.
+ */
+export 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>
+}

+ 187 - 0
packages/lsp/lsp/tests/lsp.spec.ts

@@ -0,0 +1,187 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Lsp, {
+  finalExtension,
+  LspError,
+  LspProviderId,
+  type LspProvider,
+  type LspProviderQuery,
+  type LspQueryResult,
+} from '@deepseek-ai/dsh-lsp'
+
+/** A scripted provider that records the queries it receives. */
+function makeProvider(
+  id: string,
+  extensionToLanguage: Record<string, string>,
+  result: LspQueryResult = { kind: 'locations', locations: [] },
+): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
+  const seen: LspProviderQuery[] = []
+  const seenSignals: (AbortSignal | undefined)[] = []
+  return {
+    id: LspProviderId(id),
+    extensionToLanguage,
+    seen,
+    seenSignals,
+    query(request, signal) {
+      seen.push(request)
+      seenSignals.push(signal)
+      return Promise.resolve(result)
+    },
+  }
+}
+
+/** Mount an Lsp service on a fresh root context. */
+async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> {
+  const ctx = new Context()
+  await ctx.plugin(Lsp)
+  return { ctx, lsp: ctx.lsp as Lsp }
+}
+
+const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } }
+
+function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters<Lsp['query']>[0] {
+  return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' }
+}
+
+describe('finalExtension', () => {
+  it('lowercases and keeps only the final extension', () => {
+    expect(finalExtension('src/Foo.TS')).toBe('.ts')
+    expect(finalExtension('a/b/foo.d.ts')).toBe('.ts')
+    expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs')
+  })
+
+  it('returns empty for no extension or a leading-dot dotfile', () => {
+    expect(finalExtension('Makefile')).toBe('')
+    expect(finalExtension('.bashrc')).toBe('')
+    expect(finalExtension('dir.d/file')).toBe('')
+  })
+})
+
+describe('Lsp registration', () => {
+  it('registers a provider and routes a query to it, then releases on dispose', async () => {
+    const { lsp } = await mountLsp()
+    const provider = makeProvider('ts', { '.ts': 'typescript' })
+    const dispose = lsp.registerProvider(provider)
+
+    await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] })
+    expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
+
+    dispose()
+    await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+  })
+
+  it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => {
+    const { lsp } = await mountLsp()
+    const provider = makeProvider('ts', { TS: 'typescript' })
+    lsp.registerProvider(provider)
+    await lsp.query(query('a.ts'))
+    expect(provider.seen[0]?.languageId).toBe('typescript')
+  })
+
+  it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => {
+    const { lsp } = await mountLsp()
+    expect(() => lsp.registerProvider(makeProvider('  ', { '.ts': 'typescript' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
+  })
+
+  it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => {
+    const { lsp } = await mountLsp()
+    expect(() => lsp.registerProvider(makeProvider('ts', {})))
+      .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
+  })
+
+  it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => {
+    const { lsp } = await mountLsp()
+    expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
+  })
+
+  it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => {
+    const { lsp } = await mountLsp()
+    expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': '  ' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
+  })
+
+  it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => {
+    const { lsp } = await mountLsp()
+    expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
+  })
+
+  it('rejects a duplicate provider id (LSP_CONFLICT)', async () => {
+    const { lsp } = await mountLsp()
+    lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
+    expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
+  })
+
+  it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => {
+    const { lsp } = await mountLsp()
+    lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
+    expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
+  })
+
+  it('publishes nothing when a later extension conflicts (atomic reservation)', async () => {
+    const { lsp } = await mountLsp()
+    lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
+    // This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back.
+    expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' })))
+      .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
+    // `.py` must NOT have been reserved.
+    await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+  })
+
+  it('releases every extension and the id together on dispose', async () => {
+    const { lsp } = await mountLsp()
+    const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' }))
+    dispose()
+    await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+    await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+    // The id is free again after release.
+    expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow()
+  })
+
+  it('selection is order-independent across two providers', async () => {
+    const { lsp } = await mountLsp()
+    const ts = makeProvider('ts', { '.ts': 'typescript' }, hover)
+    const py = makeProvider('py', { '.py': 'python' })
+    lsp.registerProvider(ts)
+    lsp.registerProvider(py)
+    await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] })
+    await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
+  })
+
+  it('forwards the abort signal verbatim to the provider', async () => {
+    const { lsp } = await mountLsp()
+    const provider = makeProvider('ts', { '.ts': 'typescript' })
+    lsp.registerProvider(provider)
+    const controller = new AbortController()
+    await lsp.query(query('a.ts'), controller.signal)
+    expect(provider.seenSignals[0]).toBe(controller.signal)
+  })
+
+  it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => {
+    const { lsp } = await mountLsp()
+    lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
+    await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+  })
+
+  it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
+    const { ctx, lsp } = await mountLsp()
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
+    }, { inject: ['lsp'] }))
+    await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] })
+    await fiber.dispose()
+    await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
+  })
+
+  it('LspError carries its structured code', () => {
+    expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE')
+  })
+
+  it('brands a provider id without altering the string', () => {
+    expect(LspProviderId('ts')).toBe('ts')
+  })
+})

+ 24 - 0
packages/lsp/lsp/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../llm/llm"
+    }
+  ]
+}

+ 56 - 0
packages/lsp/tool-lsp/README.md

@@ -0,0 +1,56 @@
+# @deepseek-ai/dsh-tool-lsp
+
+The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider.
+
+Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`.
+
+## The tool
+
+`lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input.
+
+The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. |
+| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. |
+| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. |
+
+## Model Experience
+
+### Prompt guidance
+
+**What the model sees**: One system-prompt section (order 112) positioning LSP as a precision aid, plus the tool schema below.
+
+**Token effect**: Fixed — the verbatim prose below is contributed once per request while the tool is enabled.
+
+#### Verbatim text for this context surface
+
+```markdown
+Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.
+```
+
+### Tool schema
+
+**What the model sees**: The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp).
+
+**Token effect**: Fixed per request while enabled; the `timeoutMs` budget is never sent to the model.
+
+### Results
+
+**What the model sees**: File-grouped `path:line:character` location lines, or normalized hover text; capped by `maxLocations` / `maxHoverChars` with an omission marker when truncated, and distinct `No results.` / `No hover information.` lines for empty results.
+
+**Token effect**: Capped by the two limits above.
+
+### ACP presentation
+
+**What the model sees**: A generic search card — `{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }` — whose args-derived title carries the operation and one-based cursor; follow-along focuses the queried line while the title preserves the column. Rendered by the client, not sent to the model.
+
+**Token effect**: Zero direct token effect (client-side rendering only).
+
+## Known Limitations and Deferred Work
+
+- **UTF-16 cursor coordinates** — columns are exact for the protocol but hard for a model to count around non-BMP characters; an off-symbol position may return empty results, so the prompt explains the convention without encouraging broad LSP use ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
+- **No cross-server completeness promise** — supported servers may return empty or partial results depending on indexing readiness; the tool promises no completeness across languages or servers.

+ 45 - 0
packages/lsp/tool-lsp/package.json

@@ -0,0 +1,45 @@
+{
+  "name": "@deepseek-ai/dsh-tool-lsp",
+  "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with definition/references/implementation/hover operations, one-based UTF-16 cursor coordinates, workspace-grouped location rendering, and hover normalization",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-lsp": "^0.0.1",
+    "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-lsp": "workspace:^",
+    "@deepseek-ai/dsh-lsp-local": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-timeout-policy": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 130 - 0
packages/lsp/tool-lsp/src/index.ts

@@ -0,0 +1,130 @@
+/**
+ * Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
+ * (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor
+ * coordinates to the seam's zero-based positions, requires the session workspace with no fallback,
+ * caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to
+ * enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider.
+ *
+ * Namespace plugin (named exports, no default export).
+ * @module @deepseek-ai/dsh-tool-lsp
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import { LspError } from '@deepseek-ai/dsh-lsp'
+import type {} from '@deepseek-ai/dsh-lsp'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+import {
+  DEFAULT_MAX_HOVER_CHARS,
+  DEFAULT_MAX_LOCATIONS,
+  formatHover,
+  formatLocations,
+  LSP_OPERATIONS,
+  parseLspArgs,
+  presentLspCall,
+} from './render.ts'
+import { sessionCwd } from './session-cwd.ts'
+
+export {
+  DEFAULT_MAX_HOVER_CHARS,
+  DEFAULT_MAX_LOCATIONS,
+  formatHover,
+  formatLocations,
+  LSP_OPERATIONS,
+  parseLspArgs,
+  presentLspCall,
+  renderUri,
+} from './render.ts'
+export { sessionCwd } from './session-cwd.ts'
+
+/** Cordis plugin name for loader diagnostics. */
+export const name = 'tool-lsp'
+
+/** Services required by this plugin. */
+export const inject = ['tools', 'lsp', 'systemPrompt']
+
+/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */
+export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
+
+/** The stable system-prompt guidance positioning LSP as a precision aid. */
+export const LSP_PROMPT_TEXT =
+  'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.'
+
+/** Plugin configuration: result caps and the timeout budget. */
+export interface Config {
+  /** Largest number of rendered locations before an omission marker (default 100). */
+  maxLocations?: number
+  /** Largest hover length in characters after normalization (default 16000). */
+  maxHoverChars?: number
+  /** Tool-call timeout budget in ms (default 60000). */
+  timeoutMs?: number
+}
+
+export const Config: z<Config> = z.object({
+  maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
+  maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS),
+  timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
+})
+
+type ResolvedConfig = Required<Config>
+
+/**
+ * Register the `lsp` tool and its system-prompt guidance.
+ * @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`).
+ * @param config - the resolved plugin configuration.
+ */
+export function apply(ctx: Context, config: Config): void {
+  const resolved = config as ResolvedConfig
+  assertPositiveInteger('maxLocations', resolved.maxLocations)
+  assertPositiveInteger('maxHoverChars', resolved.maxHoverChars)
+  assertPositiveInteger('timeoutMs', resolved.timeoutMs)
+
+  ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
+
+  ctx.tools.register(defineTool({
+    name: 'lsp',
+    description:
+      'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.',
+    parameters: {
+      operation: {
+        type: 'string',
+        required: true,
+        enum: [...LSP_OPERATIONS],
+        description: 'definition, references, implementation, or hover.',
+      },
+      file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
+      line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
+      character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' },
+    },
+    timeoutMs: resolved.timeoutMs,
+    async execute(args, exec): Promise<ContentBlock[]> {
+      const input = parseLspArgs(args)
+      const workspaceRoot = sessionCwd(exec)
+      if (workspaceRoot === undefined) {
+        throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
+      }
+      const result = await ctx.lsp.query({
+        operation: input.operation,
+        filePath: input.filePath,
+        position: input.position,
+        workspaceRoot,
+      }, exec.signal)
+      switch (result.kind) {
+        case 'locations':
+          return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }]
+        case 'hover':
+          return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
+      }
+    },
+    presentCall: presentLspCall,
+  }))
+}
+
+/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
+function assertPositiveInteger(name: string, value: number): void {
+  if (!Number.isInteger(value) || value < 1) {
+    throw new Error(`tool-lsp: ${name} must be a positive integer`)
+  }
+}

+ 158 - 0
packages/lsp/tool-lsp/src/render.ts

@@ -0,0 +1,158 @@
+/**
+ * Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
+ * conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and
+ * ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it
+ * depends only on the tool arguments.
+ * @module @deepseek-ai/dsh-tool-lsp/render
+ */
+
+import { fileURLToPath } from 'node:url'
+import { isAbsolute, relative, sep } from 'node:path'
+import type { GenericCallView } from '@deepseek-ai/dsh-tools'
+import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
+
+/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
+export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover']
+
+/** Default cap on rendered locations before an omission marker is appended. */
+export const DEFAULT_MAX_LOCATIONS = 100
+
+/** Default cap on hover characters (applied after normalization) before truncation is marked. */
+export const DEFAULT_MAX_HOVER_CHARS = 16_000
+
+/** Validated `lsp` arguments after coordinate checks. */
+export interface LspToolInput {
+  readonly operation: LspOperation
+  readonly filePath: string
+  /** Zero-based UTF-16 position converted from the one-based model coordinates. */
+  readonly position: LspPosition
+}
+
+/** The raw, schema-typed argument shape. */
+export interface LspToolArgs {
+  readonly operation: string
+  readonly file_path: string
+  readonly line: number
+  readonly character: number
+}
+
+/**
+ * Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are
+ * positive one-based integers converted to the seam's zero-based position.
+ * @param args - the schema-validated raw arguments.
+ * @returns the validated input with a zero-based position.
+ * @throws Error when the operation is unknown or a coordinate is not a positive integer.
+ */
+export function parseLspArgs(args: LspToolArgs): LspToolInput {
+  if (!isOperation(args.operation)) {
+    throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`)
+  }
+  if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
+  const line = oneBased(args.line, 'line')
+  const character = oneBased(args.character, 'character')
+  return {
+    operation: args.operation,
+    filePath: args.file_path,
+    // The model counts from 1; the seam (and protocol) count from 0.
+    position: { line: line - 1, character: character - 1 },
+  }
+}
+
+/** Whether a string is one of the four operations. */
+function isOperation(value: string): value is LspOperation {
+  return (LSP_OPERATIONS as readonly string[]).includes(value)
+}
+
+/** Validate a one-based coordinate is a positive integer. */
+function oneBased(value: number, name: string): number {
+  if (!Number.isInteger(value) || value < 1) {
+    throw new Error(`${name} must be a positive integer (one-based)`)
+  }
+  return value
+}
+
+/**
+ * Render a locations result grouped by file, converting each zero-based location back to a one-based
+ * `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path;
+ * outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
+ * appends an omission marker when it truncates.
+ * @param locations - the seam's locations (possibly empty).
+ * @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
+ * @param maxLocations - the cap before truncation.
+ * @returns the rendered text; a distinct no-result line when there are none.
+ */
+export function formatLocations(
+  locations: readonly LspLocation[],
+  workspaceRoot: string,
+  maxLocations: number,
+): string {
+  if (locations.length === 0) return 'No results.'
+  const shown = locations.slice(0, maxLocations)
+  const omitted = locations.length - shown.length
+  const grouped = new Map<string, string[]>()
+  for (const location of shown) {
+    const path = renderUri(location.uri, workspaceRoot)
+    const line = location.range.start.line + 1
+    const character = location.range.start.character + 1
+    const entries = grouped.get(path) ?? []
+    entries.push(`${path}:${line}:${character}`)
+    grouped.set(path, entries)
+  }
+  const lines: string[] = []
+  for (const entries of grouped.values()) lines.push(...entries)
+  if (omitted > 0) {
+    lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`)
+  }
+  return lines.join('\n')
+}
+
+/**
+ * Render a hover result, applying `maxHoverChars` last and marking truncation.
+ * @param hover - the normalized hover, or `null` for no hover.
+ * @param maxHoverChars - the cap applied after normalization.
+ * @returns the rendered hover text; a distinct no-result line for `null`.
+ */
+export function formatHover(hover: LspHover | null, maxHoverChars: number): string {
+  if (hover === null) return 'No hover information.'
+  const contents = hover.contents
+  if (contents.length <= maxHoverChars) return contents
+  return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).`
+}
+
+/**
+ * Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative
+ * (inside) or absolute (outside); any other URI is returned verbatim.
+ * @param uri - the target URI from the seam.
+ * @param workspaceRoot - the canonical workspace root.
+ * @returns the display path or the verbatim URI.
+ */
+export function renderUri(uri: string, workspaceRoot: string): string {
+  if (!uri.startsWith('file:')) return uri
+  let absolute: string
+  try {
+    absolute = fileURLToPath(uri)
+  } catch {
+    // A malformed file: URI is not a path we can resolve; show it verbatim.
+    return uri
+  }
+  const rel = relative(workspaceRoot, absolute)
+  if (rel === '') return '.'
+  const outside = rel.startsWith('..') || isAbsolute(rel)
+  return outside ? absolute : rel.split(sep).join('/')
+}
+
+/**
+ * ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the
+ * operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has
+ * no character, so the title preserves the column).
+ * @param args - the raw tool arguments.
+ * @returns the generic call view.
+ */
+export function presentLspCall(args: LspToolArgs): GenericCallView {
+  return {
+    card: 'generic',
+    kind: 'search',
+    title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`,
+    locations: [{ path: args.file_path, line: args.line }],
+  }
+}

+ 19 - 0
packages/lsp/tool-lsp/src/session-cwd.ts

@@ -0,0 +1,19 @@
+/**
+ * Derive the workspace root an `lsp` call resolves against: the calling agent's per-session
+ * workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths.
+ * Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as
+ * `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it
+ * can start a server.
+ * @module @deepseek-ai/dsh-tool-lsp/session-cwd
+ */
+
+import type { ToolExecution } from '@deepseek-ai/dsh-tools'
+
+/**
+ * The session workspace cwd for this call, or `undefined` when none applies.
+ * @param exec - the tool-execution context; only its optional `agent` is read.
+ * @returns the calling agent's session cwd, or undefined for a non-agent caller.
+ */
+export function sessionCwd(exec: ToolExecution): string | undefined {
+  return exec.agent?.session.header.cwd
+}

+ 93 - 0
packages/lsp/tool-lsp/tests/integration.spec.ts

@@ -0,0 +1,93 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from 'cordis'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import Lsp from '@deepseek-ai/dsh-lsp'
+import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
+import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
+import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
+
+/**
+ * Real-composition integration: the model-facing `lsp` tool over the real seam, the real
+ * `dsh-lsp-local` provider (driving an inline stdio server), and the real `dsh-timeout-policy`, all
+ * driven only through `ctx.tools.execute()`. Pins that a query round-trips end to end and that the
+ * policy's `TOOL_TIMEOUT` budget wins when the server hangs.
+ */
+
+let root: string
+let ws: string
+
+beforeEach(async () => {
+  root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-')))
+  ws = join(root, 'ws')
+  await mkdir(ws)
+  await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
+})
+
+afterEach(async () => {
+  await rm(root, { recursive: true, force: true })
+})
+
+/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */
+function serverScript(hang: boolean): string {
+  const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
+  return 'let b=Buffer.alloc(0);'
+    + `const DEF=${definition};`
+    + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+    + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
+    + 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
+    + `else if(m.method==="textDocument/definition"){${hang ? '' : 'process.stdout.write(fr({id:m.id,result:DEF}));'}}`
+    + 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
+    + 'else if(m.method==="exit")process.exit(0);'
+    + '}});'
+}
+
+async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(Lsp)
+  await ctx.plugin(LspLocal, {
+    providerId: 'inline',
+    command: process.execPath,
+    args: ['-e', serverScript(hang)],
+    extensionToLanguage: { '.ts': 'typescript' },
+    shutdownTimeoutMs: 200,
+    killGraceMs: 200,
+  })
+  await ctx.plugin(TimeoutPolicy)
+  await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {})
+  return ctx
+}
+
+let seq = 0
+function call(ctx: Context, args: unknown) {
+  return ctx.tools.execute({
+    callId: `int-${++seq}` as never,
+    name: 'lsp',
+    arguments: args,
+    agent: { session: { header: { cwd: ws } } } as never,
+  })
+}
+
+describe('tool-lsp real composition', () => {
+  it('round-trips a definition query through the real provider and renders a location', async () => {
+    const ctx = await mount(false)
+    const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
+    expect(result.isError).toBe(false)
+    expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
+    await ctx.fiber.dispose()
+  }, 30_000)
+
+  it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
+    const ctx = await mount(true, 300)
+    const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
+    expect(result.isError).toBe(true)
+    expect(result.error?.code).toBe('TOOL_TIMEOUT')
+    await ctx.fiber.dispose()
+  }, 30_000)
+})

+ 24 - 0
packages/lsp/tool-lsp/tests/load-path.spec.ts

@@ -0,0 +1,24 @@
+/**
+ * Real-load-path guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a
+ * stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the
+ * bare `apply`, dropping `inject` (postmortem 0001). This unwraps through the REAL
+ * `Loader.prototype.unwrapExports` and verifies the namespace shape survives.
+ */
+
+import { describe, expect, it } from 'vitest'
+import Loader from '@cordisjs/plugin-loader'
+import * as toolLsp from '@deepseek-ai/dsh-tool-lsp'
+
+describe('dsh-tool-lsp real-load-path guard', () => {
+  it('has no default export and keeps name/inject/Config through unwrapExports', () => {
+    expect('default' in toolLsp).toBe(false)
+
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(toolLsp) as Record<string, unknown>
+    expect(unwrapped).toBe(toolLsp)
+    expect(unwrapped.name).toBe('tool-lsp')
+    expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt'])
+    expect(typeof unwrapped.apply).toBe('function')
+    expect(unwrapped.Config).toBeDefined()
+  })
+})

+ 125 - 0
packages/lsp/tool-lsp/tests/render.spec.ts

@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest'
+import { pathToFileURL } from 'node:url'
+import { join } from 'node:path'
+import {
+  DEFAULT_MAX_HOVER_CHARS,
+  DEFAULT_MAX_LOCATIONS,
+  formatHover,
+  formatLocations,
+  LSP_OPERATIONS,
+  parseLspArgs,
+  presentLspCall,
+  renderUri,
+} from '@deepseek-ai/dsh-tool-lsp'
+import type { LspLocation } from '@deepseek-ai/dsh-lsp'
+
+const WS = '/home/u/proj'
+
+function loc(uri: string, line: number, character = 0): LspLocation {
+  return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
+}
+
+describe('parseLspArgs', () => {
+  it('accepts the four operations and converts one-based to zero-based', () => {
+    for (const operation of LSP_OPERATIONS) {
+      const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 })
+      expect(input.operation).toBe(operation)
+      expect(input.position).toEqual({ line: 2, character: 4 })
+    }
+  })
+
+  it('rejects an unknown operation', () => {
+    expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }))
+      .toThrow(/operation must be one of/)
+  })
+
+  it('rejects a blank file_path', () => {
+    expect(() => parseLspArgs({ operation: 'hover', file_path: '   ', line: 1, character: 1 }))
+      .toThrow(/file_path/)
+  })
+
+  it('rejects non-positive or non-integer coordinates', () => {
+    expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/)
+    expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/)
+    expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/)
+  })
+})
+
+describe('renderUri', () => {
+  it('relativizes a file: URI inside the workspace with forward slashes', () => {
+    const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
+    expect(renderUri(uri, WS)).toBe('src/a.ts')
+  })
+
+  it('returns an absolute path for a file: URI outside the workspace', () => {
+    const uri = pathToFileURL('/other/lib/b.ts').href
+    expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
+  })
+
+  it('renders the workspace root itself as "."', () => {
+    expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
+  })
+
+  it('keeps a non-file URI verbatim', () => {
+    expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
+    expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
+  })
+
+  it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
+    // A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
+    expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
+  })
+})
+
+describe('formatLocations', () => {
+  it('renders a no-result line for an empty list', () => {
+    expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).toBe('No results.')
+  })
+
+  it('renders one-based path:line:character grouped by file', () => {
+    const a = pathToFileURL(join(WS, 'a.ts')).href
+    const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS)
+    expect(text).toBe('a.ts:1:1\na.ts:5:3')
+  })
+
+  it('caps at maxLocations and marks the omission', () => {
+    const a = pathToFileURL(join(WS, 'a.ts')).href
+    const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
+    const text = formatLocations(many, WS, 2)
+    expect(text).toContain('a.ts:1:1')
+    expect(text).toContain('3 more locations omitted (limit 2).')
+  })
+
+  it('uses the singular omission marker for exactly one extra', () => {
+    const a = pathToFileURL(join(WS, 'a.ts')).href
+    const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1)
+    expect(text).toContain('1 more location omitted (limit 1).')
+  })
+})
+
+describe('formatHover', () => {
+  it('renders a no-result line for null', () => {
+    expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.')
+  })
+
+  it('returns short hover verbatim', () => {
+    expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_HOVER_CHARS)).toBe('```ts\nx: number\n```')
+  })
+
+  it('caps hover at maxHoverChars and marks truncation', () => {
+    const text = formatHover({ contents: 'a'.repeat(50) }, 10)
+    expect(text.startsWith('aaaaaaaaaa\n')).toBe(true)
+    expect(text).toContain('hover truncated (limit 10 characters).')
+  })
+})
+
+describe('presentLspCall', () => {
+  it('is a generic search card with an operation/cursor title and a line location', () => {
+    expect(presentLspCall({ operation: 'references', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
+      card: 'generic',
+      kind: 'search',
+      title: 'LSP references a.ts:3:7',
+      locations: [{ path: 'a.ts', line: 3 }],
+    })
+  })
+})

+ 164 - 0
packages/lsp/tool-lsp/tests/tool-lsp.spec.ts

@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
+import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
+import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
+
+/** A scripted provider recording queries; `respond` yields the result or throws. */
+function stubProvider(
+  respond: (request: LspProviderQuery) => LspQueryResult,
+  extensionToLanguage: Record<string, string> = { '.ts': 'typescript' },
+): LspProvider & { seen: LspProviderQuery[] } {
+  const seen: LspProviderQuery[] = []
+  return {
+    id: LspProviderId('stub'),
+    extensionToLanguage,
+    seen,
+    query(request) {
+      seen.push(request)
+      return Promise.resolve(respond(request))
+    },
+  }
+}
+
+/** Mount the real tool stack over a real seam plus one stub provider. */
+async function mount(
+  provider?: LspProvider,
+  config: ToolLsp.Config = {},
+): Promise<{ ctx: Context }> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(Lsp)
+  if (provider) (ctx.lsp as Lsp).registerProvider(provider)
+  await ctx.plugin(ToolLsp, config)
+  return { ctx }
+}
+
+let seq = 0
+/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
+function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
+  return ctx.tools.execute({
+    callId: `c-${++seq}` as never,
+    name: 'lsp',
+    arguments: args,
+    ...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {},
+  })
+}
+
+const okLocations: LspQueryResult = {
+  kind: 'locations',
+  locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
+}
+
+describe('tool-lsp registration', () => {
+  it('registers the lsp tool and its prompt section', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    expect(ctx.tools.get('lsp')).toBeDefined()
+    const prompt = await ctx.systemPrompt.assemble()
+    const text = prompt.sections.map(s => s.text).join('\n')
+    expect(text).toContain(LSP_PROMPT_TEXT)
+  })
+
+  it('attaches the default timeout budget to the tool definition', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS)
+  })
+
+  it('honors a configured timeout override', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 })
+    expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000)
+  })
+
+  it('exposes exactly the four operations in the schema enum', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
+    expect(schema.properties.operation.enum).toEqual(['definition', 'references', 'implementation', 'hover'])
+  })
+
+  it('has no default export (namespace plugin shape)', () => {
+    expect((ToolLsp as { default?: unknown }).default).toBeUndefined()
+  })
+
+  it('rejects a non-positive config value at load', async () => {
+    await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
+  })
+})
+
+describe('tool-lsp execution', () => {
+  it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
+    const provider = stubProvider(() => okLocations)
+    const { ctx } = await mount(provider)
+    const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
+    expect(result.isError).toBe(false)
+    expect(provider.seen[0]).toMatchObject({
+      operation: 'definition',
+      filePath: 'a.ts',
+      position: { line: 2, character: 4 },
+      workspaceRoot: '/ws',
+    })
+  })
+
+  it('renders locations relative to the workspace', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    const result = await call(ctx, { operation: 'references', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
+    expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
+  })
+
+  it('renders hover content', async () => {
+    const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
+    const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
+    expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
+  })
+
+  it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, null)
+    expect(result.isError).toBe(true)
+    expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED')
+  })
+
+  it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
+    const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
+    expect(result.isError).toBe(true)
+    expect(result.error?.code).toBe('LSP_UNAVAILABLE')
+  })
+
+  it('returns a structured INVALID_ARGS on a bad operation', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
+    expect(result.isError).toBe(true)
+    expect(result.error?.code).toBe('INVALID_ARGS')
+  })
+
+  it('forwards exec.signal to the seam query', async () => {
+    const seen: (AbortSignal | undefined)[] = []
+    const provider: LspProvider = {
+      id: LspProviderId('sig'),
+      extensionToLanguage: { '.ts': 'typescript' },
+      query(_request, signal) {
+        seen.push(signal)
+        return Promise.resolve(okLocations)
+      },
+    }
+    const { ctx } = await mount(provider)
+    await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
+    // The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
+    // undefined); the point is the tool threads it through without throwing.
+    expect(seen).toHaveLength(1)
+  })
+
+  it('presentCall renders the pending card from args', async () => {
+    const { ctx } = await mount(stubProvider(() => okLocations))
+    const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 })
+    expect(view).toEqual({
+      card: 'generic',
+      kind: 'search',
+      title: 'LSP hover a.ts:2:3',
+      locations: [{ path: 'a.ts', line: 2 }],
+    })
+  })
+})

+ 33 - 0
packages/lsp/tool-lsp/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../lsp"
+    }
+  ]
+}

+ 146 - 1
pnpm-lock.yaml

@@ -724,6 +724,80 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/lsp/lsp:
+    devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
+  packages/lsp/lsp-local:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-lsp':
+        specifier: workspace:^
+        version: link:../lsp
+      '@deepseek-ai/dsh-timeout':
+        specifier: workspace:^
+        version: link:../../util/timeout
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      typescript:
+        specifier: ^6.0.3
+        version: 6.0.3
+      typescript-language-server:
+        specifier: ^5.0.0
+        version: 5.3.0
+
+  packages/lsp/tool-lsp:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-lsp':
+        specifier: workspace:^
+        version: link:../lsp
+      '@deepseek-ai/dsh-lsp-local':
+        specifier: workspace:^
+        version: link:../lsp-local
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-timeout-policy':
+        specifier: workspace:^
+        version: link:../../timeout/timeout-policy
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/mcp/mcp-client:
     dependencies:
       '@modelcontextprotocol/sdk':
@@ -1171,7 +1245,7 @@ importers:
     devDependencies:
       cordis:
         specifier: ^4.0.0-rc.6
-        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
   packages/support/subagent-mock:
     dependencies:
@@ -3612,6 +3686,18 @@ packages:
     resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
     engines: {node: '>= 0.6'}
 
+  cordis@4.0.0-rc.6:
+    resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==}
+    hasBin: true
+    peerDependencies:
+      '@cordisjs/plugin-include': ^1.0.4
+      '@cordisjs/plugin-loader': ^1.0.0-rc.4
+    peerDependenciesMeta:
+      '@cordisjs/plugin-include':
+        optional: true
+      '@cordisjs/plugin-loader':
+        optional: true
+
   cordis@4.0.0-rc.7:
     resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==}
     hasBin: true
@@ -5330,6 +5416,11 @@ packages:
       eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
       typescript: '>=4.8.4 <6.1.0'
 
+  typescript-language-server@5.3.0:
+    resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==}
+    engines: {node: '>=20'}
+    hasBin: true
+
   typescript@6.0.3:
     resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
     engines: {node: '>=14.17'}
@@ -5471,6 +5562,20 @@ packages:
       jsdom:
         optional: true
 
+  vscode-jsonrpc@5.0.1:
+    resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==}
+    engines: {node: '>=8.0.0 || >=10.0.0'}
+
+  vscode-jsonrpc@9.0.1:
+    resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==}
+    engines: {node: '>=14.0.0'}
+
+  vscode-languageserver-protocol@3.18.2:
+    resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==}
+
+  vscode-languageserver-types@3.18.0:
+    resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==}
+
   w3c-xmlserializer@5.0.0:
     resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
     engines: {node: '>=18'}
@@ -5876,6 +5981,14 @@ snapshots:
 
   '@chevrotain/types@11.1.2': {}
 
+  '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6)':
+    dependencies:
+      '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)
+      cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      cosmokit: 1.8.1
+      js-yaml: 4.2.0
+    optional: true
+
   '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)':
     dependencies:
       '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)
@@ -5891,6 +6004,14 @@ snapshots:
       js-yaml: 4.2.0
     optional: true
 
+  '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)':
+    dependencies:
+      cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      cosmokit: 1.8.1
+    optionalDependencies:
+      node-addon-require-builtin: 0.1.0
+    optional: true
+
   '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)':
     dependencies:
       cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
@@ -7037,6 +7158,14 @@ snapshots:
 
   cookie@0.7.2: {}
 
+  cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5):
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      cosmokit: 1.8.1
+    optionalDependencies:
+      '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6)
+      '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)
+
   cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5):
     dependencies:
       '@standard-schema/spec': 1.1.0
@@ -9038,6 +9167,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  typescript-language-server@5.3.0:
+    dependencies:
+      vscode-jsonrpc: 5.0.1
+      vscode-languageserver-protocol: 3.18.2
+
   typescript@6.0.3: {}
 
   unbash@3.0.0: {}
@@ -9182,6 +9316,17 @@ snapshots:
     transitivePeerDependencies:
       - msw
 
+  vscode-jsonrpc@5.0.1: {}
+
+  vscode-jsonrpc@9.0.1: {}
+
+  vscode-languageserver-protocol@3.18.2:
+    dependencies:
+      vscode-jsonrpc: 9.0.1
+      vscode-languageserver-types: 3.18.0
+
+  vscode-languageserver-types@3.18.0: {}
+
   w3c-xmlserializer@5.0.0:
     dependencies:
       xml-name-validator: 5.0.0

+ 16 - 0
scripts/gen-tool-catalog.ts

@@ -26,6 +26,8 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
+import Lsp from '@deepseek-ai/dsh-lsp'
+import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
 import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
 import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
 import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
@@ -147,6 +149,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
     note:
       'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
   },
+  {
+    pkg: '@deepseek-ai/dsh-tool-lsp',
+    dir: 'tool-lsp',
+    source: 'packages/lsp/tool-lsp/src/index.ts',
+    requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'],
+    writes: ['tool/call', 'tool/result'],
+    async mount(ctx) {
+      // The tool registers from the seam alone; the schema does not depend on any provider.
+      await ctx.plugin(Lsp)
+      await ctx.plugin(ToolLsp)
+    },
+    note:
+      'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.',
+  },
   {
     pkg: '@deepseek-ai/dsh-tool-skill',
     dir: 'tool-skill',

+ 2 - 0
scripts/verify-package-readme-model-experience.ts

@@ -47,6 +47,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
   'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
   'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
+  'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
+  'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
   'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
   'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
   'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },

+ 1 - 0
tsconfig.base.json

@@ -45,6 +45,7 @@
         "./packages/bash/*/src",
         "./packages/code-runtime/*/src",
         "./packages/fs/*/src",
+        "./packages/lsp/*/src",
         "./packages/skill/*/src",
         "./packages/compact/*/src",
         "./packages/context/*/src",

+ 4 - 1
tsconfig.build.json

@@ -83,6 +83,9 @@
     { "path": "./packages/hooks/hook-protocol" },
     { "path": "./packages/hooks/hooks-claude" },
     { "path": "./packages/hooks/hooks-codex" },
-    { "path": "./packages/mcp/mcp-client" }
+    { "path": "./packages/mcp/mcp-client" },
+    { "path": "./packages/lsp/lsp" },
+    { "path": "./packages/lsp/lsp-local" },
+    { "path": "./packages/lsp/tool-lsp" }
   ]
 }

+ 4 - 1
tsconfig.json

@@ -94,6 +94,9 @@
     { "path": "./packages/hooks/hook-protocol" },
     { "path": "./packages/hooks/hooks-claude" },
     { "path": "./packages/hooks/hooks-codex" },
-    { "path": "./packages/mcp/mcp-client" }
+    { "path": "./packages/mcp/mcp-client" },
+    { "path": "./packages/lsp/lsp" },
+    { "path": "./packages/lsp/lsp-local" },
+    { "path": "./packages/lsp/tool-lsp" }
   ]
 }