refactor(lsp): configure local servers together

This commit is contained in:
Dudu-0223
2026-07-16 17:31:20 +08:00
parent 0d8e7e98f7
commit a63c55eb00
13 changed files with 213 additions and 101 deletions

View File

@@ -5,7 +5,7 @@ The language-server capability seam: an abstract LSP interface, a generic stdio
| 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`) |
| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers 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.

View File

@@ -1,21 +1,23 @@
# @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.
A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `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.
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server 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 |
The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape:
| Server 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). |
@@ -28,7 +30,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
| `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.
`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query.
## Protocol behavior
@@ -46,4 +48,4 @@ Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized re
- **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)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks.
- **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.
- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal.

View File

@@ -1,10 +1,10 @@
/**
* 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.
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
* of server commands and registers one isolated provider for each entry. Every provider lazily
* single-flights one server process per canonical workspace realpath, serves transient-open queries
* through it, and evicts a crashed process so a later query can replace it. Providers read sources
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — 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.
@@ -55,10 +55,8 @@ 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
/** One configured local language server and its host bounds. */
export interface LspLocalServerConfig {
/** Executable to spawn (absolute, or resolved on PATH at load). */
command: string
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
@@ -83,11 +81,16 @@ export interface Config {
killGraceMs?: number
}
/** The resolved config after schemastery fills every default; the provider reads this shape. */
type ResolvedConfig = Required<Config>
/** Plugin configuration: provider id → local language-server configuration. */
export interface Config {
/** Non-empty table of stable provider ids to independent local server configurations. */
servers: Record<string, LspLocalServerConfig>
}
export const Config: z<Config> = z.object({
providerId: z.string().required(),
/** One server config after schemastery fills every default. */
type ResolvedServerConfig = Required<LspLocalServerConfig>
const LspLocalServerConfig: z<LspLocalServerConfig> = z.object({
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
@@ -101,43 +104,66 @@ export const Config: z<Config> = z.object({
killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS),
})
export const Config: z<Config> = z.object({
servers: z.dict(LspLocalServerConfig).required(),
})
/**
* 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.
* Register the configured stdio LSP providers. Resolves every executable at load (after credential
* scrubbing) before publishing any provider; each process launches lazily on its 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 entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server')
// Resolve every server-local setting before registration so a bad later command or bound cannot
// publish an earlier provider. Registry-level mapping conflicts are rolled back below.
const providers = entries.map(([providerId, rawConfig]) => {
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
const resolved = rawConfig as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const childEnv = buildChildEnv(resolved.env)
const executable = resolveExecutable(resolved.command, childEnv)
return new LocalLspProvider(providerId, resolved, childEnv, executable)
})
ctx.effect(() => {
const disposers: Array<() => void> = []
try {
for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider))
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return async () => {
// Remove every route before process teardown so no new query can enter a draining provider.
for (const dispose of disposers.reverse()) dispose()
await Promise.all(providers.map(provider => provider.disposeAll()))
}
}, 'lsp-local.registerProviders')
}
/** Validate one resolved server entry before any provider in the table is registered. */
function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void {
// Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a
// nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load.
assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveInteger('killGraceMs', resolved.killGraceMs)
assertPositiveInteger(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs)
// Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound
// (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad
// document cap fails later in the read path instead of at load.
assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes)
assertPositiveInteger('maxMessageBytes', resolved.maxMessageBytes)
assertPositiveInteger('maxDocumentBytes', resolved.maxDocumentBytes)
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')
assertPositiveInteger(providerId, 'maxStderrBytes', resolved.maxStderrBytes)
assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes)
assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes)
}
/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */
function assertPositiveInteger(name: string, value: number): void {
function assertPositiveInteger(providerId: string, name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`lsp-local: ${name} must be a positive integer`)
throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`)
}
}
@@ -150,11 +176,12 @@ class LocalLspProvider implements LspProvider {
private disposed = false
constructor(
private readonly config: ResolvedConfig,
providerId: string,
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record<string, string>,
private readonly executable: string,
) {
this.id = LspProviderId(config.providerId)
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
}

View File

@@ -46,11 +46,14 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
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' },
servers: {
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))

View File

@@ -8,7 +8,7 @@ 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'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -28,17 +28,23 @@ 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',
/** One fake stdio server entry with optional behavior and host-bound overrides. */
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
return {
command: process.execPath,
args: ['--import', tsxLoader, fixtureServer],
env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv },
extensionToLanguage: { '.ts': 'typescript' },
...overrides,
}
}
/** Mount the real seam + lsp-local plugin driving one fake server. */
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
servers: { fake: fakeServer(fakeEnv, overrides) },
})
return ctx
}
@@ -53,6 +59,24 @@ function locationJson(line: number): unknown {
}
describe('lsp-local end to end over a fake server', () => {
it('routes different extensions to independent configured servers', async () => {
await writeFile(join(ws, 'a.py'), 'x = 1\n')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
python: fakeServer(
{ LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) },
{ extensionToLanguage: { '.py': 'python' } },
),
},
})
expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } })
expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } })
await ctx.fiber.dispose()
})
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'))
@@ -245,10 +269,13 @@ describe('lsp-local end to end over a fake server', () => {
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' },
servers: {
missing: {
command: 'definitely-not-a-real-lsp-binary-xyz',
args: [],
extensionToLanguage: { '.ts': 'typescript' },
},
},
})).rejects.toThrow(/was not found on PATH/)
await ctx.fiber.dispose()
})

View File

@@ -5,6 +5,7 @@ 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'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
let root: string
let ws: string
@@ -24,6 +25,11 @@ function query(): LspQueryRequest {
return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
}
/** Wrap one server entry in the plugin's named server table. */
function config(providerId: string, server: LspLocalServerConfig): Config {
return { servers: { [providerId]: server } }
}
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.
@@ -35,26 +41,24 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
providerId: 'onpath',
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
env: { PATH: bin },
extensionToLanguage: { '.ts': 'typescript' },
})).resolves.toBeDefined()
}))).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',
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
env: { PATH: `::${join(root, 'empty')}` },
extensionToLanguage: { '.ts': 'typescript' },
})).rejects.toThrow(/was not found on PATH/)
}))).rejects.toThrow(/was not found on PATH/)
await ctx.fiber.dispose()
})
@@ -64,12 +68,11 @@ describe('lsp-local provider resolution', () => {
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',
const fiber = await ctx.plugin(LspLocal, config('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' }))
@@ -79,13 +82,12 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive teardown budget at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
providerId: 'bad-budget',
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
killGraceMs: 0,
})).rejects.toThrow(/killGraceMs must be a positive integer/)
}))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/)
await ctx.fiber.dispose()
})
@@ -94,24 +96,65 @@ describe('lsp-local provider resolution', () => {
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
providerId: 'abs-bad',
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
})).rejects.toThrow(/is not an executable file/)
}))).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
providerId: 'abs-directory',
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
})).rejects.toThrow(/is not an executable file/)
}))).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
it('rejects an empty server table at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
it('rejects an empty server id at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/server ids must be non-empty strings/)
await ctx.fiber.dispose()
})
it('resolves every executable before publishing any provider', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } },
},
})).rejects.toThrow(/was not found on PATH/)
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
await ctx.fiber.dispose()
})
it('rolls back earlier registrations when a later server conflicts', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
},
})).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
await ctx.fiber.dispose()
})
})

View File

@@ -53,10 +53,13 @@ beforeAll(async () => {
ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
providerId: 'typescript',
command: serverBin,
args: ['--stdio'],
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
servers: {
typescript: {
command: serverBin,
args: ['--stdio'],
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
},
},
})
}, 60_000)

View File

@@ -7,7 +7,7 @@ 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-lsp-local` | a generic local backend that registers configured stdio language-server providers |
| `@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`.

View File

@@ -52,12 +52,15 @@ async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
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,
servers: {
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 } : {})