Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	docs/config-catalog.md
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
This commit is contained in:
Tianyi Cui
2026-07-21 19:15:18 +08:00
140 changed files with 8516 additions and 426 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: 7265b04ac9b2f83764bdd13f07b2d3404c4c1708
2026-07-15-lsp-capability-seam.zh.md: 10e8956005045d0934dd9dada5718b85a34cda3f

View File

@@ -0,0 +1,198 @@
# Agent Note: LSP capability seam and model-facing query tool
Status: implemented
English | [中文](2026-07-15-lsp-capability-seam.zh.md)
## Problem
The harness has text search and file reads, but neither identifies a program symbol. A textual match cannot reliably distinguish two same-named functions, follow an import alias, connect an interface to its implementations, or report an inferred type. Before changing code, an agent therefore lacks the semantic navigation that a human gets from an editor's language server.
LSP support has three owners: the model needs a stable query schema, the harness needs provider selection and normalized results, and the local implementation needs process, JSON-RPC, workspace, synchronization, and filesystem behavior. Combining them would bind the model contract to local subprocesses and obstruct remote or sandbox-native providers.
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.
## Decision
Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation:
1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors.
2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping.
3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation.
`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays.
The model and seam expose exactly `goToDefinition`, `findReferences`, `goToImplementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. These operation literals match Claude Code's familiar camelCase names while the tool name and `file_path` field remain harness-owned.
The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.`
## Package and ownership boundaries
`dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities.
The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` selects and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` validates model arguments and passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting.
The intended contract shape is:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
type LspProviderId = Branded<'LspProviderId'>
interface LspPosition {
readonly line: number
readonly character: number
}
interface LspRange {
readonly start: LspPosition
readonly end: LspPosition
}
interface LspQueryRequest {
readonly operation: LspOperation
readonly filePath: string
readonly position: LspPosition
readonly workspaceRoot: string
}
interface LspProviderQuery extends LspQueryRequest {
readonly languageId: string
}
type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
interface LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
}
interface LspService {
registerProvider(provider: LspProvider): () => void
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
}
```
Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch.
`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.
## Model-facing contract
The single `lsp` tool accepts:
```ts
interface LspToolInput {
readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
readonly file_path: string
readonly line: number
readonly character: number
}
```
`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input.
The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace.
Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors.
ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure.
## Timeout ownership
`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable.
The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget.
Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work.
## Workspace, filesystem, and document synchronization
`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one `O_NOFOLLOW | O_NONBLOCK` handle through validation and reading, so a FIFO with no writer cannot block before the regular-file check. It observes caller cancellation around each filesystem operation. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers.
The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`.
1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs.
2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it.
3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request.
4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination.
Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-workspace provider queue serializes source-read/open/query/close lifecycles, so a waiting query reads current bytes only when its turn starts; the instance also keeps protocol lifecycles serialized. Distinct workspaces may run in parallel. The server's workspace index remains responsible for closed files reached from the source.
The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider.
## Local server lifecycle and protocol behavior
`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; server process launch stays lazy (first query spawns it) and uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter.
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. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits.
Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Positions must be nonnegative integers. Hover normalization accepts only valid `MarkupContent` and `MarkedString` shapes, preserves string values, renders language-tagged values as fenced code, and joins arrays with one blank line. The model-facing tool applies `maxResultChars` after rendering.
Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence.
## Deliberately deferred surface
Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation.
Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration.
The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider.
## Alternatives considered
**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries.
**Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers.
**Expose arbitrary LSP methods.** A JSON-RPC escape hatch would leak protocol payloads and admit unreviewed mutation or command execution; the operation union stays closed.
**Expose `resolve(request)` / `query(spec)`.** With no defaulted fields, resolution would only expose provider selection, and a public spec could outlive provider disposal or replacement. One operation keeps selection and invocation atomic to the registration lifetime.
**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it.
**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess.
**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine.
**Configure phase timeouts.** Nested timers create competing classifications and fresh budgets. One caller-owned deadline covers query work; only out-of-call teardown keeps local bounds.
**Query without `didOpen`.** Although permitted, support is inconsistent and may use stale server state. Transient open supplies an explicit current snapshot.
**Add routes or select the first match.** Registration order and HMR timing are not product semantics, while a route table duplicates unique extension ownership. Overlaps therefore fail registration.
**Run concurrent queries in one instance.** If cancellation fails, terminating the shared process would kill unrelated work. Per-instance serialization limits that blast radius; instances remain parallel.
**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.
## 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.
- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors.
- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`.
- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection.
- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown.
- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal.
- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event.
- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping.
- 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.
## 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.
Transient opens repeat parsing and notifications. Per-instance serialization increases latency under parallel agents, and long-lived workspace processes consume memory until disposal.
Extension ownership is exclusive within one runtime. Two providers cannot both claim `.ts`, even with different language ids; this is a conscious MVP limit. The intended extension is a deployment-configured selector above registrations that can relax exclusive reservations without adding provider choice to model input or changing `LspProvider.query`.
UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use.
Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee.

View File

@@ -0,0 +1,198 @@
# Agent Note: LSP 能力服务边界与面向模型的查询工具
Status: implemented
[English](2026-07-15-lsp-capability-seam.md) | 中文
## 问题
harness 已具备文本搜索与文件读取能力但二者都无法识别程序符号。文本匹配无法可靠地区分同名函数、跟踪导入别名、关联接口与具体实现也无法报告推断类型。因此agent智能体在修改代码前缺少人类通过编辑器语言服务器获得的语义导航能力。
语言服务器协议Language Server ProtocolLSP支持分属三个职责方模型需要稳定的查询 schemaharness 需要提供方选择与规范化结果本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程并阻碍远程或沙箱原生提供方。
许多语言服务器在查询文档已按当前文本打开时表现最佳。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。
## 决策
将 LSP 建成由三个包package组成的能力服务边界其中包含一个只读模型工具和一个通用本地提供方实现
1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。
2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。
3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化以及 ACPAgent Client Protocol展示。
`dsh-lsp-local` 是通用 host不是语言服务器目录或安装器。部署显式配置命令与映射未来 preset 属于组合插件或 `cordis.yml` overlay。
模型与服务边界仅公开 `goToDefinition``findReferences``goToImplementation``hover``ctx.lsp` 不提供任意 JSON-RPC 方法。这些操作字面量与 Claude Code 熟悉的 camelCase 命名一致,而工具名与 `file_path` 字段仍由 harness 自行定义。
提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.`
## 包与职责边界
`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。
服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。
预期契约如下:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
type LspProviderId = Branded<'LspProviderId'>
interface LspPosition {
readonly line: number
readonly character: number
}
interface LspRange {
readonly start: LspPosition
readonly end: LspPosition
}
interface LspQueryRequest {
readonly operation: LspOperation
readonly filePath: string
readonly position: LspPosition
readonly workspaceRoot: string
}
interface LspProviderQuery extends LspQueryRequest {
readonly languageId: string
}
type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
interface LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
}
interface LspService {
registerProvider(provider: LspProvider): () => void
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
}
```
映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制也不提供通用请求逃生口。
`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换它依赖 `dsh-lsp` 与 Node API不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置如果后续映射无效或发生冲突插件会撤销此前的注册并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools``lsp``systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
## 面向模型的契约
单一 `lsp` 工具接受以下参数:
```ts
interface LspToolInput {
readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
readonly file_path: string
readonly line: number
readonly character: number
}
```
`line``character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`findReferences` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。
工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。
位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }``title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character跟随位置聚焦输入行标题保留完整光标展示保持纯函数。
## 超时归属
`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000``dsh-timeout-policy` 执行预算并提供传入 `ctx.lsp.query``exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。
服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`
提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()``timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。
## 工作区、文件系统与文档同步
`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件并在校验与读取期间保持同一个 `O_NOFOLLOW | O_NONBLOCK` 句柄,因此没有写入方的 FIFO 不会在普通文件校验前造成阻塞。它在每项文件系统操作前后检查调用方是否取消。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
`read` 工具的输出带窗口与行号,进入 transcript文本记录且已被观察不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。
本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync``Full``Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。
1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。
2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消写入失败或遭取消会使实例失效并等待有界进程终止完成池才能复用它。
3. 发送所请求的 `textDocument/definition``textDocument/references``textDocument/implementation``textDocument/hover` 请求。
4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。
每次调用后都关闭文档,因此第一版不需要 `didChange``didSave`、内容缓存、变更监听器或文档 LRU。每个工作区的提供方队列可取消并串行执行源文件读取、打开、查询和关闭的完整生命周期因此等待中的查询只在轮到它时才读取当前字节实例也会串行执行协议生命周期。不同工作区可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。
规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity符号链接别名因此共享实例。结果位置可以在工作区外但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。
## 本地服务器生命周期与协议行为
`dsh-lsp-local``(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000``maxStderrBytes` 默认值为 `1_000_000``maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。
初始化声明 `general.positionEncodings: ['utf-16']``workspace: { workspaceFolders: true, configuration: true }``textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。
导航结果直接映射 `Location`,并将 `LocationLink``targetUri``targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent``MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`
取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。
## 明确延后的接口
符号操作因需要不同 schema 且与读取或搜索重叠而延后;未来的工作区符号工具必须接收搜索词。调用层级因支持度不一而延后,`prepareCallHierarchy` 仍是内部准备步骤,不是模型操作。
诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。
本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区并写入私有缓存与临时目录的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。
## 备选方案
**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。
**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一契约。
**公开任意 LSP 方法。** JSON-RPC 逃生口会泄露协议载荷,并允许未经评审的变更或命令执行;操作联合保持封闭。
**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。
**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。
**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。
**配置分阶段超时。** 嵌套定时器会产生相互竞争的分类与新预算。一个由调用方负责的截止时间覆盖查询;只有调用外清理保留本地限制。
**不发送 `didOpen`。** 协议虽允许,但支持不一致且可能使用陈旧服务器状态。临时打开提供明确的当前快照。
**增加路由或选择首个匹配项。** 注册顺序与 HMR 时机不是产品语义,路由表又会重复唯一扩展名所有权。因此,扩展名重叠时注册失败。
**在一个实例中并发查询。** 取消失败时,终止共享进程会杀死无关工作。实例内串行可限制影响范围;不同实例仍可并行。
**内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。
## 测试
- 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。
- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。
- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。
- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink``hover` 归一化,以及 `findReferences``references.includeDeclaration` 的映射。
- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。
- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。
- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。
- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`
- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。
- 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。
- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
## 影响
各语言服务器对方法支持、能力解释和索引就绪时机的处理不同LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。
同一运行时内的扩展名所有权互斥。即使 language id 不同,两个提供方也不能同时占用 `.ts`;这是有意接受的 MVP 限制。预期扩展方式是在注册之上增加由部署配置的 selector允许放宽互斥占用同时不向模型输入增加提供方选择也不改变 `LspProvider.query`
UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。
直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。

View File

@@ -20,7 +20,11 @@ The client advertises NO optional capabilities (no `fs`, no `terminal`): the chi
### No start-time capabilities
The provider's `capabilities` are all `false`. An out-of-process child cannot honor the parent's `maxDepth` (it has no access to `parent.options.subagentDepth`) or `toolFilter` (it owns its own tool registry), and the first cut does not implement `outputSchema`. The service rejects a request needing any of them before `start` runs. The backend injects only `subagents` (not `ctx.agents`) and ignores `request.parent`.
The provider's `capabilities` are all `false`. An out-of-process child cannot honor the parent's `maxDepth` (it has no access to `parent.options.subagentDepth`) or `toolFilter` (it owns its own tool registry), and the first cut does not implement `outputSchema`. The service rejects a request needing any of them before `start` runs. The backend injects only `subagents` (not `ctx.agents`); the ONE thing it reads off `request.parent` is the session header's cwd (see the workspace resolution below) — no conversation context, depth, or tool state crosses the process boundary.
### Workspace cwd resolution
The child's working directory is an explicit resolution, never the harness process cwd: the deployment `cwd` override when configured (made absolute against the launch directory and validated at load), else the parent session header's cwd (validated at start), and a loud rejection before anything spawns when neither exists. One ACP server process serves sessions from many workspaces, so `process.cwd()` cannot stand in for a session's workspace — the old implicit fallback ran children in the server's launch directory. A candidate must be an absolute path naming a directory the harness can ENTER (`X_OK``statSync().isDirectory()` alone accepts a mode-600 directory that spawn would fail with EACCES), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace.
### StopReason mapping
@@ -33,6 +37,7 @@ The child is a separate process, so it inherits an environment. Credential-shape
## Testing
- **Keyless unit/integration:** A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports.
- **Keyless Loader composition:** A test-only cordis.yml boots the stdio app through the real Loader with the backend's `cwd` omitted; a scripted model delegates once and the scripted child proves it ran in — and was announced — the parent session's workspace (the cwd-inheritance branch end to end).
- **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file.
- **Snapshot gap:** Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while `TODO(acp-subagent-replay)` tracks parent replay against a replaying child.

View File

@@ -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-17-dedicated-full-screen-tui-front-door.md: 8fbc5dddc029190b346075a65c9e7857187f3d2b
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6ddc3523b7a7173013efe2ef15c5ca0e940929fb
2026-07-17-dedicated-full-screen-tui-front-door.md: 3e2b1e751001020eccc9193438daff23dd42518a
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5f3632f43702e16ca9dec07c0bb8e42bf50499b7

View File

@@ -20,9 +20,11 @@ The selected front door receives the exact generated or resumed `SessionId` used
### Session projection and interaction
The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Pending chunks and tool calls update the same components that completed events settle.
The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle.
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The plugin registers the shared `userInteraction` provider and presents questions as queued keyboard overlays; agent behavior and answer logging remain owned by their existing services.
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and pairs the selected model with its reasoning state; during a run, elapsed activity and the Escape interrupt hint replace that summary. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services.
The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
### Terminal ownership
@@ -39,6 +41,7 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t
- **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit.
- **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud.
- **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition.
- **Mutate `agent.options` when `/model` runs** — rejected because creation options do not provide an atomic boundary between asynchronous prompt assembly and request routing. Agent-scoped waterfalls preserve immutable creation input and snapshot the selected pair for each step.
## Consequences
@@ -46,3 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t
- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol.
- Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor.
- Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI.
- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state.

View File

@@ -20,9 +20,11 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README
### 会话投影与交互
TUI 从活跃的 `session.surface` 重建 transcript文本记录并在事件携带 `surfaceOp` 时重新投影因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall``presentResult` 方法生成的工具卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。
TUI 从活跃的 `session.surface` 重建 transcript文本记录并在事件携带 `surfaceOp` 时重新投影因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall``presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。插件注册共享的 `userInteraction` 提供方,以排队的键盘浮层呈现问题agent 行为和答案日志仍由既有服务负责。
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率并将选中模型及其推理状态组合显示agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述agent 行为和答案日志仍由既有服务负责。
`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall瀑布式事件会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
### 终端所有权
@@ -39,6 +41,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调
- **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。
- **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。
- **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。
- **在 `/model` 运行时修改 `agent.options`**:不予采纳,因为创建选项无法在异步 prompt 组装与请求路由之间提供原子边界。agent 作用域内的 waterfall 会在保持创建输入不可变的同时,为每个 step 快照一次选中的字段组合。
## 后果
@@ -46,3 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调
- TUI 会引入 pi-tui 依赖并严格要求 TTY非 TTY 部署使用 Headless app 或结构化协议。
- 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。
- 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。
- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。

View File

@@ -16,6 +16,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
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/ language-server seam + local stdio provider + model-facing 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

View File

@@ -30,6 +30,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services
| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home |
| `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) | semantic navigation registry |
| `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`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction; optional model-free result pruning |

View File

@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:253`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`
@@ -617,6 +617,46 @@ export interface Config {
Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts)
## `@deepseek-ai/dsh-lsp-local`
Requires: `lsp`
```ts config-catalog
/** 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>
}
/** 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' }`). */
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
/** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */
killGraceMs?: number
}
```
Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts)
## `@deepseek-ai/dsh-mcp-client`
Requires: `tools`
@@ -939,8 +979,11 @@ export interface Config {
/** Arguments passed to {@link command}. */
args: string[]
/**
* Working directory for the child process and its ACP session. Defaults to
* the parent process's cwd when omitted.
* Working directory override for the child process and its ACP session.
* Must be non-empty; a relative path resolves against the harness launch
* directory at load, and the result must be an existing directory. When
* omitted, each child inherits its delegating parent session's cwd — and
* starting one from a parent session that has no cwd fails.
*/
cwd?: string
/**
@@ -970,7 +1013,7 @@ export interface Config {
export type PermissionPolicy = 'allow' | 'reject'
```
Source: [`packages/subagent/subagent-acp/src/index.ts:18`](../packages/subagent/subagent-acp/src/index.ts)
Source: [`packages/subagent/subagent-acp/src/index.ts:21`](../packages/subagent/subagent-acp/src/index.ts)
## `@deepseek-ai/dsh-subagent-fork`
@@ -1139,6 +1182,24 @@ export interface Config {
Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/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 complete rendered result in characters, including truncation metadata (default 16000). */
maxResultChars?: number
/** Tool-call timeout budget in ms (default 60000). */
timeoutMs?: number
}
```
Source: [`packages/lsp/tool-lsp/src/index.ts:58`](../packages/lsp/tool-lsp/src/index.ts)
## `@deepseek-ai/dsh-tool-ralph`
Requires: `tools` · `workflows` · `subagents` · `systemPrompt`
@@ -1307,7 +1368,7 @@ Source: [`packages/core/tools/src/index.ts:467`](../packages/core/tools/src/inde
## `@deepseek-ai/dsh-tui`
Requires: `agents` · `commands` · `userInteraction` · `tools`
Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter`
```ts config-catalog
/** Serializable plugin configuration. */
@@ -1322,14 +1383,20 @@ export interface Config extends TuiConfig {
export interface TuiConfig {
/** Render model reasoning blocks. */
showReasoning?: boolean
/** Maximum tool-output lines shown before the card is collapsed. */
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
maxToolOutputLines?: number
/** Maximum options visible at once in a user-question dialog. */
/** Maximum options visible at once in a user-question panel. */
maxQuestionOptions?: number
/** User-question dialog width in terminal columns. */
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question dialog maximum height in terminal rows. */
/** User-question panel maximum height in terminal rows. */
questionDialogMaxHeight?: number
/** Model-selector width in terminal columns. */
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
showHardwareCursor?: boolean
/** Apply the built-in ANSI color palette. */
@@ -1339,7 +1406,7 @@ export interface TuiConfig {
}
```
Source: [`packages/ui/tui/src/index.ts:103`](../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:127`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`
@@ -1588,6 +1655,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/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-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))

View File

@@ -216,7 +216,7 @@ roots(): Agent[]
Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md)
Source: [`packages/core/agent/src/index.ts:223`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:224`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService`

View File

@@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` |
| [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |

View File

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

View File

@@ -49,7 +49,10 @@ interface SubagentStartRequest {
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
* and the parent's delegation depth. The out-of-process backend (ACP) reads
* exactly one field — the session header's cwd, the child's workspace when
* no deployment `cwd` override is configured; nothing else crosses the
* process boundary.
*/
readonly parent: Agent
/**

View File

@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:220`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
@@ -39,7 +39,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:135`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |

View File

@@ -139,6 +139,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
@@ -177,6 +182,8 @@ flowchart TD
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
pkg_web --> pkg_llm
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_llm
pkg_sandbox --> pkg_llm
pkg_token_meter --> pkg_llm
pkg_token_meter --> pkg_session
@@ -204,6 +211,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_sandbox_policy --> pkg_sandbox
@@ -390,6 +401,11 @@ flowchart TD
pkg_workspace_context --> pkg_tools
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_timeout
pkg_tool_lsp --> pkg_tools
pkg_mcp_client --> pkg_llm
pkg_mcp_client --> pkg_tools
pkg_tool_tasks --> pkg_agent
@@ -436,6 +452,8 @@ flowchart TD
pkg_tui --> pkg_llm
pkg_tui --> pkg_llm_retry
pkg_tui --> pkg_session
pkg_tui --> pkg_system_prompt
pkg_tui --> pkg_token_meter
pkg_tui --> pkg_tools
pkg_tui --> pkg_user_interaction
pkg_agent_spine_demo --> pkg_agent
@@ -534,6 +552,7 @@ flowchart TD
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`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) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`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) |
@@ -548,6 +567,7 @@ flowchart TD
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`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) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
@@ -593,6 +613,7 @@ flowchart TD
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`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), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`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) |
@@ -601,7 +622,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

View File

@@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@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-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
| `@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-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@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/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
@@ -489,6 +490,52 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.
## `@deepseek-ai/dsh-tool-lsp`
### `lsp`
Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.
```json
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "goToDefinition, findReferences, goToImplementation, or hover.",
"enum": [
"goToDefinition",
"findReferences",
"goToImplementation",
"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-ralph`
### `ralph`

View File

@@ -31,6 +31,7 @@ const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cor
const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
switch (value) {
@@ -68,6 +69,7 @@ const SCENARIOS: Scenario[] = [
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
{ name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
{ name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
{ name: 'workspace-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-read', hasModelTurn: true, recorded: true },
{ name: 'fs-write', hasModelTurn: true, recorded: true },

View File

@@ -0,0 +1,41 @@
# Test-only composition: the ACP subagent backend on the real Loader/app path.
# The scripted model delegates once; the scripted mock ACP child (MOCK_ECHO_CWD)
# echoes its process cwd and announced session cwd, so parent-session cwd
# inheritance is asserted keylessly end to end. `cwd` is deliberately omitted —
# the inheritance branch under test. The child command path is machine-absolute,
# so the driving e2e supplies it via DSH_TEST_MOCK_ACP_SERVER.
- id: mock-llm
name: './mock-delegating-llm.ts'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-acp
name: '@deepseek-ai/dsh-subagent-acp'
config:
providerName: acp
command: !!js process.execPath
args:
- !!js process.env.DSH_TEST_MOCK_ACP_SERVER
permission: reject
env:
MOCK_ECHO_CWD: '1'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: acp
toolName: subagent
# ACP advertises no depthLimit: the child harness owns its own recursion
# budget, so the local numeric default cannot apply here.
maxDepth: 'provider-managed'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: mock
model: mock-delegate
persona: 'Test ACP subagent cwd inheritance.'
persistenceRoot: './.sessions'
persistenceCompression: 'none'
workspaceContext: false

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
/** Test driver: one delegation turn through a headless Loader composition. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('acp-subagent cwd driver requires a config path')
const ctx = await boot('acp-subagent-cwd-e2e', resolveConfigPath(configPath, undefined))
try {
await runOneShot(ctx, { task: 'delegate' })
} finally {
await ctx.fiber.dispose()
}

View File

@@ -0,0 +1,48 @@
import type { Context } from 'cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
/**
* Test adapter for the `mock-delegate` model: the first request calls the
* `subagent` tool once, and the follow-up streams the tool result text back
* verbatim — so the ACP child's answer (the scripted mock server's cwd echo)
* reaches the REPL stdout for the driving e2e to assert.
*/
class MockDelegatingAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const toolResultText = options.messages.at(-1)?.content
.filter(block => block.type === 'tool-result')
.flatMap(block => block.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.join('') ?? ''
if (toolResultText.length === 0) {
const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' })
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args }
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } }
yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
const reply = `child reported:\n${toolResultText}`
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: reply }
yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'mock-llm'
export const inject = ['llm']
/**
* Register the delegating mock adapter under the `mock` provider.
* @param ctx - the plugin context supplying `ctx.llm`.
*/
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['mock'], new MockDelegatingAdapter())
}

View File

@@ -0,0 +1,29 @@
# Keyless replay keeps the LSP composition intact and replaces only the model adapter.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ../cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- insert:
- id: lsp
name: '@deepseek-ai/dsh-lsp'
- id: lsp-local
name: '@deepseek-ai/dsh-lsp-local'
config:
servers:
fixture:
command: !!js process.execPath
args: ['./lsp-server.mjs']
extensionToLanguage:
'.ts': typescript
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: tool-lsp
name: '@deepseek-ai/dsh-tool-lsp'
config:
maxLocations: 1
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'

View File

@@ -0,0 +1,25 @@
# Exercise the model-facing LSP tool through the shipped ACP app and Loader entry path.
# The scenario workspace supplies the deterministic stdio server used by this test composition.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ../cordis.yml
patches:
- insert:
- id: lsp
name: '@deepseek-ai/dsh-lsp'
- id: lsp-local
name: '@deepseek-ai/dsh-lsp-local'
config:
servers:
fixture:
command: !!js process.execPath
args: ['./lsp-server.mjs']
extensionToLanguage:
'.ts': typescript
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: tool-lsp
name: '@deepseek-ai/dsh-tool-lsp'
config:
maxLocations: 1

View File

@@ -10,7 +10,7 @@
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2ef0a5f14624/b5e2b8c5e6a6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-766ddb6deb99/b7b4bdfcf7aa-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}

View File

@@ -131,8 +131,8 @@
{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","outcome":"allowed-once"}}
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","outcome":"allowed-once"}}
{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"}
{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}}

View File

@@ -155,8 +155,8 @@
{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"}
{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}}
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","outcome":"rejected"}}
{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"}
{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}}

View File

@@ -55,8 +55,8 @@
{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}
{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}}
{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}}
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}}
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","outcome":"rejected"}}
{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"}
{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}}

View File

@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE." }
]
}

View File

@@ -0,0 +1,23 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,7 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP goToDefinition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1,27 @@
You are an AI agent powered by the DeepSeek Harness SDK.
You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.
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. findReferences always includes the declaration.
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.

View File

@@ -0,0 +1,510 @@
{
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
},
"workdir": {
"type": "string",
"description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
},
"run_in_background": {
"type": "boolean",
"description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
}
},
"required": [
"command",
"description"
]
}
},
{
"name": "create_goal",
"description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.",
"parameters": {
"type": "object",
"properties": {
"objective": {
"type": "string",
"description": "The concrete completion objective inferred from the direct human request."
},
"max_goal_rounds": {
"type": "number",
"description": "Optional positive safe-integer limit on automatic continuation rounds."
}
},
"required": [
"objective"
]
}
},
{
"name": "edit",
"description": "Edit an existing UTF-8 text file by replacing literal text.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to edit, resolved by the filesystem backend."
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
},
"new_string": {
"type": "string",
"description": "Literal replacement text. Use an empty string to delete the match."
},
"replace_all": {
"type": "boolean",
"description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
}
},
"required": [
"file_path",
"old_string",
"new_string"
]
}
},
{
"name": "get_goal",
"description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "lsp",
"description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "goToDefinition, findReferences, goToImplementation, or hover.",
"enum": [
"goToDefinition",
"findReferences",
"goToImplementation",
"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"
]
}
},
{
"name": "ralph",
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
"objective": {
"type": "string",
"description": "The immutable completion objective for every fresh Ralph round."
},
"maxRounds": {
"type": "number",
"description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
}
},
"required": [
"objective"
]
}
},
{
"name": "read",
"description": "Read a UTF-8 text file and return line-numbered content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to read, resolved by the filesystem backend."
},
"offset": {
"type": "number",
"description": "1-based first line to return. Defaults to 1."
},
"limit": {
"type": "number",
"description": "Maximum number of lines to return. Defaults to 2000."
}
},
"required": [
"file_path"
]
}
},
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The exact skill name from the available skills list."
}
},
"required": [
"name"
]
}
},
{
"name": "subagent",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background task and return its id; collect with task_output or stop with task_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "subagent_fork",
"description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background task and return its id; collect with task_output or stop with task_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "task_kill",
"description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.",
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Task id returned by the tool that started the background work."
},
"reason": {
"type": "string",
"description": "Optional short reason, recorded in the log and forwarded to the task."
}
},
"required": [
"task_id"
]
}
},
{
"name": "task_list",
"description": "List your background tasks (running and finished) with their ids, kinds, and statuses.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "task_output",
"description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.",
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Task id returned by the tool that started the background work."
},
"wait": {
"type": "boolean",
"description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
}
},
"required": [
"task_id"
]
}
},
{
"name": "todo_write",
"description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).",
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "The COMPLETE task list, replacing any previous list.",
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"content": {
"type": "string",
"description": "What the task is — a short imperative line."
},
"status": {
"type": "string",
"description": "pending (not started) | in_progress (now) | completed (done).",
"enum": [
"pending",
"in_progress",
"completed"
]
}
},
"required": [
"content",
"status"
]
}
}
},
"required": [
"todos"
]
}
},
{
"name": "update_goal",
"description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.",
"parameters": {
"type": "object",
"properties": {
"goal_id": {
"type": "string",
"description": "Exact id returned by get_goal."
},
"revision": {
"type": "number",
"description": "Exact positive revision returned by get_goal."
},
"action": {
"type": "string",
"description": "edit | pause | resume | complete | blocked",
"enum": [
"edit",
"pause",
"resume",
"complete",
"blocked"
]
},
"objective": {
"type": "string",
"description": "Replacement objective; valid only with action edit."
},
"max_goal_rounds": {
"type": "number",
"description": "Replacement cap; valid only with action edit."
},
"blocked_reason": {
"type": "string",
"description": "Concrete blocking condition; required only with action blocked."
}
},
"required": [
"goal_id",
"revision",
"action"
]
}
},
{
"name": "workflow",
"description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.",
"parameters": {
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"description": "Short kebab-case workflow name."
},
"description": {
"type": "string",
"description": "One-line description of what the workflow does."
},
"whenToUse": {
"type": "string",
"description": "Optional guidance on when this workflow applies."
},
"phases": {
"type": "array",
"description": "Optional phase declarations matched by phase() calls.",
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"title": {
"type": "string",
"description": "The phase title phase() calls match by exact string."
},
"detail": {
"type": "string",
"description": "Optional one-line description of the phase."
},
"provider": {
"type": "string",
"description": "Optional provider override this phase is expected to use."
},
"model": {
"type": "string",
"description": "Optional model override this phase is expected to use."
}
},
"required": [
"title"
]
}
}
},
"required": [
"name",
"description"
]
},
"args": {
"type": "object",
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
"additionalProperties": true
}
},
"required": [
"script",
"meta"
]
}
},
{
"name": "write",
"description": "Create or fully replace a UTF-8 text file.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to write, resolved by the filesystem backend."
},
"content": {
"type": "string",
"description": "Full UTF-8 text content to write."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
}
},
"required": [
"file_path",
"content"
]
}
}
],
"changes": []
}

View File

@@ -0,0 +1,57 @@
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
let buffered = Buffer.alloc(0)
function frame(message) {
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }))
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body])
}
function location(line) {
return {
uri: pathToFileURL(resolve('subject.ts')).href,
range: { start: { line, character: 6 }, end: { line, character: 12 } },
}
}
function handle(message) {
switch (message.method) {
case 'initialize':
process.stdout.write(frame({
id: message.id,
result: {
capabilities: {
positionEncoding: 'utf-16',
textDocumentSync: 1,
definitionProvider: true,
},
},
}))
break
case 'textDocument/definition':
process.stdout.write(frame({ id: message.id, result: [location(0), location(1)] }))
break
case 'shutdown':
process.stdout.write(frame({ id: message.id, result: null }))
break
case 'exit':
process.exit(0)
}
}
process.stdin.on('data', (chunk) => {
buffered = Buffer.concat([buffered, chunk])
for (;;) {
const headerEnd = buffered.indexOf('\r\n\r\n')
if (headerEnd < 0) return
const match = /Content-Length: (\d+)/i.exec(buffered.toString('ascii', 0, headerEnd))
if (match === null) throw new Error('missing Content-Length')
const length = Number(match[1])
const bodyStart = headerEnd + 4
if (buffered.length < bodyStart + length) return
const message = JSON.parse(buffered.toString('utf8', bodyStart, bodyStart + length))
buffered = buffered.subarray(bodyStart + length)
handle(message)
}
})

View File

@@ -0,0 +1,2 @@
export const answer = 42
console.log(answer)

View File

@@ -20,6 +20,8 @@ flowchart LR
cfg --> plugin_cordis_web
plugin_cordis_web_fetch_local["web-fetch-local<br/>@deepseek-ai/dsh-web-fetch-local"]
cfg --> plugin_cordis_web_fetch_local
plugin_cordis_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
cfg --> plugin_cordis_token_meter
plugin_cordis_tui_agent["tui-agent<br/>@deepseek-ai/dsh-tui-demo"]
cfg --> plugin_cordis_tui_agent
plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
@@ -41,6 +43,7 @@ flowchart LR
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` |

View File

@@ -44,6 +44,9 @@
- id: web-fetch-local
name: '@deepseek-ai/dsh-web-fetch-local'
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
# The app bundle pre-creates the self-referential demo's `main` agent.
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'

View File

@@ -27,6 +27,8 @@
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-llm-deepseek": "workspace:*",
"@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/dsh-lsp": "workspace:*",
"@deepseek-ai/dsh-lsp-local": "workspace:*",
"@deepseek-ai/dsh-permission": "workspace:*",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
@@ -36,6 +38,7 @@
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-tui-demo": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",
@@ -45,6 +48,7 @@
"@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tool-fs-search": "workspace:*",
"@deepseek-ai/dsh-tool-goal": "workspace:*",
"@deepseek-ai/dsh-tool-lsp": "workspace:*",
"@deepseek-ai/dsh-tool-ralph": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*",

View File

@@ -10,7 +10,7 @@ pnpm run demo:tui
The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
Run `pnpm run demo:code-mode tui` for the Code Mode overlay.

View File

@@ -19,7 +19,7 @@
welcome: 'TUI Code Mode ready. Give it a multi-tool task.'
ui:
showReasoning: true
maxToolOutputLines: 12
maxToolOutputLines: 6
persona: |
You are a coding agent powered by the {{model}} model.

View File

@@ -31,7 +31,7 @@
welcome: 'TUI agent ready. Give it a coding task.'
ui:
showReasoning: true
maxToolOutputLines: 12
maxToolOutputLines: 6
persona: |
You are a coding agent powered by the {{model}} model.

View File

@@ -1,5 +1,5 @@
import type { Context } from 'cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1'
@@ -18,7 +18,17 @@ function textChunks(text: string): StreamChunk[] {
/** Keyless two-step adapter for the real-PTY TUI conversation test. */
class ScriptedTuiAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve([
{ provider, id: 'tui-scripted-model', name: 'Scripted Base' },
{ provider, id: 'tui-scripted-model-pro', name: 'Scripted Pro' },
])
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) {
throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables')
}
const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false
if (hasToolResult) {
for (const chunk of textChunks(FINAL_TEXT)) yield chunk

View File

@@ -12,6 +12,9 @@
config:
cwd: !!js process.cwd()
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
@@ -21,5 +24,6 @@
workspaceContext:
maxBytes: 65536
welcome: 'scripted TUI ready.'
persona: 'Scripted model {{model}}.'
ui:
showReasoning: true

View File

@@ -67,7 +67,7 @@ buffer
style 1-1 inverse
28| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
29| "/workspace/project ↑3.0k ↓115 idle reasoning:on tools:compact"
style 0-58 dim
style 67-99 dim
29| "/tmp/dsh-tui-snapshot-bash-te ↑3.0k ↓115 3% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-28 dim
style 42-99 dim
30-35| <blank>

View File

@@ -73,7 +73,7 @@ buffer
style 1-1 inverse
30| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
31| "/workspace/project ↑3.1k ↓158 idle reasoning:on tools:compact"
style 0-49 dim
style 67-99 dim
31| "/tmp/dsh-tui-snapshot-code-mo ↑3.1k ↓158 3% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-28 dim
style 42-99 dim
32-35| <blank>

View File

@@ -111,6 +111,6 @@ buffer
style 1-1 inverse
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
49| "/workspace/project ↑18 ↓18 idle reasoning:on tools:compact"
style 0-61 dim
style 67-99 dim
49| "/tmp/dsh-tui-snapshot-cordis-dyn ↑18 ↓18 8% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-31 dim
style 42-99 dim

View File

@@ -101,6 +101,6 @@ buffer
style 1-1 inverse
45| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
46| "/workspace/project ↑3.5k ↓227 idle reasoning:on tools:compact"
style 0-56 dim
style 67-99 dim
46| "/tmp/dsh-tui-snapshot-dynamic ↑3.5k ↓227 3% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-28 dim
style 42-99 dim

View File

@@ -64,7 +64,7 @@ buffer
style 1-1 inverse
29| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
30| "/workspace/project ↑2.9k ↓41 idle reasoning:on tools:compact"
style 0-62 dim
style 67-99 dim
30| "/tmp/dsh-tui-snapshot-multi-tu ↑2.9k ↓41 3% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-29 dim
style 42-99 dim
31-35| <blank>

View File

@@ -86,6 +86,6 @@ buffer
style 1-1 inverse
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
38| "/workspace/project ↑20 ↓6 idle reasoning:on tools:compact"
style 0-55 dim
style 67-99 dim
38| "/tmp/dsh-tui-snapshot-parallel-fi ↑20 ↓6 3% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-32 dim
style 42-99 dim

View File

@@ -76,6 +76,6 @@ buffer
style 1-1 inverse
34| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
35| "/workspace/project ↑3.1k ↓145 idle reasoning:on tools:compact"
style 0-49 dim
style 67-99 dim
35| "/tmp/dsh-tui-snapshot-todo-pl ↑3.1k ↓145 3% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-28 dim
style 42-99 dim

View File

@@ -24,7 +24,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => {
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
const output = await runTuiPtySmoke({
label: 'tui-agent conversation',
tempDirPrefix: 'tui-agent-conversation-',
@@ -32,7 +32,9 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
configPath: scriptedConfigPath,
tsconfigPath,
actions: [
{ waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' },
{ waitFor: 'scripted TUI ready.', send: '/model\r' },
{ waitFor: 'Select model', send: '\x1b[B\r' },
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
],

View File

@@ -15,6 +15,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -170,6 +171,7 @@ async function mountScenarioContext(
tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
await ctx.plugin(TokenMeterService)
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(FsPolicy)

View File

@@ -13,7 +13,10 @@
"headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
"headless-agent/tests/fixtures/time-context-driver.ts",
"headless-agent/tests/fixtures/time-context-mock-llm.ts",
"acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts",
"tui-agent/tests/fixtures/tui-scripted-llm.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts",
"*/tests/**/*.e2e.ts",
"*/tests/**/*.snapshot.ts"
],
@@ -42,6 +45,11 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/lsp/lsp-local": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["typescript-language-server"]
},
"packages/sandbox/sandbox-local": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]

View File

@@ -15,6 +15,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`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, the model-facing file tools, and the bash-backed discovery 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) | Model-visible request context, including workspace instructions and time context | Product — stable surface |

View File

@@ -8,7 +8,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.

View File

@@ -15,6 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export * from './llm-target.ts'
export { agentEvents, assembleContextFor } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'

View File

@@ -0,0 +1,66 @@
/**
* Agent-scoped provider/model target snapshot shared by interactive front doors.
* @module @deepseek-ai/dsh-agent/llm-target
*/
import type { Context } from 'cordis'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
/** Complete provider/model route selected for one live agent. */
export interface AgentLlmTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
}
/** Mutable selection plus the target captured for the current step. */
export interface AgentLlmTargetRef {
/** Target selected for the next step that enters prompt assembly. */
current: AgentLlmTarget | undefined
/** Target captured when the current step entered prompt assembly. */
assembled: AgentLlmTarget | undefined
}
/**
* Couple one mutable target to agent-scoped prompt assembly and request routing.
* Prompt assembly snapshots the selected pair before delegating, then applies
* both prompt variables and request config to that snapshot so a concurrent
* switch takes effect on a later step instead of splitting the two surfaces.
*
* @param agentCtx - The target agent's scoped context.
* @param target - Mutable selection owned by the calling front door.
* @returns Disposer for both scoped waterfall listeners.
*/
export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void {
const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = target.current
const assembled = await next()
target.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
const disposeRequest = agentCtx.on(
'agent/request',
async (_agent, _turn, _step, _config, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
},
)
return () => {
disposeAssembly()
disposeRequest()
}
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import {
agentEvents,
installAgentLlmTarget,
type Agent,
type AgentLlmTargetRef,
} from '../src/index.ts'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
describe('installAgentLlmTarget()', () => {
it('snapshots prompt variables and request routing together, then disposes both listeners', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const target: AgentLlmTargetRef = { current: undefined, assembled: undefined }
const dispose = installAgentLlmTarget(ctx, target)
const agent = {} as Agent
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = { provider: 'alpha', model: 'a1' }
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 2, 0, seed, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await ctx.fiber.dispose()
})
})

View File

@@ -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', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', '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
packages/lsp/README.md Normal file
View File

@@ -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 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 — `goToDefinition`, `findReferences`, `goToImplementation`, `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 Agent Note](../../.agents/notes/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.

View File

@@ -0,0 +1,55 @@
# @deepseek-ai/dsh-lsp-local
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
- 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`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces 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
The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape:
| Server key | Default | Meaning |
|---|---|---|
| `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` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. |
`servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. 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
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 valid `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. Missing results, malformed ranges or positions, and malformed hover encodings fail as structured `LSP_MALFORMED_RESPONSE` errors.
## 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.
#### KV Cache effect
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## 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 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) 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-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

@@ -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 goToDefinition/findReferences/goToImplementation/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"
}
}

View File

@@ -0,0 +1,48 @@
/**
* Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases.
* @module @deepseek-ai/dsh-lsp-local/abort
*/
import { timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
* Build an abort Error carrying the signal's reason and preserving timeout classification.
* @param signal - the aborted signal whose reason to surface.
* @returns the timeout reason if present, else the Error reason, else a generic aborted Error.
*/
export 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')
}
/**
* Throw the signal's classified abort error when it has already fired.
* @param signal - the optional query cancellation signal.
*/
export function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw abortError(signal)
}
/**
* Await work while allowing a query signal to abandon its wait; the underlying work keeps its own
* handlers and continues to its owner-defined quiescence boundary.
* @param work - the owned asynchronous work.
* @param signal - optional query cancellation.
* @returns the work result, or a rejection carrying the classified abort reason.
*/
export function abortable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return work
if (signal.aborted) return Promise.reject(abortError(signal))
const canceled = Promise.withResolvers<never>()
const onAbort = (): void => { canceled.reject(abortError(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
const normalized = work.catch((error: unknown) => {
/* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */
throw error instanceof Error ? error : new Error(String(error))
})
return Promise.race([normalized, canceled.promise])
.finally(() => { signal.removeEventListener('abort', onAbort) })
}

View File

@@ -0,0 +1,331 @@
/**
* 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 { setImmediate as yieldToEventLoop } from 'node:timers/promises'
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 = Buffer.alloc(0)
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)
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
this.child = spawn(spec.command, [...spec.args], {
cwd: spec.cwd,
env: spec.env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
})
this.closed = new Promise<void>((resolve) => {
this.child.on('close', () => {
const reason = this.closeReason ?? new Error(this.exitMessage())
// 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) })
// Child stdin can fail while the process itself remains alive (for example, a server closes fd
// 0). Treat that as a fatal connection error so pending requests reject immediately instead of
// waiting for a process-close event that may never arrive.
this.child.stdin.on('error', (error) => { this.fail(error) })
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.toString('utf8')
}
/**
* 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 })
// `write()` records either synchronous or callback-delivered failures on the connection and
// rejects every pending request. This handler only consumes the write promise itself.
void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {})
})
// 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.
* @returns a promise that settles when the framed notification has been written.
*/
notify(method: string, params: unknown): Promise<void> {
return 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 {
// The server is already gone or unwritable when this rejects; `write()` has recorded the fatal
// connection failure and rejected the pending request, so cancellation remains best-effort.
void this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }).catch(() => {})
}
/**
* 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 server's process group (idempotent-safe; a dead group ignores it). */
terminate(): void {
this.signalGroup('SIGTERM')
}
/** Send SIGKILL to the server's process group. */
kill(): void {
this.signalGroup('SIGKILL')
}
/**
* Wait until the owned process group has no members.
* @param signal - optional bound for the wait.
* @returns `true` when the group exited, or `false` when the signal aborted first.
*/
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
while (this.processGroupAlive()) {
if (signal?.aborted) return false
await yieldToEventLoop()
}
return true
}
/**
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
* direct child if the group send fails. Never throws — teardown races process exit.
*/
private signalGroup(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return
try {
process.kill(-pid, sig)
} catch {
// The group is gone (already exited) or could not be signalled; try the direct child.
try {
this.child.kill(sig)
} catch {
// Already dead; nothing to signal.
}
}
}
/** Whether the detached process group still has at least one member. */
private processGroupAlive(): boolean {
const pid = this.child.pid
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
if (pid === undefined) return false
try {
process.kill(-pid, 0)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
/* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes
whether lifecycle tests observe this branch platform-dependent. */
if (code === 'ESRCH') return false
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */
if (code === 'EPERM') return true
return this.child.exitCode === null && this.child.signalCode === null
/* v8 ignore stop */
}
}
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 and
// SIGKILL the whole group so helper processes don't outlive the leader.
this.fail(asError(error))
this.signalGroup('SIGKILL')
return
}
for (const message of messages) this.dispatch(message)
}
private onStderr(chunk: Buffer): void {
// Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
// before it exits, so the final bounded segment is the useful one.
const cap = this.spec.maxStderrBytes
if (chunk.length >= cap) {
// Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
return
}
const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
this.stderr = Buffer.concat([
this.stderr.subarray(this.stderr.length - retainedBytes),
chunk,
], retainedBytes + chunk.length)
}
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')) {
// A response-write failure has already invalidated the connection in `write()`.
/* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection
failure makes this consumption handler run. */
void this.handleServerRequest(id, method, frame.params).catch(() => {})
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)
await this.write({ jsonrpc: '2.0', id, result })
} catch (error) {
await 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): Promise<void> {
if (this.closeReason !== undefined) return Promise.reject(this.closeReason)
return new Promise<void>((resolve, reject) => {
const done = (error?: Error | null): void => {
if (error === undefined || error === null) {
resolve()
return
}
this.fail(error)
reject(error)
}
try {
this.child.stdin.write(encodeMessage(message), done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {
const failure = asError(error)
this.fail(failure)
reject(failure)
}
/* v8 ignore stop */
})
}
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */
private exitMessage(): string {
const tail = this.stderrTail.trim()
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
}
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))
}

View File

@@ -0,0 +1,102 @@
/**
* 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 }
}
if (separator > MAX_HEADER_BYTES) {
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`)
}
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)}`)
}

View File

@@ -0,0 +1,154 @@
/**
* 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 { constants } from 'node:fs'
import { open, realpath, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
import { throwIfAborted } from './abort.ts'
/** 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).
* @param signal - optional cancellation observed around each filesystem operation.
* @returns the canonical directory path.
* @throws Error when the path is missing or not a directory.
*/
export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise<string> {
throwIfAborted(signal)
let canonical: string
try {
canonical = await realpath(workspaceRoot)
} catch (error) {
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
}
throwIfAborted(signal)
const info = await stat(canonical)
throwIfAborted(signal)
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.
* @param signal - optional cancellation observed throughout resolution, validation, and reading.
* @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,
signal?: AbortSignal,
): Promise<HostSource> {
throwIfAborted(signal)
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)}`)
}
throwIfAborted(signal)
if (!isInside(canonicalWorkspace, canonicalPath)) {
throw new Error(`source "${filePath}" resolves outside the workspace`)
}
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
// actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a
// symlink between realpath and open (which would otherwise escape the workspace).
// O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular.
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
try {
throwIfAborted(signal)
const info = await handle.stat()
throwIfAborted(signal)
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`)
}
// Bound the read to the cap even if the file grew after stat: read one extra byte and reject on
// overflow, so a concurrent grow cannot defeat the memory bound.
const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal)
const text = decodeUtf8Strict(buffer, filePath)
throwIfAborted(signal)
return { canonicalPath, text }
} finally {
await handle.close()
}
}
/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */
async function readCapped(
handle: FileHandle,
maxBytes: number,
filePath: string,
signal?: AbortSignal,
): Promise<Buffer> {
const limit = maxBytes + 1
const chunk = Buffer.allocUnsafe(limit)
let total = 0
for (;;) {
throwIfAborted(signal)
const { bytesRead } = await handle.read(chunk, total, limit - total, total)
throwIfAborted(signal)
if (bytesRead === 0) break
total += bytesRead
/* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */
if (total > maxBytes) {
throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`)
}
}
return chunk.subarray(0, total)
}
/** 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 strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch {
throw new Error(`source "${filePath}" is not valid UTF-8 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)
}

View File

@@ -0,0 +1,336 @@
/**
* 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.
* @module @deepseek-ai/dsh-lsp-local
*/
import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, isAbsolute, join } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
import type {
LspProvider,
LspProviderQuery,
LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { canonicalizeWorkspace, readHostSource } 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
/** 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' }`). */
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
/** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */
killGraceMs?: number
}
/** 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>
}
/** 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({}),
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().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
})
export const Config: z<Config> = z.object({
servers: z.dict(LspLocalServerConfig).required(),
})
/**
* 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 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.
assertTimer(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertTimer(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(providerId, 'maxStderrBytes', resolved.maxStderrBytes)
assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes)
assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes)
}
/** Reject a timer value Node would clamp instead of scheduling as configured. */
function assertTimer(providerId: string, name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */
function assertPositiveInteger(providerId: string, name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`)
}
}
/** 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>>
/** One live instance per canonical workspace realpath. */
private readonly instances = new Map<string, LspInstance>()
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
private readonly queues = new Map<string, Promise<void>>()
private disposed = false
constructor(
providerId: string,
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record<string, string>,
private readonly executable: string,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
}
/** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */
private isDisposed(): boolean {
return this.disposed
}
/** Reject work that cannot publish or use a provider-owned instance. */
private assertActive(signal?: AbortSignal): void {
/* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls
exercise the post-await check instead. */
if (this.isDisposed()) throw new LspError('lsp-local provider is disposed', 'LSP_DISPOSED')
if (signal?.aborted) throw abortError(signal)
}
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
// Honor an already-aborted signal before host I/O so a canceled request never starts a server.
this.assertActive(signal)
const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
this.assertActive(signal)
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
// its turn starts, while an invalid source still cannot leave an idle process pooled.
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal)
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
let instance = this.instanceFor(workspace)
if (instance.dead) {
this.evictIfCurrent(workspace, instance)
instance = this.instanceFor(workspace)
}
try {
return await instance.query(request, source, signal)
} finally {
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
if (instance.dead) this.evictIfCurrent(workspace, instance)
}
})
}
/** Serialize one complete query lifecycle for a canonical workspace. */
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
const previous = this.queues.get(workspace) ?? Promise.resolve()
const result = abortable(previous, signal).then(run)
// The tail follows the actual prior work even when this caller aborts its wait. It never rejects,
// so later callers serialize without inheriting an earlier query's outcome.
const tail = previous.then(() => result).then(() => undefined, () => undefined)
this.queues.set(workspace, tail)
void tail.then(() => {
if (this.queues.get(workspace) === tail) this.queues.delete(workspace)
})
return result
}
/** Return or synchronously publish the one instance for a canonical workspace. */
private instanceFor(workspace: string): LspInstance {
this.assertActive()
const existing = this.instances.get(workspace)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspace, created)
return created
}
/** Drop the slot iff it still contains this instance. */
private evictIfCurrent(workspace: string, instance: LspInstance): void {
/* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
}
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,
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 live = [...this.instances.values()]
const draining = [...this.queues.values()]
this.instances.clear()
await Promise.all([
...live.map(instance => instance.dispose()),
...draining,
])
this.queues.clear()
}
}
/** 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)) {
// Verify an absolute command too, so an unavailable one fails at load, not on the first query.
if (!isExecutableFileSync(command)) {
throw new Error(`lsp-local: command "${command}" is not an executable file`)
}
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 (isExecutableFileSync(candidate)) return candidate
}
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
}
/** Synchronous regular-file and executable check used only at load-time resolution. */
function isExecutableFileSync(path: string): boolean {
try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,334 @@
/**
* 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 { LspError } from '@deepseek-ai/dsh-lsp'
import type {
LspOperation,
LspProviderQuery,
LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec } from './connection.ts'
import type { HostSource } 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
/** 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
/** The one teardown transaction shared by abort, failure, and explicit disposal. */
private teardownPromise: Promise<void> | undefined
/** 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 source - the pre-validated, already-read host source (the provider reads before spawning).
* @param signal - optional cancellation for this query's full lifecycle.
* @returns the normalized result.
*/
query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
// rather than block on the shared tail forever.
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
// on the wait does not deserialize the queue.
this.queue = this.queue.then(() => 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
await this.connection.notify('initialized', {})
}
private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED')
/* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */
if (signal?.aborted) throw abortError(signal)
// Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends
// in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8
// negotiation, malformed result) without the process exiting — tear the instance down so a
// permanently-rejecting/pending `ready` can't make every later query for this workspace fail.
try {
await abortable(this.ready, signal)
} catch (error) {
if (!this.dead) {
await this.startTeardown()
}
throw error
}
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 LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION')
}
if (!supportsTransientOpen(capabilities.textDocumentSync)) {
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
}
const uri = pathToFileURL(source.canonicalPath).href
let opened = false
try {
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
if (signal?.aborted) throw abortError(signal)
try {
await abortable(this.connection.notify('textDocument/didOpen', {
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
}), signal)
} catch (error) {
// A canceled backpressured write or failed stdin leaves the protocol stream unusable before
// `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance.
await this.startTeardown()
throw error
}
opened = true
const payload = await this.sendRequest(request.operation, uri, request.position, signal)
return this.normalize(request.operation, payload)
} finally {
// A disposed or closed instance (e.g. an aborted request whose server ignored
// `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let
// the next queued query's document lifecycle overlap the still-active request.
if (opened && !this.dead) {
try {
await this.connection.notify('textDocument/didClose', { textDocument: { uri } })
} catch {
// 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.
try {
await this.startTeardown()
} catch {
/* v8 ignore next -- teardown owns all expected process races; this only preserves the
already-settled query outcome if an unexpected cleanup primitive itself rejects. */
}
}
}
}
}
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 },
// findReferences always includes declarations: the caller gets no flag and impact analysis
// never omits the defining site.
...(operation === 'findReferences' ? { 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 give the server a
* bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the
* instance so the still-active request cannot overlap the next queued query's document lifecycle.
*/
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
try {
return await abortable(send, signal)
} catch (error) {
if (!signal.aborted) throw error
this.connection.cancel(requestId)
// Wait, bounded, for the server to honor the cancellation. If it does not, the request is still
// running: terminate the instance (disposal awaits process close) so nothing outlives the query.
const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE')
try {
// `settled` is true if the request finished (either outcome) before the grace elapsed.
const settled = await Promise.race([
send.then(markSettled, markSettled),
new Promise<boolean>((resolve) => {
/* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */
if (grace.signal.aborted) { resolve(false); return }
grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
])
if (!settled) await this.startTeardown()
} finally {
grace[Symbol.dispose]()
}
throw error
}
}
private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
// display paths against, not the request's possibly-symlinked workspaceRoot.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
}
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> {
await this.startTeardown()
}
/** Publish disposal once and make every caller await the same quiescence boundary. */
private startTeardown(): Promise<void> {
this.disposed = true
this.teardownPromise ??= this.tearDown()
return this.teardownPromise
}
private async tearDown(): Promise<void> {
const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
try {
await this.gracefulShutdown(shutdownDeadline.signal)
} catch {
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
} finally {
shutdownDeadline[Symbol.dispose]()
}
await this.forceTerminate()
}
/** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
await abortable(this.connection.request('shutdown', null), signal)
await this.connection.notify('exit', null)
await abortable(this.connection.closed, signal)
}
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
private async forceTerminate(): Promise<void> {
this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let groupExited: boolean
try {
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
} finally {
graceDeadline[Symbol.dispose]()
}
if (!groupExited) this.connection.kill()
await Promise.all([
this.connection.closed,
this.connection.waitForProcessGroupExit(),
])
}
}
/** 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',
])
/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
function markSettled(): boolean {
return true
}
/**
* 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

View File

@@ -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
}

View File

@@ -0,0 +1,235 @@
/**
* 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 { LspError } from '@deepseek-ai/dsh-lsp'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {
WireHover,
WireLocation,
WireLocationLink,
WireMarkedString,
WireProviderCapability,
WireRange,
WireServerCapabilities,
WireTextDocumentSyncKind,
} 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 'goToDefinition': return 'textDocument/definition'
case 'findReferences': return 'textDocument/references'
case 'goToImplementation': 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 'goToDefinition': return capabilities.definitionProvider
case 'findReferences': return capabilities.referencesProvider
case 'goToImplementation': 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.
* The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an
* explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false.
* @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
}
/** 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
}
/**
* 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 isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character)
}
/** Whether a wire coordinate is a valid nonnegative integer. */
function isProtocolCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
/**
* 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) return []
if (payload === undefined) throw malformedResponse('LSP navigation result was missing')
const elements = Array.isArray(payload) ? payload : [payload]
const locations: LspLocation[] = []
for (const element of elements) {
if (element === null || typeof element !== 'object') {
throw malformedResponse('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 malformedResponse('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. The model-facing tool owns the complete result cap.
* @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) return null
if (payload === undefined) throw malformedResponse('LSP hover result was missing')
if (typeof payload !== 'object') throw malformedResponse('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
if (range === undefined) return { contents }
if (!isRange(range)) throw malformedResponse('LSP hover result contained a malformed range')
return { contents, range: toRange(range) }
}
/** 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 malformedResponse('LSP hover result had no contents')
}
if (typeof contents === 'string') return contents
if (Array.isArray(contents)) {
return contents.map((value) => {
if (isMarkedString(value)) return renderMarkedString(value)
throw malformedResponse('LSP hover contents contained a malformed MarkedString')
}).join('\n\n')
}
if (typeof contents !== 'object') {
throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array')
}
const record = contents as Record<string, unknown>
if (record.kind === 'markdown' || record.kind === 'plaintext') {
if (typeof record.value !== 'string') {
throw malformedResponse('LSP hover MarkupContent value was not a string')
}
return record.value
}
if (typeof record.language === 'string' && typeof record.value === 'string') {
return renderMarkedString({ language: record.language, value: record.value })
}
throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array')
}
/** Whether an untrusted value is either form of `MarkedString`. */
function isMarkedString(value: unknown): value is WireMarkedString {
if (typeof value === 'string') return true
if (value === null || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return typeof record.language === 'string' && typeof record.value === 'string'
}
/** Create the stable structured error used for malformed server result payloads. */
function malformedResponse(message: string): LspError {
return new LspError(message, 'LSP_MALFORMED_RESPONSE')
}

View File

@@ -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 fixtureServer = fileURLToPath(new URL('./fixture-server.ts', 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, {
servers: {
fake: {
command: ${JSON.stringify(process.execPath)},
args: [${JSON.stringify(fixtureServer)}],
env: { LSP_FAKE_DEF: ${JSON.stringify(location)} },
extensionToLanguage: { '.ts': 'typescript' },
},
},
})
const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
console.log(JSON.stringify(result))
await ctx.fiber.dispose()
`
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)
})

View File

@@ -0,0 +1,240 @@
import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', 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: [fixtureServer],
cwd: process.cwd(),
env: { ...process.env as Record<string, string>, ...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: {} })
await 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: {} })
await 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: {} })
await 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('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
const conn = connectScript('process.stderr.write("😀😀")', 4)
await conn.closed
expect(conn.stderrTail).toBe('😀')
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
})
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('rejects a pending request when child stdin closes but the process stays alive', async () => {
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
await new Promise<void>(resolve => setTimeout(resolve, 100))
const timeout = new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
})
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
})
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))
}
}

View File

@@ -0,0 +1,207 @@
/**
* 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_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
* simulating a server that dies while idle so the pool holds a dead instance (eviction test).
* - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - 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 fixture-server.ts (Node's erasable TypeScript syntax support).
*/
import { appendFileSync, closeSync } from 'node:fs'
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 exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
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>()
process.on('SIGTERM', () => {
markExit('TERM')
process.exit(0)
})
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') {
markExit('EXIT')
if (exitDelayMs > 0) {
setTimeout(() => {
markExit('CLEAN')
process.exit(0)
}, exitDelayMs)
return
}
markExit('CLEAN')
process.exit(0)
}
if (method === 'textDocument/didOpen') {
if (crashOnOpen) process.exit(1)
if (openMarker !== undefined) {
const params = message.params as { textDocument?: { text?: unknown } } | undefined
appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
}
if (onOpen !== undefined) emitServerRequest(onOpen)
return
}
if (method === 'initialized') {
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
if (pauseStdinAfterInitialized) process.stdin.pause()
if (closeStdinAfterInitialized) closeSync(0)
return
}
if (method === 'textDocument/didClose') return
if (method?.startsWith('textDocument/')) {
if (hang) return
const reply = (): void => {
if (closeStdinAfterReply) closeSync(0)
if (errorReply) {
send({ id, error: { code: -32000, message: 'server refused the request' } })
} else {
send({ id, result: resultFor(method) })
}
// Simulate an idle death: answer this request, then exit before the next one arrives so the
// pool is left holding a dead instance.
if (exitAfterReply) setTimeout(() => process.exit(0), 20)
}
if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
else reply()
return
}
// Unknown request with an id: answer null so the client never stalls.
if (id !== undefined) send({ id, result: null })
}
/** Append one teardown event when the fixture is configured to expose process ordering. */
function markExit(event: string): void {
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
}
/** 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()
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
setInterval(() => {}, 1000)
}

View File

@@ -0,0 +1,82 @@
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 an oversized header block that includes its terminator', () => {
const decoder = new MessageDecoder(1_000)
const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii')
expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/)
})
it('rejects a non-JSON body', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
})
})

View File

@@ -0,0 +1,131 @@
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 { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
const execFileAsync = promisify(execFile)
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('rejects a FIFO with no writer without blocking in open', async () => {
const fifo = join(ws, 'pipe.ts')
await execFileAsync('mkfifo', [fifo])
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/)
})
it('honors a pre-aborted source read before filesystem work', async () => {
const controller = new AbortController()
controller.abort(new Error('source read cancelled'))
await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
})
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/)
})
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
// byte sequences are rejected).
await writeFile(join(ws, 'repl.ts'), 'const s = "<22>"\n')
const source = await readHostSource('repl.ts', ws, BIG)
expect(source.text).toBe('const s = "<22>"\n')
})
})

View File

@@ -0,0 +1,338 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, 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, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', 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: [fixtureServer],
cwd: ws,
env: { ...process.env as Record<string, string>, ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
})
live.push(instance)
return instance
}
function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery {
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
}
/** Run a query against an instance, reading the source first the way the provider does. */
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
const source = await readHostSource('a.ts', ws, 4_000_000)
return instance.query(query(operation), source, signal)
}
/** 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,
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(run(instance, 'goToDefinition')).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(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
it('rejects a workspace/applyEdit request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
it('rejects an unknown server request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
})
describe('LspInstance query and abort', () => {
it('sends includeDeclaration for references', async () => {
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
await expect(run(instance, 'findReferences')).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(run(instance, 'goToDefinition', 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 = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
})
it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => {
// The hang server never honors cancellation, so after the bounded grace the instance must be torn
// down (its process closed) rather than left with an active request.
const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
expect(instance.dead).toBe(true)
})
it('resolves the cancel grace when the server honors $/cancelRequest', async () => {
// A server that answers $/cancelRequest by settling the pending request lets the grace race
// resolve via the request rather than the timeout, so the instance is NOT force-terminated.
const script = 'let b=Buffer.alloc(0),reqId=null;'
+ '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")reqId=m.id;'
+ 'else if(m.method==="$/cancelRequest"&&reqId!==null)process.stdout.write(fr({id:reqId,error:{code:-32800,message:"request cancelled"}}));'
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
+ 'else if(m.method==="exit")process.exit(0);'
+ '}});'
const instance = scriptInstance(script, { killGraceMs: 2_000 })
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
// The server acknowledged cancellation within grace, so the instance was not force-killed.
expect(instance.dead).toBe(false)
await instance.dispose()
})
it('observes abort while awaiting a slow initialize handshake', async () => {
// A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be
// observed during that wait instead of hanging the tool-timeout signal.
const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 150))
controller.abort(new Error('handshake-abort'))
await expect(pending).rejects.toThrow(/handshake-abort/)
await instance.dispose()
})
it('terminates when abort interrupts a backpressured didOpen write', async () => {
// The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
// keeps didOpen's write callback pending until cancellation forces bounded process teardown.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const marker = join(root, 'initialized.log')
const instance = makeInstance({
LSP_FAKE_INITIALIZED_MARKER: marker,
LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await waitForFile(marker)
// Let the client enter the large didOpen write after the fixture has paused stdin.
await new Promise<void>(resolve => setTimeout(resolve, 100))
controller.abort(new Error('didOpen-abort'))
await expect(pending).rejects.toThrow(/didOpen-abort/)
expect(instance.dead).toBe(true)
})
it('terminates when stdin fails during the didOpen write', async () => {
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
// the instance must still become dead so its provider can replace it.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
expect(instance.dead).toBe(true)
})
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(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
})
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(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/)
})
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
resolvedWorkspaceRoot: ws,
})
expect(instance.dead).toBe(true)
})
})
describe('LspInstance disposal', () => {
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_DELAY_MS: '75',
LSP_FAKE_EXIT_MARKER: marker,
}, { shutdownTimeoutMs: 500 })
await run(instance, 'goToDefinition')
await instance.dispose()
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
})
it('is idempotent — a second dispose awaits close without error', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'goToDefinition')
await instance.dispose()
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('rejects a query after disposal', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'goToDefinition')
await instance.dispose()
await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' }))
})
it('reports dead after the process closes', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'goToDefinition')
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 run(instance, 'goToDefinition')
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
const marker = join(root, 'helper.pid')
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
+ `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});`
+ `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
+ RESPONDING_SERVER
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await run(instance, 'goToDefinition')
const helperPid = Number(await readFile(marker, 'utf8'))
try {
const first = instance.dispose()
await instance.dispose()
expect(processAlive(helperPid)).toBe(false)
await first
} finally {
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
}
})
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 = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 200))
controller.abort('a string reason, not an Error')
await expect(pending).rejects.toThrow(/aborted/)
})
})
/** Probe a pid without changing its state. */
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
throw error
}
}
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
const started = Date.now()
for (;;) {
try {
await readFile(path)
return
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}

View File

@@ -0,0 +1,319 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, 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 { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', 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 })
})
/** 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: [fixtureServer],
env: { ...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
}
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('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('goToDefinition'))
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 } } }],
resolvedWorkspaceRoot: ws,
})
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('goToImplementation'))
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('findReferences'))
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('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
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('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
await ctx.fiber.dispose()
})
it('does not pool a poisoned instance when initialize rejects', async () => {
// A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a
// permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it.
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
// A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one.
await expect(ctx.lsp.query(query('goToDefinition'))).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('goToDefinition'))).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('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
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('goToDefinition'), 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('goToDefinition')),
ctx.lsp.query(query('goToDefinition')),
ctx.lsp.query(query('goToDefinition')),
])
for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
it('reads a queued query source only when its lifecycle starts', async () => {
const marker = join(root, 'opened.jsonl')
const ctx = await mount({
LSP_FAKE_DEF: 'null',
LSP_FAKE_REPLY_DELAY_MS: '300',
LSP_FAKE_OPEN_MARKER: marker,
})
const first = ctx.lsp.query(query('goToDefinition'))
await waitFor(async () => (await markerLines(marker)).length === 1)
const second = ctx.lsp.query(query('goToDefinition'))
await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
await Promise.all([first, second])
expect(await markerLines(marker)).toEqual([
'const x = 1\nconst y = x\n',
'const changed = 2\n',
])
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('goToDefinition'), controller.signal)
controller.abort(new Error('caller cancelled'))
await expect(pending).rejects.toThrow(/cancelled/)
await ctx.fiber.dispose()
})
it('honors an already-aborted signal before any host I/O or startup', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
controller.abort(new Error('pre-aborted'))
await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
await ctx.fiber.dispose()
})
it('surfaces the server stderr tail in the exit error', async () => {
// A server that writes to stderr then exits without answering: the query rejection carries the
// retained stderr tail so the failure is diagnosable.
const ctx = await mount({}, {
command: process.execPath,
args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
})
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/)
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('goToDefinition'), 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('goToDefinition'))).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('goToDefinition'))).rejects.toThrow()
await ctx.fiber.dispose()
})
it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
// The first query succeeds, then the server exits before the second arrives, leaving a dead
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first.
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
await new Promise(resolve => setTimeout(resolve, 60))
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
it('does not spawn a server when the signal aborts during source read', async () => {
// Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
controller.abort(new Error('mid-read cancel'))
await expect(pending).rejects.toThrow(/mid-read cancel/)
// A subsequent live query still works, proving no half-created instance poisoned the pool.
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
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('goToDefinition'), workspaceRoot: ws }),
ctx.lsp.query({ ...query('goToDefinition'), 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('goToDefinition'))
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, {
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()
})
})
/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */
async function markerLines(path: string): Promise<string[]> {
try {
const text = await readFile(path, 'utf8')
return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}
/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */
async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
const started = Date.now()
while (!await condition()) {
if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}

View File

@@ -0,0 +1,185 @@
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'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
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: 'goToDefinition', 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.
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, config('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, config('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, 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' }))
await ctx.fiber.dispose()
})
it('rejects a nonpositive teardown budget at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
killGraceMs: 0,
}))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/)
await ctx.fiber.dispose()
})
it('rejects a nonpositive byte cap at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
maxDocumentBytes: 0,
}))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/)
await ctx.fiber.dispose()
})
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
[name]: MAX_TIMER_DELAY_MS + 1,
}))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`))
await ctx.fiber.dispose()
})
it('rejects an absolute command that is not executable at load', async () => {
const notExe = join(root, 'not-exe.txt')
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
}))).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, config('abs-directory', {
command: ws,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
}))).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

@@ -0,0 +1,173 @@
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('goToDefinition')).toBe('textDocument/definition')
expect(requestMethod('findReferences')).toBe('textDocument/references')
expect(requestMethod('goToImplementation')).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, 'goToDefinition')).toBe(true)
expect(supportsOperation(caps, 'findReferences')).toBe(true)
expect(supportsOperation(caps, 'goToImplementation')).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('requires an explicit openClose for the options form (no change-enum fallback)', () => {
expect(supportsTransientOpen({ change: 1 })).toBe(false)
expect(supportsTransientOpen({ change: 2 })).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 only for the protocol no-result value null', () => {
expect(normalizeLocations(null)).toEqual([])
expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
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/)
})
it('rejects negative and fractional position coordinates', () => {
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }]))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }]))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
})
describe('normalizeHover', () => {
it('returns null for null', () => {
expect(normalizeHover(null)).toBeNull()
})
it('rejects a missing hover result', () => {
expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
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('rejects a MarkupContent with a non-string value', () => {
expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
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 malformed MarkedString array member', () => {
expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
expect(() => normalizeHover({ contents: [null] }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
it('rejects a hover with no contents field', () => {
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
})
it('rejects a malformed range instead of silently dropping it', () => {
expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
})

View File

@@ -0,0 +1,114 @@
/**
* 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, {
servers: {
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('goToDefinition', 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('findReferences', 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('goToImplementation', 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)
})

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,42 @@
# @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 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 — `goToDefinition`, `findReferences`, `goToImplementation`, `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. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
## 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.
#### KV Cache effect
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## 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 Agent Note](../../../.agents/notes/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.

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,158 @@
/**
* The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
* order-independent selection over normalized goToDefinition/findReferences/goToImplementation/
* 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_DISPOSED`,
* `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) 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

View File

@@ -0,0 +1,130 @@
/**
* 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 = 'goToDefinition' | 'findReferences' | 'goToImplementation' | '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 (`goToDefinition`, `findReferences`,
* `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
* Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
*
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
* relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
* otherwise a symlinked workspace misclassifies in-workspace results as external.
*/
export type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
| { 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).
* `findReferences` 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>
}

View File

@@ -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: [], resolvedWorkspaceRoot: '/ws' },
): 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'] = 'goToDefinition'): 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: [], resolvedWorkspaceRoot: '/ws' })
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: [], resolvedWorkspaceRoot: '/ws' })
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: [], resolvedWorkspaceRoot: '/ws' })
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')
})
})

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,88 @@
# @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` (`goToDefinition` | `findReferences` | `goToImplementation` | `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. `findReferences` 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 relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; 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. |
| `maxResultChars` | `16000` | Largest complete rendered result, including truncation metadata. |
| `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
### System prompt
#### What the model sees
One system-prompt section (order 112) positions LSP as a precision aid with the following text:
##### Verbatim guidance
```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. findReferences always includes the declaration.
```
#### Token effect
Fixed guidance cost on every request while the plugin is active.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged; activation or disposal may invalidate reuse from this section.
### Tool schema
#### What the model sees
The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp).
#### Token effect
Fixed schema cost on every request while enabled; the `timeoutMs` budget is never sent to the model.
#### KV Cache effect
Prefix-stable while the visible tool definition and order are unchanged; registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
### Results
#### What the model sees
File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines.
#### Token effect
Capped per tool result by `maxResultChars`, with `maxLocations` additionally bounding navigation item count.
#### KV Cache effect
Tool results append after the cached request prefix and do not directly invalidate it.
### ACP presentation
#### What the model sees
Nothing. The client renders 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.
#### Token effect
Zero direct token effect because rendering is client-side only.
#### KV Cache effect
None; ACP presentation is outside the model request.
## 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 Agent Note](../../../.agents/notes/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.

View File

@@ -0,0 +1,47 @@
{
"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 goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded 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-timeout": "^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": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,243 @@
/**
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
* (`goToDefinition`/`findReferences`/`goToImplementation`/`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 { assertNever } 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 { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
} from './render.ts'
import { sessionCwd } from './session-cwd.ts'
export {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
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. findReferences 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 complete rendered result in characters, including truncation metadata (default 16000). */
maxResultChars?: 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),
maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS),
timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
})
type ResolvedConfig = Required<Config>
const LSP_POSITION_OUTPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
line: { type: 'integer', required: true },
character: { type: 'integer', required: true },
},
} as const
const LSP_RANGE_OUTPUT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
start: { ...LSP_POSITION_OUTPUT_SCHEMA, required: true },
end: { ...LSP_POSITION_OUTPUT_SCHEMA, required: true },
},
} as const
/**
* 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('maxResultChars', resolved.maxResultChars)
assertTimer('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 goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.',
parameters: {
operation: {
type: 'string',
required: true,
enum: [...LSP_OPERATIONS],
description: 'goToDefinition, findReferences, goToImplementation, 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.' },
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'locations' },
locations: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
uri: { type: 'string', required: true },
range: { ...LSP_RANGE_OUTPUT_SCHEMA, required: true },
},
},
},
resolvedWorkspaceRoot: { type: 'string', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'hover' },
hover: {
required: true,
oneOf: [
{ type: 'null' },
{
type: 'object',
additionalProperties: false,
properties: {
contents: { type: 'string', required: true },
range: LSP_RANGE_OUTPUT_SCHEMA,
},
},
],
},
},
},
],
},
render: (_args, value) => {
switch (value.kind) {
case 'locations':
return [{ type: 'text', text: formatLocations(value.locations, value.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }]
case 'hover':
return [{ type: 'text', text: formatHover(value.hover, resolved.maxResultChars) }]
/* v8 ignore next -- exhaustive over the output schema's closed union; unreachable. */
default:
return assertNever(value, 'tool-lsp output')
}
},
},
timeoutMs: resolved.timeoutMs,
async execute(args, exec) {
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 {
kind: 'locations' as const,
locations: result.locations.map(location => ({
uri: location.uri,
range: {
start: { line: location.range.start.line, character: location.range.start.character },
end: { line: location.range.end.line, character: location.range.end.character },
},
})),
resolvedWorkspaceRoot: result.resolvedWorkspaceRoot,
}
case 'hover':
return {
kind: 'hover' as const,
hover: result.hover === null
? null
: {
contents: result.hover.contents,
...result.hover.range === undefined
? {}
: {
range: {
start: { line: result.hover.range.start.line, character: result.hover.range.start.character },
end: { line: result.hover.range.end.line, character: result.hover.range.end.character },
},
},
},
}
/* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */
default:
return assertNever(result, 'tool-lsp result')
}
},
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`)
}
}
/** Reject a timer value Node would clamp instead of scheduling as configured. */
function assertTimer(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}

View File

@@ -0,0 +1,168 @@
/**
* 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, complete-result
* 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[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover']
/** Default cap on rendered locations before an omission marker is appended. */
export const DEFAULT_MAX_LOCATIONS = 100
/** Default cap on the complete rendered tool result, including truncation metadata. */
export const DEFAULT_MAX_RESULT_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 by count, then applies the complete result cap.
* @param locations - the seam's locations (possibly empty).
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
* @param maxLocations - the cap before truncation.
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
* @returns the rendered text; a distinct no-result line when there are none.
*/
export function formatLocations(
locations: readonly LspLocation[],
workspaceRoot: string,
maxLocations: number,
maxResultChars: number,
): string {
if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations')
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 boundResult(lines.join('\n'), maxResultChars, 'locations')
}
/**
* Render a hover result, applying `maxResultChars` last and keeping its marker within the cap.
* @param hover - the normalized hover, or `null` for no hover.
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
* @returns the rendered hover text; a distinct no-result line for `null`.
*/
export function formatHover(hover: LspHover | null, maxResultChars: number): string {
const text = hover === null ? 'No hover information.' : hover.contents
return boundResult(text, maxResultChars, 'hover')
}
/** Bound a complete rendered result, including the truncation notice itself. */
function boundResult(text: string, maxChars: number, label: string): string {
if (text.length <= maxChars) return text
const notice = `\n… ${label} truncated (limit ${maxChars} characters).`
if (notice.length >= maxChars) return notice.slice(0, maxChars)
return `${text.slice(0, maxChars - notice.length)}${notice}`
}
/**
* 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 '.'
// A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false
// positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`).
const outside = rel === '..' || rel.startsWith(`..${sep}`) || 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 }],
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,94 @@
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'
/**
* Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy.
* The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path.
*/
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, {
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 } : {})
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 integration', () => {
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: 'goToDefinition', 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: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('TOOL_TIMEOUT')
await ctx.fiber.dispose()
}, 30_000)
})

View File

@@ -0,0 +1,24 @@
/**
* Loader export-shape 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 verifies the namespace survives
* `Loader.prototype.unwrapExports`; the `lsp-definition` ACP snapshot owns full app composition.
*/
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 Loader export-shape 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()
})
})

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
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 an in-workspace path whose first segment starts with dots relative', () => {
// `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external.
const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('..generated/a.ts')
})
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, DEFAULT_MAX_RESULT_CHARS)).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, DEFAULT_MAX_RESULT_CHARS)
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, DEFAULT_MAX_RESULT_CHARS)
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, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('1 more location omitted (limit 1).')
})
it('caps the complete location text even when one URI is enormous', () => {
const maxResultChars = 80
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
expect(text).toHaveLength(maxResultChars)
expect(text).toContain('locations truncated')
})
})
describe('formatHover', () => {
it('renders a no-result line for null', () => {
expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.')
})
it('returns short hover verbatim', () => {
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```')
})
it('caps the complete hover text including its truncation marker', () => {
const text = formatHover({ contents: 'a'.repeat(100) }, 60)
expect(text).toHaveLength(60)
expect(text).toContain('hover truncated (limit 60 characters).')
})
it('still honors a cap smaller than the truncation marker', () => {
expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10)
})
})
describe('presentLspCall', () => {
it('is a generic search card with an operation/cursor title and a line location', () => {
expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
card: 'generic',
kind: 'search',
title: 'LSP findReferences a.ts:3:7',
locations: [{ path: 'a.ts', line: 3 }],
})
})
})

View File

@@ -0,0 +1,230 @@
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'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
/** 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 } } }],
resolvedWorkspaceRoot: '/ws',
}
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(['goToDefinition', 'findReferences', 'goToImplementation', '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/)
})
it('rejects a timeout above Node timer range at load', async () => {
await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(/timeoutMs/)
expect(() => {
ToolLsp.apply(new Context(), {
maxLocations: 100,
maxResultChars: 16_000,
timeoutMs: MAX_TIMER_DELAY_MS + 1,
})
}).toThrow(/timeoutMs/)
})
})
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: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
expect(result.isError).toBe(false)
expect(provider.seen[0]).toMatchObject({
operation: 'goToDefinition',
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: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
expect(result).toMatchObject({ isError: false, value: okLocations })
})
it('keeps all acquired locations in the canonical value when presentation is capped', async () => {
const locations = [
{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } },
{ uri: 'file:///ws/b.ts', range: { start: { line: 1, character: 2 }, end: { line: 1, character: 3 } } },
]
const { ctx } = await mount(stubProvider(() => ({
kind: 'locations',
locations,
resolvedWorkspaceRoot: '/ws',
})), { maxLocations: 1 })
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({
type: 'text',
text: 'a.ts:1:1\n… 1 more location omitted (limit 1).',
})
expect(result).toMatchObject({
isError: false,
value: { kind: 'locations', locations, resolvedWorkspaceRoot: '/ws' },
})
})
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
// location URIs are under. Relativizing against the alias would misclassify the location as
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/real/ws',
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
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' })
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number' } } })
})
it('preserves an optional hover range in the canonical value', async () => {
const range = { start: { line: 2, character: 3 }, end: { line: 2, character: 7 } }
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number', range } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 3, character: 4 }, '/ws')
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number', range } } })
})
it('preserves a null hover result as an explicit value', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: null })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({ type: 'text', text: 'No hover information.' })
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: null } })
})
it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null)
expect(result.isError).toBe(true)
expect(result.error?.info?.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: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.isError).toBe(true)
expect(result.error?.info?.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?.info?.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: 'goToDefinition', 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 }],
})
})
})

View File

@@ -0,0 +1,36 @@
{
"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": "../../util/timeout"
},
{
"path": "../lsp"
}
]
}

View File

@@ -4,7 +4,9 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag
## Start and ownership
`start(request)` performs `spawn` → ACP `initialize``newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped.
`start(request)` resolves the child's working directory, then performs `spawn` → ACP `initialize``newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
The working directory is the configured `cwd` override when set, else the delegating parent session's cwd — never the server process's own cwd, because one server process serves sessions from many workspaces. The parent-derived value must be an absolute path naming a directory the harness can enter (search permission — what a subprocess cwd needs), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace.
The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent.
@@ -14,7 +16,7 @@ After publication, the provider sends the prompt and collects streamed `agent_me
## Capabilities and context
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field.
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary.
## Configuration
@@ -23,7 +25,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
| `providerName` | `acp` | Registry name on `ctx.subagents`. |
| `command` | required | Executable spawned for each run. |
| `args` | `[]` | Command arguments. |
| `cwd` | process cwd | Child process and ACP session working directory. |
| `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
@@ -57,7 +59,7 @@ The child environment is built by [`buildChildEnv`](../subagent-subprocess/READM
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.
Keyless tests drive a scripted ACP subprocess over real stdio, including a Loader-composed stdio app proving parent-session cwd inheritance end to end. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.
## Model Experience
@@ -92,6 +94,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)).
- **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here.
- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
- **Only `agent_message_chunk` text is collected** — the child's tool-call activity, thought chunks, and plan updates are not surfaced to the parent.
- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut.

View File

@@ -1,11 +1,14 @@
/**
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
* tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent-
* enforced start capabilities. This plugin uses named exports only; a default would hide its
* tools, so it shares no Cordis context and advertises no parent-enforced start capabilities;
* the ONE thing it reads off `request.parent` is the session's workspace cwd (see
* {@link resolveCwd}). This plugin uses named exports only; a default would hide its
* loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
* @module @deepseek-ai/dsh-subagent-acp
*/
import { accessSync, constants, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
@@ -23,8 +26,11 @@ export interface Config {
/** Arguments passed to {@link command}. */
args: string[]
/**
* Working directory for the child process and its ACP session. Defaults to
* the parent process's cwd when omitted.
* Working directory override for the child process and its ACP session.
* Must be non-empty; a relative path resolves against the harness launch
* directory at load, and the result must be an existing directory. When
* omitted, each child inherits its delegating parent session's cwd — and
* starting one from a parent session that has no cwd fails.
*/
cwd?: string
/**
@@ -71,6 +77,60 @@ function assertPositiveFinite(name: string, value: number): void {
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/**
* Whether `path` names an existing directory the harness can ENTER. The
* search-permission probe matters: `statSync().isDirectory()` is true for a
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
*/
function isDirectory(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
// statSync/accessSync throw only filesystem access errors here
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
// serve as the child's cwd.
return false
}
}
/**
* Assert `cwd` can actually host the child: absolute (it doubles as the ACP
* session workspace, and a relative path would be re-anchored to the server
* process's launch directory) and an existing directory (fail here, before the
* process boundary, instead of as an ambiguous spawn ENOENT).
* @param label - which source supplied the value, for the diagnostic.
* @param cwd - the candidate working directory.
* @returns `cwd`, validated.
*/
function assertUsableCwd(label: string, cwd: string): string {
if (!isAbsolute(cwd)) {
throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`)
}
if (!isDirectory(cwd)) {
throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`)
}
return cwd
}
/**
* Resolve the child's working directory: the deployment `cwd` override when
* configured (already validated at load), else the parent session's workspace
* cwd (validated here, its earliest resolvable point). Fails loud when neither
* exists — falling back to the harness process cwd would silently bind the
* child to the server's launch directory instead of the delegating session's
* workspace (one server process serves many sessions, each with its own cwd).
*/
function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string {
if (configured !== undefined) return configured
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one')
}
return assertUsableCwd('parent session cwd', parentCwd)
}
/**
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
@@ -87,7 +147,7 @@ class AcpProvider implements SubagentProvider {
const spec: AcpRunSpec = {
command: this.config.command,
args: this.config.args,
cwd: this.config.cwd ?? process.cwd(),
cwd: resolveCwd(this.config.cwd, request),
permission: this.config.permission,
env: this.config.env,
disposeEofGraceMs: this.config.disposeEofGraceMs,
@@ -107,5 +167,15 @@ export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved))
// `path.resolve('')` is the process cwd — an empty string would silently
// reintroduce the launch-directory fallback this resolution removed.
if (resolved.cwd === '') {
throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd')
}
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const validated: ResolvedConfig = resolved.cwd === undefined
? resolved
: { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) }
ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated))
}

View File

@@ -37,7 +37,11 @@ export interface AcpRunSpec {
command: string
/** Arguments passed to {@link command}. */
args: string[]
/** Working directory for the child process AND its ACP session `cwd`. */
/**
* Absolute working directory for the child process AND its ACP session
* `cwd`. The provider resolves it before this spec exists: config override,
* else the delegating parent session's workspace.
*/
cwd: string
/** How to auto-answer the child's permission prompts. */
permission: PermissionPolicy

Some files were not shown because too many files have changed in this diff Show More