detached 408d4f4

feat(agent): add lsp and linter support

commit
408d4f4d5e34dc3d474eb26757d54b41f5543bb8
author
brice · · 2026-08-04 10:54 UTC
committer
brice · · 2026-08-04 12:56 UTC
parent
5b4cea4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +4380 -30
M README.md +11 -6
@@ -19,7 +19,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
19 19
20 - one static binary.20 - one static binary.
21 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.21 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
22-- four tools (read, write, edit, bash).22+- five built-in tools (read, write, edit, bash, and lsp).
23 - three run modes (interactive tui, print, json).23 - three run modes (interactive tui, print, json).
24 - built-in telegram bot.24 - built-in telegram bot.
25 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md).25 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -219,7 +219,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
219 | `--no-session` | Don't read or write session files. |219 | `--no-session` | Don't read or write session files. |
220 | `--cwd <path>` | Use `<path>` as the working directory. |220 | `--cwd <path>` | Use `<path>` as the working directory. |
221 | `--no-tools` | Disable all tools. |221 | `--no-tools` | Disable all tools. |
222-| `--tools <csv>` | Only enable the listed tools. |222+| `--no-lsp` | Disable the built-in LSP/linter tool for this run. |
223+| `--tools <csv>` | Only enable the listed tools. Include `lsp` to select it explicitly. |
223 | `--max-steps <n>` | Cap agent loop iterations (default 50). |224 | `--max-steps <n>` | Cap agent loop iterations (default 50). |
224 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |225 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
225 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |226 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
@@ -232,8 +233,9 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
232 - `write`: create or overwrite files, making parent directories as needed.233 - `write`: create or overwrite files, making parent directories as needed.
233 - `edit`: one or more exact-match replacements in an existing file.234 - `edit`: one or more exact-match replacements in an existing file.
234 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
236+- `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
235 237
236-When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd.238+When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd.
237 239
238 ## Modes240 ## Modes
239 241
@@ -290,7 +292,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
290 | `/unjail` | Allow tools to touch paths outside again. |292 | `/unjail` | Allow tools to touch paths outside again. |
291 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |293 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
292 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |294 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
293-| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |295+| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
294 | `/clear` | Clear the chat transcript. |296 | `/clear` | Clear the chat transcript. |
295 | `/exit` | Exit zot. |297 | `/exit` | Exit zot. |
296 298
@@ -399,6 +401,8 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
399 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.401 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
400 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.402 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
401 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.403 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
404+- **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
405+- **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
402 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zot automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.406 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zot automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
403 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.407 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
404 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.408 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
@@ -876,7 +880,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
876 | `ctrl+d` | Exit on empty input. |880 | `ctrl+d` | Exit on empty input. |
877 | `ctrl+l` | Redraw the screen. |881 | `ctrl+l` | Redraw the screen. |
878 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |882 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
879-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |883+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |
880 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |884 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
881 | `@` | Open the file picker. Browse files and directories in the working directory. |885 | `@` | Open the file picker. Browse files and directories in the working directory. |
882 886
@@ -1038,12 +1042,13 @@ packages/provider/auth/ credential store, api-key probe, oauth, lo
1038 packages/core/ agent loop, sessions, cost tracking, compaction1042 packages/core/ agent loop, sessions, cost tracking, compaction
1039 packages/tui/ terminal raw-mode, input parser, editor, renderer, markdown, view1043 packages/tui/ terminal raw-mode, input parser, editor, renderer, markdown, view
1040 packages/agent/ cli wiring, arg parsing, system prompt, config1044 packages/agent/ cli wiring, arg parsing, system prompt, config
1045+packages/agent/lsp/ LSP clients, server discovery, linters, diagnostics
1041 packages/agent/extensions/ extension subprocess manager1046 packages/agent/extensions/ extension subprocess manager
1042 packages/agent/extproto/ extension wire-format types1047 packages/agent/extproto/ extension wire-format types
1043 packages/agent/modes/ interactive tui, print, json, dialogs1048 packages/agent/modes/ interactive tui, print, json, dialogs
1044 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1049 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1045 packages/agent/modes/telegram/ telegram adapter, api client, daemon1050 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1046-packages/agent/tools/ read, write, edit, bash, sandbox1051+packages/agent/tools/ read, write, edit, bash, lsp, sandbox
1047 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1052 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1048 packages/agent/subagents/ named markdown profile discovery and dispatch1053 packages/agent/subagents/ named markdown profile discovery and dispatch
1049 packages/agent/swarm/ background subagent runtime1054 packages/agent/swarm/ background subagent runtime
A docs/lsp.md +142 -0
@@ -0,0 +1,142 @@
1+# LSP and linter support
2+
3+zot exposes a built-in `lsp` tool for code intelligence and diagnostics. It
4+communicates with stdio language servers through JSON-RPC and can also run
5+configured command-line linters without a shell.
6+
7+## Configuration
8+
9+zot loads JSON configuration from the following layers, in increasing
10+precedence:
11+
12+- `$ZOT_HOME/lsp.json`
13+- `<cwd>/.pi/lsp-bridge.json`, `<cwd>/.pi/lsp.json`
14+- `<cwd>/.omp/lsp.json`
15+- `<cwd>/pi-lsp-bridge.json`
16+- `<cwd>/lsp.json`, `<cwd>/.lsp.json`, and `<cwd>/.zot/lsp.json`
17+
18+A file may put providers below `servers`, `lspServers`, or `linters`, use a
19+flat provider map, or use the `providers` array from pi-lsp-bridge:
20+
21+```json
22+{
23+ "servers": {
24+ "gopls": {
25+ "command": "gopls",
26+ "fileTypes": [".go"],
27+ "rootMarkers": ["go.mod", "go.work"]
28+ },
29+ "eslint": {
30+ "kind": "cli",
31+ "command": "eslint",
32+ "args": ["--format", "json"],
33+ "parser": "eslint-json",
34+ "mode": "files",
35+ "fileTypes": [".js", ".jsx", ".ts", ".tsx"],
36+ "rootMarkers": ["eslint.config.js", ".eslintrc"]
37+ }
38+ }
39+}
40+```
41+
42+The pi-lsp-bridge array accepts built-in names and objects with `id`, `kind`,
43+`command`, `args`, selectors, root markers, and nested `lsp`/`cli` options.
44+Unknown bridge-specific metadata is ignored. Set `autoDetect` to `false` to
45+keep only providers named or defined by the configuration file.
46+
47+Server overrides merge onto zot's built-in definitions. Built-ins cover common
48+Go, Rust, TypeScript/JavaScript, Python, C/C++, YAML, JSON, and shell servers,
49+plus ESLint, golangci-lint, and Ruff CLI providers. A provider is selected
50+when it is enabled and its file type/root markers match the workspace. Command
51+lookup prefers project-local bin directories and then `PATH`; missing commands
52+are reported by `status` or the request result. Set `disabled` to `true` to
53+turn off a provider.
54+
55+Useful server fields are:
56+
57+| Field | Meaning |
58+| --- | --- |
59+| `kind` | `lsp` (default) or `cli` |
60+| `command`, `args` | Executable and arguments |
61+| `fileTypes` | Extensions, basenames, or language IDs handled by the provider |
62+| `languageId` | Optional LSP language ID sent on `textDocument/didOpen`; inferred when omitted |
63+| `rootMarkers` | Files identifying a workspace root |
64+| `isLinter` | LSP provider used for diagnostics, not navigation/refactors |
65+| `parser` | CLI output parser: `eslint-json`, `golangci-lint-json`, `ruff-json`, `sarif`, or generic |
66+| `mode` | CLI provider scope: `files` or `workspace` |
67+| `settings` | Values returned for `workspace/configuration` requests |
68+| `initializationOptions` | Values sent during LSP initialization |
69+| `env` | Extra environment variables for the child process |
70+
71+CLI providers may use `{files}` in an argument. It is replaced with the
72+project-relative paths touched by the diagnostics request. CLI output is capped
73+before parsing.
74+
75+## Tool actions
76+
77+The model calls `lsp` with an `action` and an optional `path`, `line`, `column`,
78+`server`, or `query`:
79+
80+- `diagnostics` — merge LSP and CLI diagnostics for a file, glob, or the whole workspace. `run_cli:false` skips command-line linters.
81+- `definition`, `type_definition`, `implementation`, `references`, `hover` — query a language server at a 1-based line and column.
82+- `symbols` — return document symbols, or workspace symbols when `query` is supplied and `path` is omitted or `*`.
83+- `rename` — request a `WorkspaceEdit`; applies it by default, or previews it with `apply:false`.
84+- `code_actions` — list actions, or apply returned edits with `apply:true`.
85+- `status` — show configured providers, running state, and diagnostic counts without starting servers.
86+- `reload` — stop cached LSP processes and reload configuration.
87+- `capabilities` — inspect initialize capabilities for one or all servers.
88+- `request` — send a raw JSON-RPC method with JSON `params`.
89+
90+`--no-lsp` disables the tool for one run. `--tools lsp` selects it when an
91+explicit tool list is used.
92+
93+## Diagnostics and context limits
94+
95+Diagnostics are deduplicated across providers using path, severity, code,
96+start position, and normalized message. Results are ordered by severity and
97+location. Repeated
98+identical messages are represented as one occurrence group with example paths
99+or lines. Cascades beneath missing-module errors are grouped as `SECONDARY`
100+items so the root import problem remains visible without repeating a large
101+error fan-out.
102+
103+The model-facing summary has a hard diagnostic cap (50 by default, with a
104+smaller eight-item summary used after writes and edits). Messages are
105+whitespace-compacted and clipped before formatting. Slow write-time checks are
106+bounded to a short two-second budget; a successful file write or edit is never
107+turned into a failed mutation because an LSP or linter is unavailable.
108+
109+Write diagnostics are enabled by default when LSP is enabled. Edit diagnostics
110+are disabled by default to keep multi-edit sequences compact. The persisted
111+configuration keys are `lsp_diagnostics_on_write` and
112+`lsp_diagnostics_on_edit` when an embedding needs to override those defaults.
113+Repeated post-write diagnostics are tracked per file by severity/code/message
114+identity, so a line shift does not replay the same error. Clearing a file's
115+diagnostics allows a later recurrence to be surfaced again.
116+
117+## Settings
118+
119+`/settings` provides two independent switches, both enabled by default:
120+
121+- **lsp in main session** (`lsp_enabled`) controls the main agent and applies
122+ immediately to the live tool registry.
123+- **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned swarm
124+ child processes.
125+
126+The sub-agent setting does not grant access to a child whose profile or
127+explicit tool list omits `lsp`. Both settings can also be controlled through
128+`$ZOT_HOME/config.json`.
129+
130+## Safety and lifecycle
131+
132+Language servers and linters run as local child processes. The `lsp` tool never
133+executes configured commands through a shell. Workspace edits returned by
134+rename and code actions are restricted to the session workspace, reject
135+resource operations, validate UTF-16 LSP positions, and write through a
136+temporary file before replacement. Server-initiated `workspace/applyEdit`
137+requests use the same checks.
138+
139+LSP clients are cached per workspace and provider for the lifetime of the zot
140+process. `reload` and process shutdown terminate cached children. Server
141+stderr is kept out of model-facing tool results; diagnostics and protocol
142+errors are returned through the established tool result channel.
M docs/subagents.md +2 -2
@@ -23,7 +23,7 @@ Profiles are read-only inputs. zot does not execute files from these directories
23 ---23 ---
24 name: reviewer24 name: reviewer
25 description: Read-only code reviewer25 description: Read-only code reviewer
26-tools: read, write, edit, bash26+tools: read, write, edit, bash, lsp
27 model: openai-codex/gpt-5.6-luna27 model: openai-codex/gpt-5.6-luna
28 thinking: max28 thinking: max
29 systemPromptMode: replace29 systemPromptMode: replace
@@ -40,7 +40,7 @@ Supported metadata:
40 |---|---|40 |---|---|
41 | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. |41 | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. |
42 | `description` | Short description shown to the main agent. |42 | `description` | Short description shown to the main agent. |
43-| `tools` | Comma-separated or list-form tool names. zot currently enforces its own `read`, `write`, `edit`, and `bash` registry; unknown names do not grant capabilities. |43+| `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; unknown names do not grant capabilities. |
44 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |44 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
45 | `provider` | Optional separate provider ID for a model without a provider prefix. |45 | `provider` | Optional separate provider ID for a model without a provider prefix. |
46 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |46 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
M examples/extensions/mcp-bridge/bridge.go +1 -1
@@ -5,7 +5,7 @@
5 // mcp__<server>__<tool>5 // mcp__<server>__<tool>
6 //6 //
7 // The double underscore separates the server name from the tool name,7 // The double underscore separates the server name from the tool name,
8-// avoiding collisions with zot's built-in tools (read, write, edit, bash, skill).8+// avoiding collisions with zot's built-in tools (read, write, edit, bash, lsp, skill).
9 package main9 package main
10 10
11 import (11 import (
M packages/agent/args.go +5 -1
@@ -63,6 +63,7 @@ type Args struct {
63 63
64 CWD string64 CWD string
65 NoTools bool65 NoTools bool
66+ NoLSP bool
66 Tools []string67 Tools []string
67 MaxSteps int68 MaxSteps int
68 69
@@ -177,6 +178,8 @@ func ParseArgs(in []string) (Args, error) {
177 a.NoSess = true178 a.NoSess = true
178 case "--no-tools":179 case "--no-tools":
179 a.NoTools = true180 a.NoTools = true
181+ case "--no-lsp":
182+ a.NoLSP = true
180 case "--list-models":183 case "--list-models":
181 a.ListModels = true184 a.ListModels = true
182 case "--experimental-oauth":185 case "--experimental-oauth":
@@ -459,7 +462,8 @@ func PrintHelp(version string) {
459 section("workspace, tools, skills",462 section("workspace, tools, skills",
460 row{"--cwd PATH", "treat PATH as the working directory"},463 row{"--cwd PATH", "treat PATH as the working directory"},
461 row{"--no-tools", "disable all tools"},464 row{"--no-tools", "disable all tools"},
462- row{"--tools csv", "only enable the listed tools"},465+ row{"--no-lsp", "disable the built-in LSP/linter tool"},
466+ row{"--tools csv", "only enable the listed tools (include lsp explicitly)"},
463 row{"--no-yolo", "ask before running every tool call"},467 row{"--no-yolo", "ask before running every tool call"},
464 row{"-y, --yes", "accept zot run consent without prompting"},468 row{"-y, --yes", "accept zot run consent without prompting"},
465 row{"--no-ext", "skip extension discovery for this run"},469 row{"--no-ext", "skip extension discovery for this run"},
M packages/agent/args_test.go +10 -0
@@ -12,6 +12,16 @@ func TestParseArgsSubagentAndReasoning(t *testing.T) {
12 }12 }
13 }13 }
14 14
15+func TestParseArgsNoLSP(t *testing.T) {
16+ args, err := ParseArgs([]string{"--no-lsp"})
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if !args.NoLSP {
21+ t.Fatal("--no-lsp did not disable LSP")
22+ }
23+}
24+
15 func TestParseArgsTemperatureAllowsZero(t *testing.T) {25 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
16 args, err := ParseArgs([]string{"--temperature", "0"})26 args, err := ParseArgs([]string{"--temperature", "0"})
17 if err != nil {27 if err != nil {
M packages/agent/botcmd.go +2 -0
@@ -218,6 +218,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
218 if err != nil {218 if err != nil {
219 return err219 return err
220 }220 }
221+ defer closeResolvedLSP(resolved)
221 222
222 if ok, cerr := spec.configured(ZotHome()); cerr != nil {223 if ok, cerr := spec.configured(ZotHome()); cerr != nil {
223 return cerr224 return cerr
@@ -269,6 +270,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
269 if err != nil {270 if err != nil {
270 return err271 return err
271 }272 }
273+ defer closeResolvedLSP(next)
272 agent.Client = next.NewClient()274 agent.Client = next.NewClient()
273 agent.Model = next.Model275 agent.Model = next.Model
274 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)276 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
M packages/agent/build.go +19 -5
@@ -9,6 +9,7 @@ import (
9 "strings"9 "strings"
10 10
11 zotdocs "github.com/patriceckhart/zot"11 zotdocs "github.com/patriceckhart/zot"
12+ "github.com/patriceckhart/zot/packages/agent/lsp"
12 "github.com/patriceckhart/zot/packages/agent/skills"13 "github.com/patriceckhart/zot/packages/agent/skills"
13 "github.com/patriceckhart/zot/packages/agent/subagents"14 "github.com/patriceckhart/zot/packages/agent/subagents"
14 "github.com/patriceckhart/zot/packages/agent/tools"15 "github.com/patriceckhart/zot/packages/agent/tools"
@@ -621,7 +622,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
621 if args.PermissionSet != nil {622 if args.PermissionSet != nil {
622 sandbox.SetPermissions(args.PermissionSet)623 sandbox.SetPermissions(args.PermissionSet)
623 }624 }
624- reg := buildToolRegistry(args, args.CWD, sandbox)625+ subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != ""
626+ lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession)
627+ reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(), cfg.LSPDiagnosticsOnEditEnabled())
625 628
626 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())629 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
627 630
@@ -1028,6 +1031,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1028 v.Sandbox = s1031 v.Sandbox = s
1029 case *tools.BashTool:1032 case *tools.BashTool:
1030 v.Sandbox = s1033 v.Sandbox = s
1034+ case *tools.LSPTool:
1035+ v.Sandbox = s
1031 }1036 }
1032 _ = name1037 _ = name
1033 }1038 }
@@ -1044,16 +1049,25 @@ func (r Resolved) NewAgent() *core.Agent {
1044 return a1049 return a
1045 }1050 }
1046 1051
1047-func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {1052+func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1048 if args.NoTools {1053 if args.NoTools {
1049 return core.Registry{}1054 return core.Registry{}
1050 }1055 }
1056+ var manager *lsp.Manager
1057+ if lspEnabled {
1058+ manager = lsp.NewManager()
1059+ }
1051 all := map[string]core.Tool{1060 all := map[string]core.Tool{
1052 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},1061 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1053- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},1062+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1054- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},1063+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1055 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1064 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1056 }1065 }
1066+ if manager != nil {
1067+ lspTool := tools.NewLSPTool(cwd, manager)
1068+ lspTool.Sandbox = sandbox
1069+ all["lsp"] = lspTool
1070+ }
1057 reg := core.Registry{}1071 reg := core.Registry{}
1058 if len(args.Tools) == 0 {1072 if len(args.Tools) == 0 {
1059 for _, t := range all {1073 for _, t := range all {
@@ -1070,7 +1084,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
1070 }1084 }
1071 1085
1072 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1086 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1073- order := []string{"read", "write", "edit", "bash"}1087+ order := []string{"read", "write", "edit", "bash", "lsp"}
1074 var out []ToolSummary1088 var out []ToolSummary
1075 for _, name := range order {1089 for _, name := range order {
1076 if t, ok := reg[name]; ok {1090 if t, ok := reg[name]; ok {
M packages/agent/build_test.go +20 -0
@@ -9,9 +9,29 @@ import (
9 "strings"9 "strings"
10 "testing"10 "testing"
11 11
12+ "github.com/patriceckhart/zot/packages/agent/tools"
12 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
13 )14 )
14 15
16+func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
17+ root := t.TempDir()
18+ registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
19+ if _, ok := registry["lsp"]; !ok {
20+ t.Fatal("default registry does not include lsp")
21+ }
22+ write, ok := registry["write"].(*tools.WriteTool)
23+ if !ok || write.LSP == nil || !write.LSPDiagnostics {
24+ t.Fatalf("write tool LSP wiring = %#v", write)
25+ }
26+ edit, ok := registry["edit"].(*tools.EditTool)
27+ if !ok || edit.LSP == nil || edit.LSPDiagnostics {
28+ t.Fatalf("edit tool LSP wiring = %#v", edit)
29+ }
30+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {
31+ t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))
32+ }
33+}
34+
15 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {35 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
16 root := t.TempDir()36 root := t.TempDir()
17 zotHome := filepath.Join(root, "zot-home")37 zotHome := filepath.Join(root, "zot-home")
M packages/agent/cli.go +31 -2
@@ -179,6 +179,16 @@ func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Age
179 return fallback179 return fallback
180 }180 }
181 181
182+func closeAgentLSP(ag *core.Agent) {
183+ if ag != nil {
184+ _ = tools.CloseLSPManagers(ag.Tools)
185+ }
186+}
187+
188+func closeResolvedLSP(r Resolved) {
189+ _ = tools.CloseLSPManagers(r.ToolRegistry)
190+}
191+
182 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {192 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
183 if keepTail <= 0 || len(msgs) <= keepTail {193 if keepTail <= 0 || len(msgs) <= keepTail {
184 return provider.RepairOrphanedToolResults(msgs)194 return provider.RepairOrphanedToolResults(msgs)
@@ -648,6 +658,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
648 defer stopExt()658 defer stopExt()
649 659
650 ag := r.NewAgent()660 ag := r.NewAgent()
661+ defer closeAgentLSP(ag)
651 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)662 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
652 sess, err := openOrCreateSession(args, r, ag, version)663 sess, err := openOrCreateSession(args, r, ag, version)
653 if err != nil {664 if err != nil {
@@ -702,6 +713,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
702 defer stopExt()713 defer stopExt()
703 714
704 ag := r.NewAgent()715 ag := r.NewAgent()
716+ defer closeAgentLSP(ag)
705 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)717 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
706 sess, err := openOrCreateSession(args, r, ag, version)718 sess, err := openOrCreateSession(args, r, ag, version)
707 if err != nil {719 if err != nil {
@@ -795,6 +807,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
795 defer stopExt()807 defer stopExt()
796 808
797 ag := r.NewAgent()809 ag := r.NewAgent()
810+ defer closeAgentLSP(ag)
798 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)811 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
799 sess, err := openOrCreateSession(args, r, ag, version)812 sess, err := openOrCreateSession(args, r, ag, version)
800 if err != nil {813 if err != nil {
@@ -915,8 +928,10 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
915 if mutateRegistry != nil {928 if mutateRegistry != nil {
916 reg = mutateRegistry(reg)929 reg = mutateRegistry(reg)
917 }930 }
931+ oldTools := ag.Tools
918 ag.SetTools(reg)932 ag.SetTools(reg)
919 ag.System = resolved.SystemPrompt933 ag.System = resolved.SystemPrompt
934+ _ = tools.CloseLSPManagers(oldTools)
920 }935 }
921 936
922 // reloadResourcesAfterStartupPre reloads extensions (if any) and937 // reloadResourcesAfterStartupPre reloads extensions (if any) and
@@ -1152,6 +1167,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1152 return a1167 return a
1153 }1168 }
1154 1169
1170+ var ag *core.Agent
1155 buildAgent := func() (*core.Agent, string, string, error) {1171 buildAgent := func() (*core.Agent, string, string, error) {
1156 resolved, err := Resolve(args, true)1172 resolved, err := Resolve(args, true)
1157 if err != nil {1173 if err != nil {
@@ -1209,7 +1225,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1209 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1225 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1210 }1226 }
1211 1227
1212- var ag *core.Agent
1213 if r.HasCredential() {1228 if r.HasCredential() {
1214 ag = wireAgentExt(r.NewAgent())1229 ag = wireAgentExt(r.NewAgent())
1215 }1230 }
@@ -1321,6 +1336,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1321 return a1336 return a
1322 }1337 }
1323 wireAgentPersist(ag)1338 wireAgentPersist(ag)
1339+ defer func() {
1340+ closeAgentLSP(liveInteractiveAgent(iv, ag))
1341+ closeResolvedLSP(r)
1342+ }()
1324 1343
1325 // Re-wrap the build closures so any agent constructed by the TUI1344 // Re-wrap the build closures so any agent constructed by the TUI
1326 // (login, /model swap to a different provider) also gets the1345 // (login, /model swap to a different provider) also gets the
@@ -1601,6 +1620,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1601 activeModel = newModel1620 activeModel = newModel
1602 persistMu.Unlock()1621 persistMu.Unlock()
1603 ag = newAg1622 ag = newAg
1623+ closeAgentLSP(currentAg)
1604 1624
1605 // Push the new state into the running Interactive.1625 // Push the new state into the running Interactive.
1606 if iv != nil {1626 if iv != nil {
@@ -1709,6 +1729,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1709 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,1729 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
1710 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1730 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1711 FastMode: &fastMode,1731 FastMode: &fastMode,
1732+ LSPEnabled: initialCfg.LSPEnabled,
1733+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
1712 AutoCompactThreshold: initialCfg.AutoCompactThreshold,1734 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1713 JailByDefault: initialCfg.JailByDefault,1735 JailByDefault: initialCfg.JailByDefault,
1714 QuickModelShortcuts: quickModelShortcuts,1736 QuickModelShortcuts: quickModelShortcuts,
@@ -1766,6 +1788,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1766 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)1788 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1767 }1789 }
1768 },1790 },
1791+ RefreshTools: func() {
1792+ current := liveInteractiveAgent(iv, ag)
1793+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1794+ },
1769 AuthManager: mgr,1795 AuthManager: mgr,
1770 LlamaCPPConfig: ResolveLlamaCPPConfig,1796 LlamaCPPConfig: ResolveLlamaCPPConfig,
1771 RefreshLlamaCPPModels: RefreshLlamaCPPModels,1797 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
@@ -1977,7 +2003,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1977 if !ok {2003 if !ok {
1978 return2004 return
1979 }2005 }
1980- if finalAg := iv.Agent(); finalAg != nil {2006+ finalAg := iv.Agent()
2007+ if finalAg != nil {
1981 persistMu.Lock()2008 persistMu.Lock()
1982 if sess != nil {2009 if sess != nil {
1983 writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)2010 writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
@@ -1987,6 +2014,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1987 }2014 }
1988 persistMu.Unlock()2015 persistMu.Unlock()
1989 }2016 }
2017+ closeAgentLSP(finalAg)
2018+ closeResolvedLSP(r)
1990 // Exit cleanly. Re-raising the signal would skip os.Exit's2019 // Exit cleanly. Re-raising the signal would skip os.Exit's
1991 // at-exit hooks; explicit exit is fine because we've already2020 // at-exit hooks; explicit exit is fine because we've already
1992 // flushed the only at-risk state (the session file).2021 // flushed the only at-risk state (the session file).
M packages/agent/config.go +40 -0
@@ -73,6 +73,23 @@ type Config struct {
73 // default; nil/missing means disabled. Toggle from /settings.73 // default; nil/missing means disabled. Toggle from /settings.
74 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`74 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
75 75
76+ // LSPEnabled controls the built-in lsp tool and write-time diagnostics
77+ // for the main session. nil/missing means enabled. Toggle from
78+ // /settings.
79+ LSPEnabled *bool `json:"lsp_enabled,omitempty"`
80+
81+ // SubagentLSPEnabled controls LSP availability in swarm sub-agent
82+ // processes. nil/missing means enabled. Toggle from /settings.
83+ SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"`
84+
85+ // LSPDiagnosticsOnWrite enables bounded diagnostics after a successful
86+ // write. nil/missing means enabled when LSP itself is enabled.
87+ LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"`
88+
89+ // LSPDiagnosticsOnEdit enables bounded diagnostics after a successful
90+ // edit. nil/missing means disabled to keep multi-edit sequences quiet.
91+ LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"`
92+
76 // AutoCompactThreshold is the percentage of the model context window93 // AutoCompactThreshold is the percentage of the model context window
77 // that triggers automatic transcript compaction in interactive mode.94 // that triggers automatic transcript compaction in interactive mode.
78 // nil/missing means 85; zero disables percentage-based triggers.95 // nil/missing means 85; zero disables percentage-based triggers.
@@ -196,6 +213,29 @@ func (c Config) CompactUserInput() bool {
196 return c.CompactInput != nil && *c.CompactInput213 return c.CompactInput != nil && *c.CompactInput
197 }214 }
198 215
216+// LSPEnabledFor reports whether LSP is enabled for a main or swarm
217+// sub-agent session. Both settings default to true so new installations
218+// get code intelligence without a migration or generated config file.
219+func (c Config) LSPEnabledFor(subagent bool) bool {
220+ value := c.LSPEnabled
221+ if subagent {
222+ value = c.SubagentLSPEnabled
223+ }
224+ return value == nil || *value
225+}
226+
227+// LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics
228+// preference without requiring a generated config entry.
229+func (c Config) LSPDiagnosticsOnWriteEnabled() bool {
230+ return c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite
231+}
232+
233+// LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics
234+// preference without requiring a generated config entry.
235+func (c Config) LSPDiagnosticsOnEditEnabled() bool {
236+ return c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit
237+}
238+
199 // AuthPath returns the path to auth.json.239 // AuthPath returns the path to auth.json.
200 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }240 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
201 241
M packages/agent/ext/ext.go +1 -1
@@ -395,7 +395,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
395 // OpenAI accept). Call this BEFORE Run(); zot folds extension tools395 // OpenAI accept). Call this BEFORE Run(); zot folds extension tools
396 // into the agent's registry once the extension's ready frame fires.396 // into the agent's registry once the extension's ready frame fires.
397 //397 //
398-// Naming conflicts with built-in tools (read, write, edit, bash,398+// Naming conflicts with built-in tools (read, write, edit, bash, lsp,
399 // skill) are silently shadowed by the built-in.399 // skill) are silently shadowed by the built-in.
400 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {400 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
401 e.registerTool(name, description, schema, false, fn)401 e.registerTool(name, description, schema, false, fn)
A packages/agent/lsp/client.go +659 -0
@@ -0,0 +1,659 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "sync/atomic"
16+ "time"
17+)
18+
19+type rpcError struct {
20+ Code int `json:"code"`
21+ Message string `json:"message"`
22+ Data any `json:"data,omitempty"`
23+}
24+
25+type rpcResponse struct {
26+ Result json.RawMessage
27+ Error *rpcError
28+}
29+
30+type rpcEnvelope struct {
31+ JSONRPC string `json:"jsonrpc"`
32+ ID json.RawMessage `json:"id"`
33+ Method string `json:"method"`
34+ Params json.RawMessage `json:"params"`
35+ Result json.RawMessage `json:"result"`
36+ Error *rpcError `json:"error"`
37+}
38+
39+// ClientOptions configures notifications and the small set of client-side
40+// handlers required by real language servers.
41+type ClientOptions struct {
42+ Settings map[string]any
43+ InitializationOptions any
44+ OnDiagnostics func([]Diagnostic)
45+ OnLog func(string)
46+ OnProgress func(string, json.RawMessage)
47+ ApplyEdit func(WorkspaceEdit) error
48+}
49+
50+// Client is one stdio JSON-RPC LSP process. A Client is safe for concurrent
51+// requests; the process itself is shared by all open documents in its root.
52+type Client struct {
53+ Spec ServerConfig
54+ Root string
55+ Command string
56+ cmd *exec.Cmd
57+ stdin io.WriteCloser
58+ stdout io.ReadCloser
59+ stderr io.ReadCloser
60+
61+ writeMu sync.Mutex
62+ mu sync.Mutex
63+ pending map[int64]chan rpcResponse
64+ nextID int64
65+ closed bool
66+ closeOnce sync.Once
67+ done chan struct{}
68+ doneOnce sync.Once
69+ readErr atomic.Value // string; one concrete type keeps atomic.Value safe
70+
71+ options ClientOptions
72+ capMu sync.RWMutex
73+ caps json.RawMessage
74+ docsMu sync.Mutex
75+ docs map[string]int
76+}
77+
78+// NewClient starts a stdio server but does not initialize it. This separation
79+// is convenient for tests and lets callers choose an initialization timeout.
80+func NewClient(spec ServerConfig, root string) (*Client, error) {
81+ return NewClientWithOptions(spec, root, ClientOptions{})
82+}
83+
84+// NewClientWithOptions starts a client with notification callbacks installed.
85+func NewClientWithOptions(spec ServerConfig, root string, options ClientOptions) (*Client, error) {
86+ if strings.EqualFold(spec.Kind, "cli") {
87+ return nil, fmt.Errorf("%s is a CLI provider, not an LSP server", spec.ID)
88+ }
89+ root, err := filepath.Abs(root)
90+ if err != nil {
91+ return nil, err
92+ }
93+ command, err := ResolveCommand(root, spec.Command)
94+ if err != nil {
95+ return nil, fmt.Errorf("%s: %w", spec.ID, err)
96+ }
97+ cmd := exec.Command(command, spec.Args...)
98+ cmd.Dir = root
99+ cmd.Env = mergedEnvironment(spec.Env)
100+ stdin, err := cmd.StdinPipe()
101+ if err != nil {
102+ return nil, fmt.Errorf("LSP stdin: %w", err)
103+ }
104+ stdout, err := cmd.StdoutPipe()
105+ if err != nil {
106+ _ = stdin.Close()
107+ return nil, fmt.Errorf("LSP stdout: %w", err)
108+ }
109+ stderr, err := cmd.StderrPipe()
110+ if err != nil {
111+ _ = stdin.Close()
112+ _ = stdout.Close()
113+ return nil, fmt.Errorf("LSP stderr: %w", err)
114+ }
115+ if err := cmd.Start(); err != nil {
116+ _ = stdin.Close()
117+ _ = stdout.Close()
118+ _ = stderr.Close()
119+ return nil, fmt.Errorf("start %s: %w", spec.ID, err)
120+ }
121+ c := &Client{
122+ Spec: spec, Root: root, Command: command, cmd: cmd,
123+ stdin: stdin, stdout: stdout, stderr: stderr,
124+ pending: make(map[int64]chan rpcResponse), done: make(chan struct{}),
125+ options: options, docs: make(map[string]int),
126+ }
127+ go c.readLoop()
128+ go c.stderrLoop()
129+ return c, nil
130+}
131+
132+// NewClientWithContext starts and initializes a client.
133+func NewClientWithContext(ctx context.Context, spec ServerConfig, root string, options ClientOptions) (*Client, error) {
134+ client, err := NewClientWithOptions(spec, root, options)
135+ if err != nil {
136+ return nil, err
137+ }
138+ if err := client.Initialize(ctx); err != nil {
139+ _ = client.Close()
140+ return nil, err
141+ }
142+ return client, nil
143+}
144+
145+func (c *Client) Initialize(ctx context.Context) error {
146+ if ctx == nil {
147+ ctx = context.Background()
148+ }
149+ folders := []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}
150+ params := map[string]any{
151+ "processId": os.Getpid(),
152+ "rootUri": mustURI(c.Root),
153+ "workspaceFolders": folders,
154+ "capabilities": map[string]any{
155+ "workspace": map[string]any{
156+ "applyEdit": true,
157+ "workspaceFolders": true,
158+ "configuration": true,
159+ },
160+ "textDocument": map[string]any{
161+ "publishDiagnostics": map[string]any{"relatedInformation": true},
162+ },
163+ },
164+ "initializationOptions": c.options.InitializationOptions,
165+ "clientInfo": map[string]any{"name": "zot", "version": "lsp"},
166+ }
167+ result, err := c.Request(ctx, "initialize", params)
168+ if err != nil {
169+ return fmt.Errorf("initialize %s: %w", c.Spec.ID, err)
170+ }
171+ var response struct {
172+ Capabilities json.RawMessage `json:"capabilities"`
173+ }
174+ if err := json.Unmarshal(result, &response); err == nil {
175+ c.capMu.Lock()
176+ c.caps = append(c.caps[:0], response.Capabilities...)
177+ c.capMu.Unlock()
178+ }
179+ if err := c.Notify("initialized", map[string]any{}); err != nil {
180+ return err
181+ }
182+ return nil
183+}
184+
185+// Capabilities returns the initialize result's capabilities.
186+func (c *Client) Capabilities() json.RawMessage {
187+ c.capMu.RLock()
188+ defer c.capMu.RUnlock()
189+ return append(json.RawMessage(nil), c.caps...)
190+}
191+
192+// Request sends a raw JSON-RPC request and waits for its response.
193+func (c *Client) Request(ctx context.Context, method string, params any) (json.RawMessage, error) {
194+ if ctx == nil {
195+ ctx = context.Background()
196+ }
197+ id := atomic.AddInt64(&c.nextID, 1)
198+ responseCh := make(chan rpcResponse, 1)
199+ c.mu.Lock()
200+ if c.closed {
201+ c.mu.Unlock()
202+ return nil, errors.New("LSP client is closed")
203+ }
204+ c.pending[id] = responseCh
205+ c.mu.Unlock()
206+ request := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}
207+ if err := c.send(request); err != nil {
208+ c.mu.Lock()
209+ delete(c.pending, id)
210+ c.mu.Unlock()
211+ return nil, err
212+ }
213+ select {
214+ case response := <-responseCh:
215+ if response.Error != nil {
216+ return nil, fmt.Errorf("LSP %s (%d): %s", method, response.Error.Code, response.Error.Message)
217+ }
218+ return response.Result, nil
219+ case <-ctx.Done():
220+ c.mu.Lock()
221+ delete(c.pending, id)
222+ c.mu.Unlock()
223+ _ = c.Notify("$/cancelRequest", map[string]any{"id": id})
224+ return nil, ctx.Err()
225+ case <-c.done:
226+ return nil, c.processError()
227+ }
228+}
229+
230+// Notify sends a JSON-RPC notification.
231+func (c *Client) Notify(method string, params any) error {
232+ return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
233+}
234+
235+func (c *Client) send(message any) error {
236+ payload, err := json.Marshal(message)
237+ if err != nil {
238+ return err
239+ }
240+ c.writeMu.Lock()
241+ defer c.writeMu.Unlock()
242+ c.mu.Lock()
243+ closed := c.closed
244+ c.mu.Unlock()
245+ if closed {
246+ return errors.New("LSP client is closed")
247+ }
248+ if err := WriteMessage(c.stdin, payload); err != nil {
249+ return fmt.Errorf("write LSP message: %w", err)
250+ }
251+ return nil
252+}
253+
254+// DidOpen publishes a complete document. Callers which already opened the URI
255+// should use DidChange for subsequent full-content updates.
256+func (c *Client) DidOpen(path, languageID, text string) error {
257+ uri, err := pathToURI(path)
258+ if err != nil {
259+ return err
260+ }
261+ c.docsMu.Lock()
262+ version := c.docs[uri]
263+ if version == 0 {
264+ version = 1
265+ } else {
266+ version++
267+ }
268+ c.docs[uri] = version
269+ c.docsMu.Unlock()
270+ return c.Notify("textDocument/didOpen", map[string]any{
271+ "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text},
272+ })
273+}
274+
275+// DidChange sends a full-content change, which avoids version/range drift
276+// between zot and servers that use different incremental-sync policies.
277+func (c *Client) DidChange(path, text string) error {
278+ uri, err := pathToURI(path)
279+ if err != nil {
280+ return err
281+ }
282+ c.docsMu.Lock()
283+ version := c.docs[uri]
284+ if version == 0 {
285+ version = 1
286+ } else {
287+ version++
288+ }
289+ c.docs[uri] = version
290+ c.docsMu.Unlock()
291+ return c.Notify("textDocument/didChange", map[string]any{
292+ "textDocument": map[string]any{"uri": uri, "version": version},
293+ "contentChanges": []map[string]any{{"text": text}},
294+ })
295+}
296+
297+func (c *Client) DidSave(path, text string) error {
298+ uri, err := pathToURI(path)
299+ if err != nil {
300+ return err
301+ }
302+ params := map[string]any{"textDocument": map[string]any{"uri": uri}}
303+ if text != "" {
304+ params["text"] = text
305+ }
306+ return c.Notify("textDocument/didSave", params)
307+}
308+
309+func (c *Client) readLoop() {
310+ reader := bufio.NewReaderSize(c.stdout, 64*1024)
311+ for {
312+ payload, err := ReadMessage(reader)
313+ if err != nil {
314+ if !errors.Is(err, io.EOF) {
315+ c.readErr.Store(err.Error())
316+ }
317+ c.failPending(err)
318+ c.signalDone()
319+ return
320+ }
321+ var envelope rpcEnvelope
322+ if err := json.Unmarshal(payload, &envelope); err != nil {
323+ c.readErr.Store(fmt.Sprintf("decode LSP message: %v", err))
324+ continue
325+ }
326+ if envelope.Method == "" && len(envelope.ID) > 0 {
327+ var id int64
328+ if json.Unmarshal(envelope.ID, &id) == nil {
329+ c.mu.Lock()
330+ channel := c.pending[id]
331+ delete(c.pending, id)
332+ c.mu.Unlock()
333+ if channel != nil {
334+ channel <- rpcResponse{Result: envelope.Result, Error: envelope.Error}
335+ }
336+ }
337+ continue
338+ }
339+ if envelope.Method != "" {
340+ // Diagnostics are ordered state: process publications on the
341+ // reader goroutine so a rapid clear/update sequence cannot be
342+ // reordered by notification handlers. Server requests may
343+ // block while they wait for an edit response, so those remain
344+ // asynchronous.
345+ if envelope.Method == "textDocument/publishDiagnostics" {
346+ c.handleServerMessage(envelope)
347+ } else {
348+ go c.handleServerMessage(envelope)
349+ }
350+ }
351+ }
352+}
353+
354+func (c *Client) handleServerMessage(message rpcEnvelope) {
355+ switch message.Method {
356+ case "textDocument/publishDiagnostics":
357+ c.handleDiagnostics(message.Params)
358+ return
359+ case "$/progress":
360+ if c.options.OnProgress != nil {
361+ c.options.OnProgress(message.Method, message.Params)
362+ }
363+ return
364+ case "window/logMessage", "window/showMessage", "telemetry/event":
365+ if c.options.OnLog != nil {
366+ var value struct {
367+ Message string `json:"message"`
368+ }
369+ _ = json.Unmarshal(message.Params, &value)
370+ c.options.OnLog(value.Message)
371+ }
372+ if len(message.ID) == 0 || string(message.ID) == "null" {
373+ return
374+ }
375+ _ = c.respond(message.ID, nil, nil)
376+ return
377+ case "workspace/configuration":
378+ _ = c.respond(message.ID, c.configuration(message.Params), nil)
379+ case "workspace/workspaceFolders":
380+ _ = c.respond(message.ID, []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}, nil)
381+ case "workspace/applyEdit":
382+ var params struct {
383+ Edit WorkspaceEdit `json:"edit"`
384+ }
385+ if err := json.Unmarshal(message.Params, &params); err != nil {
386+ _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil)
387+ return
388+ }
389+ err := c.applyEdit(params.Edit)
390+ result := map[string]any{"applied": err == nil}
391+ if err != nil {
392+ result["failureReason"] = err.Error()
393+ }
394+ _ = c.respond(message.ID, result, nil)
395+ case "client/registerCapability", "window/workDoneProgress/create":
396+ _ = c.respond(message.ID, map[string]any{}, nil)
397+ default:
398+ if len(message.ID) > 0 && string(message.ID) != "null" {
399+ _ = c.respond(message.ID, nil, &rpcError{Code: -32601, Message: "method not supported"})
400+ }
401+ }
402+}
403+
404+func (c *Client) configuration(params json.RawMessage) []any {
405+ var request struct {
406+ Items []struct {
407+ Section string `json:"section"`
408+ } `json:"items"`
409+ }
410+ _ = json.Unmarshal(params, &request)
411+ out := make([]any, len(request.Items))
412+ for i, item := range request.Items {
413+ if item.Section == "" {
414+ out[i] = c.options.Settings
415+ continue
416+ }
417+ out[i] = lookupSetting(c.options.Settings, item.Section)
418+ }
419+ return out
420+}
421+
422+func lookupSetting(settings map[string]any, section string) any {
423+ if settings == nil {
424+ return nil
425+ }
426+ if value, ok := settings[section]; ok {
427+ return value
428+ }
429+ var value any = settings
430+ for _, part := range strings.Split(section, ".") {
431+ object, ok := value.(map[string]any)
432+ if !ok {
433+ return nil
434+ }
435+ value, ok = object[part]
436+ if !ok {
437+ return nil
438+ }
439+ }
440+ return value
441+}
442+
443+func (c *Client) applyEdit(edit WorkspaceEdit) error {
444+ if c.options.ApplyEdit != nil {
445+ return c.options.ApplyEdit(edit)
446+ }
447+ return ApplyWorkspaceEdit(c.Root, edit)
448+}
449+
450+func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error {
451+ message := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(id)}
452+ if rpcErr != nil {
453+ message["error"] = rpcErr
454+ } else {
455+ message["result"] = result
456+ }
457+ return c.send(message)
458+}
459+
460+func (c *Client) handleDiagnostics(params json.RawMessage) {
461+ var value struct {
462+ URI string `json:"uri"`
463+ Diagnostics []Diagnostic `json:"diagnostics"`
464+ }
465+ if err := json.Unmarshal(params, &value); err != nil {
466+ return
467+ }
468+ path, err := uriToPath(value.URI)
469+ if err != nil {
470+ return
471+ }
472+ for i := range value.Diagnostics {
473+ value.Diagnostics[i].Path = path
474+ value.Diagnostics[i].Server = c.Spec.ID
475+ }
476+ if len(value.Diagnostics) == 0 {
477+ // Preserve the URI on a clear so Manager can remove only this file's
478+ // snapshot rather than accidentally retaining stale diagnostics.
479+ value.Diagnostics = []Diagnostic{{Path: path, Server: c.Spec.ID}}
480+ }
481+ if c.options.OnDiagnostics != nil {
482+ c.options.OnDiagnostics(value.Diagnostics)
483+ }
484+}
485+
486+func (c *Client) stderrLoop() {
487+ if c.stderr == nil {
488+ return
489+ }
490+ var output limitedBuffer
491+ output.limit = 1 << 20
492+ _, _ = io.Copy(&output, c.stderr)
493+ if output.Len() > 0 && c.options.OnLog != nil {
494+ c.options.OnLog(strings.TrimSpace(output.String()))
495+ }
496+}
497+
498+func (c *Client) failPending(err error) {
499+ c.mu.Lock()
500+ defer c.mu.Unlock()
501+ for id, channel := range c.pending {
502+ delete(c.pending, id)
503+ channel <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}}
504+ }
505+}
506+
507+func (c *Client) processError() error {
508+ if err, ok := c.readErr.Load().(string); ok && err != "" {
509+ return fmt.Errorf("LSP process stopped: %s", err)
510+ }
511+ return errors.New("LSP process stopped")
512+}
513+
514+func (c *Client) signalDone() { c.doneOnce.Do(func() { close(c.done) }) }
515+
516+// Close stops the process and releases all pipes. It is safe to call more
517+// than once and is also used by Manager.Reload.
518+func (c *Client) Close() error {
519+ var waitErr error
520+ c.closeOnce.Do(func() {
521+ // Give a cooperative server a short shutdown window before killing
522+ // the process. Close remains bounded for servers that do not support
523+ // shutdown or are already wedged.
524+ if c.cmd.ProcessState == nil {
525+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
526+ _, _ = c.Request(shutdownCtx, "shutdown", nil)
527+ cancel()
528+ _ = c.Notify("exit", nil)
529+ }
530+ // Serialize the closed transition with send so a writer that has
531+ // passed its closed check cannot race stdin.Close.
532+ c.writeMu.Lock()
533+ c.mu.Lock()
534+ c.closed = true
535+ c.mu.Unlock()
536+ c.signalDone()
537+ _ = c.stdin.Close()
538+ c.writeMu.Unlock()
539+ killed := false
540+ if c.cmd.Process != nil && c.cmd.ProcessState == nil {
541+ killed = c.cmd.Process.Kill() == nil
542+ }
543+ waitErr = c.cmd.Wait()
544+ if killed {
545+ // The kill was part of normal cleanup, not a failed operation.
546+ waitErr = nil
547+ }
548+ _ = c.stdout.Close()
549+ _ = c.stderr.Close()
550+ c.failPending(errors.New("LSP client closed"))
551+ })
552+ return waitErr
553+}
554+
555+func mustURI(path string) string {
556+ uri, _ := pathToURI(path)
557+ return uri
558+}
559+
560+func mergedEnvironment(overrides map[string]string) []string {
561+ env := os.Environ()
562+ if len(overrides) == 0 {
563+ return env
564+ }
565+ index := make(map[string]int, len(env))
566+ for i, item := range env {
567+ if key, _, ok := strings.Cut(item, "="); ok {
568+ index[key] = i
569+ }
570+ }
571+ for key, value := range overrides {
572+ item := key + "=" + value
573+ if i, ok := index[key]; ok {
574+ env[i] = item
575+ } else {
576+ env = append(env, item)
577+ }
578+ }
579+ return env
580+}
581+
582+// ResolveCommand searches workspace-local tool directories before PATH.
583+func ResolveCommand(cwd, command string) (string, error) {
584+ if strings.TrimSpace(command) == "" {
585+ return "", errors.New("empty command")
586+ }
587+ cwd, _ = filepath.Abs(cwd)
588+ candidates := []string{}
589+ if filepath.IsAbs(command) || strings.ContainsAny(command, `/\\`) {
590+ if filepath.IsAbs(command) {
591+ candidates = append(candidates, command)
592+ } else {
593+ candidates = append(candidates, filepath.Join(cwd, command))
594+ }
595+ } else {
596+ // A model often runs in a package subdirectory while the project's
597+ // executable lives at the repository root. Search each workspace
598+ // ancestor before falling back to PATH.
599+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
600+ for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), "bin"} {
601+ candidates = append(candidates, filepath.Join(dir, local, command))
602+ }
603+ next := filepath.Dir(dir)
604+ if next == dir {
605+ break
606+ }
607+ }
608+ }
609+ for _, candidate := range candidates {
610+ for _, path := range executableVariants(candidate) {
611+ if isRunnable(path) {
612+ return path, nil
613+ }
614+ }
615+ }
616+ if path, err := exec.LookPath(command); err == nil {
617+ return path, nil
618+ }
619+ return "", fmt.Errorf("command %q not found in node_modules/.bin, .venv/bin, bin, or PATH", command)
620+}
621+
622+func executableVariants(path string) []string {
623+ if filepath.Ext(path) != "" {
624+ return []string{path}
625+ }
626+ return []string{path, path + ".exe", path + ".cmd", path + ".bat"}
627+}
628+
629+func isRunnable(path string) bool {
630+ info, err := os.Stat(path)
631+ if err != nil || info.IsDir() {
632+ return false
633+ }
634+ if info.Mode()&0111 != 0 || filepath.Ext(path) == ".cmd" || filepath.Ext(path) == ".bat" || filepath.Ext(path) == ".exe" {
635+ return true
636+ }
637+ return false
638+}
639+
640+// WaitForDiagnostics gives asynchronous LSP servers a bounded opportunity to
641+// publish diagnostics after didOpen/didChange.
642+func (c *Client) WaitForDiagnostics(ctx context.Context, delay time.Duration) error {
643+ if ctx == nil {
644+ ctx = context.Background()
645+ }
646+ if delay <= 0 {
647+ return nil
648+ }
649+ timer := time.NewTimer(delay)
650+ defer timer.Stop()
651+ select {
652+ case <-timer.C:
653+ return nil
654+ case <-ctx.Done():
655+ return ctx.Err()
656+ case <-c.done:
657+ return c.processError()
658+ }
659+}
A packages/agent/lsp/config.go +654 -0
@@ -0,0 +1,654 @@
1+// Package lsp implements a small, dependency-free LSP and linter backend.
2+package lsp
3+
4+import (
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// ServerConfig describes either a stdio LSP server or a command-line linter.
14+// Command is resolved relative to the workspace before it is started.
15+type ServerConfig struct {
16+ ID string `json:"-"`
17+ Kind string `json:"kind,omitempty"` // "lsp" or "cli"
18+ Command string `json:"command"`
19+ Args []string `json:"args,omitempty"`
20+ FileTypes []string `json:"fileTypes,omitempty"`
21+ LanguageID string `json:"languageId,omitempty"`
22+ RootMarkers []string `json:"rootMarkers,omitempty"`
23+ IsLinter bool `json:"isLinter,omitempty"`
24+ Disabled bool `json:"disabled,omitempty"`
25+ Parser string `json:"parser,omitempty"`
26+ Mode string `json:"mode,omitempty"` // files or workspace for CLI linters
27+ RunOnStartup bool `json:"runOnStartup,omitempty"`
28+ RunOnChange bool `json:"runOnChange,omitempty"`
29+ Description string `json:"description,omitempty"`
30+ Env map[string]string `json:"env,omitempty"`
31+ Settings map[string]any `json:"settings,omitempty"`
32+ InitializationOptions any `json:"initializationOptions,omitempty"`
33+}
34+
35+// Config is the merged global/project LSP configuration.
36+type Config struct {
37+ Servers map[string]ServerConfig
38+ AutoDetect bool
39+ // Explicit contains servers named or defined by a config file. When
40+ // auto-detection is disabled, only these configured providers remain
41+ // eligible, matching pi-lsp-bridge's providers/autoDetect behavior.
42+ Explicit map[string]bool
43+ Files []string
44+}
45+
46+// BuiltinServers returns the built-in language servers. The slice is a copy
47+// and may be modified by callers.
48+func BuiltinServers() []ServerConfig {
49+ return []ServerConfig{
50+ {ID: "gopls", Kind: "lsp", Command: "gopls", FileTypes: []string{"go", ".go"}, RootMarkers: []string{"go.work", "go.mod"}, Description: "Go language server"},
51+ {ID: "rust-analyzer", Kind: "lsp", Command: "rust-analyzer", FileTypes: []string{"rust", "rs", ".rs"}, RootMarkers: []string{"Cargo.toml"}, Description: "Rust language server"},
52+ {ID: "typescript-language-server", Kind: "lsp", Command: "typescript-language-server", Args: []string{"--stdio"}, FileTypes: []string{"typescript", "typescriptreact", "javascript", "javascriptreact", "ts", "tsx", "js", "jsx", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}, RootMarkers: []string{"tsconfig.json", "jsconfig.json", "package.json"}, Description: "TypeScript and JavaScript language server"},
53+ {ID: "pyright-langserver", Kind: "lsp", Command: "pyright-langserver", Args: []string{"--stdio"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"}, Description: "Python language server"},
54+ {ID: "clangd", Kind: "lsp", Command: "clangd", FileTypes: []string{"c", "cpp", "c++", "h", "hpp", "cc", "cpp", "cxx", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"}, RootMarkers: []string{"compile_commands.json", "compile_flags.txt", "CMakeLists.txt"}, Description: "C and C++ language server"},
55+ {ID: "yaml-language-server", Kind: "lsp", Command: "yaml-language-server", Args: []string{"--stdio"}, FileTypes: []string{"yaml", "yml", ".yaml", ".yml"}, Description: "YAML language server"},
56+ {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"},
57+ {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"},
58+ {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"},
59+ {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--out-format", "json"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"},
60+ {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"},
61+ }
62+}
63+
64+// LoadConfig reads the global and project lsp.json files. Project files are
65+// applied after the global file; within a project, lsp.json, .lsp.json, and
66+// .zot/lsp.json are applied in that order. Missing files are harmless.
67+func LoadConfig(cwd string) (Config, error) {
68+ cwd, err := absDir(cwd)
69+ if err != nil {
70+ return Config{}, err
71+ }
72+ var files []string
73+ if home := zotHome(); home != "" {
74+ files = append(files, filepath.Join(home, "lsp.json"))
75+ }
76+ files = append(files,
77+ filepath.Join(cwd, ".pi", "lsp-bridge.json"),
78+ filepath.Join(cwd, ".pi", "lsp.json"),
79+ filepath.Join(cwd, ".omp", "lsp.json"),
80+ filepath.Join(cwd, "pi-lsp-bridge.json"),
81+ filepath.Join(cwd, "lsp.json"),
82+ filepath.Join(cwd, ".lsp.json"),
83+ filepath.Join(cwd, ".zot", "lsp.json"),
84+ )
85+
86+ objects := make(map[string]map[string]json.RawMessage)
87+ explicit := make(map[string]bool)
88+ autoDetect := true
89+ for _, path := range files {
90+ data, _ := os.ReadFile(path)
91+ if len(data) > 0 {
92+ var metadata struct {
93+ AutoDetect *bool `json:"autoDetect"`
94+ }
95+ if json.Unmarshal(data, &metadata) == nil && metadata.AutoDetect != nil {
96+ autoDetect = *metadata.AutoDetect
97+ }
98+ }
99+ entries, exists, err := readConfigFile(path)
100+ if err != nil {
101+ return Config{}, err
102+ }
103+ if !exists {
104+ continue
105+ }
106+ for id := range entries {
107+ explicit[id] = true
108+ }
109+ for id := range providerIDs(data) {
110+ explicit[id] = true
111+ }
112+ for id, entry := range entries {
113+ if old := objects[id]; old != nil {
114+ objects[id] = mergeRawObjects(old, entry)
115+ } else {
116+ objects[id] = cloneRawObject(entry)
117+ }
118+ }
119+ }
120+
121+ merged := make(map[string]ServerConfig)
122+ for _, server := range BuiltinServers() {
123+ merged[server.ID] = server
124+ }
125+ for id, raw := range objects {
126+ server, err := decodeServer(id, raw)
127+ if err != nil {
128+ return Config{}, fmt.Errorf("lsp server %q: %w", id, err)
129+ }
130+ if base, ok := merged[id]; ok {
131+ // Decode the already-merged JSON over the built-in by marshaling the
132+ // built-in first. This retains fields that were omitted in the file.
133+ baseRaw, _ := json.Marshal(base)
134+ baseMap := map[string]json.RawMessage{}
135+ _ = json.Unmarshal(baseRaw, &baseMap)
136+ mergedRaw := mergeRawObjects(baseMap, raw)
137+ server, err = decodeServer(id, mergedRaw)
138+ if err != nil {
139+ return Config{}, fmt.Errorf("lsp server %q: %w", id, err)
140+ }
141+ // A parser/mode is an explicit CLI declaration even when the
142+ // overridden built-in was an LSP. This makes {"parser":"sarif"}
143+ // sufficient for a linter override.
144+ if _, hasKind := raw["kind"]; !hasKind {
145+ if _, hasParser := raw["parser"]; hasParser {
146+ server.Kind = "cli"
147+ }
148+ }
149+ }
150+ merged[id] = server
151+ }
152+ for id, server := range merged {
153+ if server.ID == "" {
154+ server.ID = id
155+ merged[id] = server
156+ }
157+ }
158+ return Config{Servers: merged, AutoDetect: autoDetect, Explicit: explicit, Files: files}, nil
159+}
160+
161+// LoadServers is a convenience for callers that only need auto-detected
162+// server configurations.
163+func LoadServers(cwd string) ([]ServerConfig, error) {
164+ config, err := LoadConfig(cwd)
165+ if err != nil {
166+ return nil, err
167+ }
168+ return config.EffectiveServers(cwd), nil
169+}
170+
171+// EffectiveServers returns enabled servers which are relevant to any file in
172+// the workspace. It is useful for status UIs; Manager uses ApplicableServers
173+// for a particular file.
174+func (c Config) EffectiveServers(cwd string) []ServerConfig {
175+ var out []ServerConfig
176+ for _, server := range c.Servers {
177+ if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) {
178+ continue
179+ }
180+ if len(server.FileTypes) == 0 && len(server.RootMarkers) == 0 {
181+ out = append(out, server)
182+ continue
183+ }
184+ if hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) {
185+ out = append(out, server)
186+ }
187+ }
188+ sortServers(out)
189+ return out
190+}
191+
192+// ApplicableServers selects servers for path. An empty path means the cwd.
193+func (c Config) ApplicableServers(cwd, path string) []ServerConfig {
194+ var out []ServerConfig
195+ for _, server := range c.Servers {
196+ if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) || !serverMatchesPath(cwd, path, server) {
197+ continue
198+ }
199+ out = append(out, server)
200+ }
201+ sortServers(out)
202+ return out
203+}
204+
205+func serverMatchesPath(cwd, path string, server ServerConfig) bool {
206+ if len(server.FileTypes) == 0 {
207+ return len(server.RootMarkers) == 0 || hasRootMarker(cwd, server.RootMarkers)
208+ }
209+ if path != "" {
210+ if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) {
211+ return false
212+ }
213+ ext := strings.ToLower(filepath.Ext(path))
214+ base := strings.ToLower(filepath.Base(path))
215+ lang := languageForExtension(ext)
216+ for _, typ := range server.FileTypes {
217+ typ = strings.ToLower(strings.TrimSpace(typ))
218+ if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") {
219+ return true
220+ }
221+ if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) {
222+ return true
223+ }
224+ }
225+ return false
226+ }
227+ return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes)
228+}
229+
230+func hasMatchingFile(cwd string, types []string) bool {
231+ var found bool
232+ _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error {
233+ if err != nil || found {
234+ return filepath.SkipDir
235+ }
236+ if entry.IsDir() {
237+ if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") {
238+ return filepath.SkipDir
239+ }
240+ return nil
241+ }
242+ found = serverMatchesPath(cwd, path, ServerConfig{FileTypes: types})
243+ return nil
244+ })
245+ return found
246+}
247+
248+func hasRootMarker(cwd string, markers []string) bool {
249+ if len(markers) == 0 {
250+ return false
251+ }
252+ cwd, _ = absDir(cwd)
253+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
254+ for _, marker := range markers {
255+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
256+ return true
257+ }
258+ }
259+ next := filepath.Dir(dir)
260+ if next == dir {
261+ break
262+ }
263+ }
264+ return false
265+}
266+
267+// FindRoot resolves a server's workspace root by walking up from cwd and
268+// choosing the nearest directory containing one of its root markers.
269+func FindRoot(cwd string, markers []string) string {
270+ cwd, err := absDir(cwd)
271+ if err != nil {
272+ return cwd
273+ }
274+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
275+ for _, marker := range markers {
276+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
277+ return dir
278+ }
279+ }
280+ next := filepath.Dir(dir)
281+ if next == dir {
282+ break
283+ }
284+ }
285+ return cwd
286+}
287+
288+func sortServers(servers []ServerConfig) {
289+ rank := func(server ServerConfig) int {
290+ if server.Kind == "lsp" && !server.IsLinter {
291+ return 0
292+ }
293+ if server.Kind == "lsp" {
294+ return 1
295+ }
296+ return 2
297+ }
298+ for i := 1; i < len(servers); i++ {
299+ for j := i; j > 0; j-- {
300+ left, right := servers[j-1], servers[j]
301+ if rank(left) < rank(right) || (rank(left) == rank(right) && left.ID <= right.ID) {
302+ break
303+ }
304+ servers[j-1], servers[j] = right, left
305+ }
306+ }
307+}
308+
309+func absDir(cwd string) (string, error) {
310+ if strings.TrimSpace(cwd) == "" {
311+ cwd, _ = os.Getwd()
312+ }
313+ abs, err := filepath.Abs(cwd)
314+ if err != nil {
315+ return "", err
316+ }
317+ info, err := os.Stat(abs)
318+ if err == nil && !info.IsDir() {
319+ return "", fmt.Errorf("workspace %q is not a directory", cwd)
320+ }
321+ return filepath.Clean(abs), nil
322+}
323+
324+func zotHome() string {
325+ if value := strings.TrimSpace(os.Getenv("ZOT_HOME")); value != "" {
326+ return value
327+ }
328+ if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" {
329+ return filepath.Join(value, "zot")
330+ }
331+ home, _ := os.UserHomeDir()
332+ if home == "" {
333+ return ""
334+ }
335+ return filepath.Join(home, ".local", "state", "zot")
336+}
337+
338+func providerIDs(data []byte) map[string]struct{} {
339+ ids := make(map[string]struct{})
340+ var top struct {
341+ Providers []json.RawMessage `json:"providers"`
342+ }
343+ if json.Unmarshal(data, &top) != nil {
344+ return ids
345+ }
346+ for _, raw := range top.Providers {
347+ var id string
348+ if json.Unmarshal(raw, &id) == nil {
349+ if id = strings.TrimSpace(id); id != "" {
350+ ids[id] = struct{}{}
351+ }
352+ continue
353+ }
354+ var object struct {
355+ ID string `json:"id"`
356+ }
357+ if json.Unmarshal(raw, &object) == nil {
358+ if id = strings.TrimSpace(object.ID); id != "" {
359+ ids[id] = struct{}{}
360+ }
361+ }
362+ }
363+ return ids
364+}
365+
366+func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, error) {
367+ data, err := os.ReadFile(path)
368+ if errors.Is(err, os.ErrNotExist) {
369+ return nil, false, nil
370+ }
371+ if err != nil {
372+ return nil, true, err
373+ }
374+ var top map[string]json.RawMessage
375+ if err := json.Unmarshal(data, &top); err != nil {
376+ return nil, true, fmt.Errorf("read %s: %w", path, err)
377+ }
378+ root := top
379+ if raw, ok := top["providers"]; ok {
380+ // pi-lsp-bridge uses a providers array. Preserve its compact
381+ // string entries (built-ins are already in the default registry)
382+ // and turn object entries into the map shape used internally.
383+ var providers []json.RawMessage
384+ if err := json.Unmarshal(raw, &providers); err != nil {
385+ return nil, true, fmt.Errorf("%s.providers must be an array: %w", path, err)
386+ }
387+ root = make(map[string]json.RawMessage)
388+ for index, provider := range providers {
389+ var id string
390+ if json.Unmarshal(provider, &id) == nil {
391+ continue
392+ }
393+ var object map[string]json.RawMessage
394+ if err := json.Unmarshal(provider, &object); err != nil {
395+ return nil, true, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err)
396+ }
397+ idRaw, ok := object["id"]
398+ if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" {
399+ return nil, true, fmt.Errorf("%s.providers[%d] is missing id", path, index)
400+ }
401+ id = strings.TrimSpace(id)
402+ delete(object, "id")
403+ flattenProviderObject(object)
404+ root[id] = mustJSON(object)
405+ }
406+ } else {
407+ for _, key := range []string{"servers", "lspServers", "linters"} {
408+ if raw, ok := top[key]; ok {
409+ var wrapped map[string]json.RawMessage
410+ if err := json.Unmarshal(raw, &wrapped); err != nil {
411+ return nil, true, fmt.Errorf("%s.%s must be an object: %w", path, key, err)
412+ }
413+ root = wrapped
414+ break
415+ }
416+ }
417+ }
418+ out := make(map[string]map[string]json.RawMessage)
419+ for id, raw := range root {
420+ if id == "$schema" || id == "servers" || id == "lspServers" || id == "linters" || id == "providers" || id == "autoDetect" || id == "version" || id == "debug" || id == "status" || id == "lifecycle" || id == "excludePaths" || id == "idleTimeoutMs" {
421+ continue
422+ }
423+ var object map[string]json.RawMessage
424+ if err := json.Unmarshal(raw, &object); err != nil {
425+ // A string is a convenient shorthand for a command.
426+ var command string
427+ if json.Unmarshal(raw, &command) == nil {
428+ object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))}
429+ } else {
430+ return nil, true, fmt.Errorf("%s.%s must be an object", path, id)
431+ }
432+ }
433+ out[id] = object
434+ }
435+ return out, true, nil
436+}
437+
438+func strconvQuote(s string) string {
439+ b, _ := json.Marshal(s)
440+ return string(b)
441+}
442+
443+// flattenProviderObject adapts pi-lsp-bridge's nested provider shape to the
444+// flat ServerConfig fields used by zot. Unknown provider metadata remains
445+// harmless JSON and is ignored by decodeServer.
446+func flattenProviderObject(object map[string]json.RawMessage) {
447+ if _, exists := object["command"]; !exists {
448+ var candidates []struct {
449+ Command string `json:"command"`
450+ Args []string `json:"args"`
451+ }
452+ if raw, ok := object["commandCandidates"]; ok && json.Unmarshal(raw, &candidates) == nil && len(candidates) > 0 && candidates[0].Command != "" {
453+ object["command"] = mustJSON(candidates[0].Command)
454+ if len(candidates[0].Args) > 0 {
455+ object["args"] = mustJSON(candidates[0].Args)
456+ }
457+ }
458+ }
459+ if _, exists := object["kind"]; !exists {
460+ if _, ok := object["cli"]; ok {
461+ object["kind"] = mustJSON("cli")
462+ } else if _, ok := object["lsp"]; ok {
463+ object["kind"] = mustJSON("lsp")
464+ }
465+ }
466+ if _, exists := object["fileTypes"]; !exists {
467+ var selectors []struct {
468+ LanguageID string `json:"languageId"`
469+ Extensions []string `json:"extensions"`
470+ Filenames []string `json:"filenames"`
471+ }
472+ if raw, ok := object["selectors"]; ok && json.Unmarshal(raw, &selectors) == nil {
473+ var fileTypes []string
474+ for _, selector := range selectors {
475+ if selector.LanguageID != "" {
476+ fileTypes = append(fileTypes, selector.LanguageID)
477+ }
478+ fileTypes = append(fileTypes, selector.Extensions...)
479+ fileTypes = append(fileTypes, selector.Filenames...)
480+ }
481+ if len(fileTypes) > 0 {
482+ object["fileTypes"] = mustJSON(uniqueStrings(fileTypes))
483+ }
484+ }
485+ if _, exists := object["fileTypes"]; !exists {
486+ var detect struct {
487+ FileExtensions []string `json:"fileExtensionsAny"`
488+ }
489+ if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil && len(detect.FileExtensions) > 0 {
490+ object["fileTypes"] = mustJSON(uniqueStrings(detect.FileExtensions))
491+ }
492+ }
493+ }
494+ if _, exists := object["rootMarkers"]; !exists {
495+ var detect struct {
496+ RepoFiles []string `json:"repoFilesAny"`
497+ ConfigFiles []string `json:"configFilesAny"`
498+ }
499+ if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil {
500+ markers := append(append([]string{}, detect.RepoFiles...), detect.ConfigFiles...)
501+ if len(markers) > 0 {
502+ object["rootMarkers"] = mustJSON(uniqueStrings(markers))
503+ }
504+ }
505+ if _, exists := object["rootMarkers"]; !exists {
506+ if raw, ok := object["configMarkers"]; ok {
507+ object["rootMarkers"] = append(json.RawMessage(nil), raw...)
508+ }
509+ }
510+ }
511+ if raw, ok := object["lsp"]; ok {
512+ var nested map[string]json.RawMessage
513+ if json.Unmarshal(raw, &nested) == nil {
514+ if _, exists := object["initializationOptions"]; !exists {
515+ if value, ok := nested["initializationOptions"]; ok {
516+ object["initializationOptions"] = append(json.RawMessage(nil), value...)
517+ }
518+ }
519+ }
520+ }
521+ if raw, ok := object["cli"]; ok {
522+ var nested map[string]json.RawMessage
523+ if json.Unmarshal(raw, &nested) == nil {
524+ for _, key := range []string{"parser", "mode", "runOnStartup", "runOnChange"} {
525+ if _, exists := object[key]; exists {
526+ continue
527+ }
528+ if value, ok := nested[key]; ok {
529+ object[key] = append(json.RawMessage(nil), value...)
530+ }
531+ }
532+ }
533+ }
534+}
535+
536+func uniqueStrings(values []string) []string {
537+ seen := make(map[string]struct{}, len(values))
538+ out := make([]string, 0, len(values))
539+ for _, value := range values {
540+ if strings.TrimSpace(value) == "" {
541+ continue
542+ }
543+ if _, exists := seen[value]; exists {
544+ continue
545+ }
546+ seen[value] = struct{}{}
547+ out = append(out, value)
548+ }
549+ return out
550+}
551+
552+func decodeServer(id string, raw map[string]json.RawMessage) (ServerConfig, error) {
553+ if _, exists := raw["initializationOptions"]; !exists {
554+ if value, ok := raw["initOptions"]; ok {
555+ raw["initializationOptions"] = append(json.RawMessage(nil), value...)
556+ }
557+ }
558+ if command, ok := raw["command"]; ok {
559+ var list []string
560+ if err := json.Unmarshal(command, &list); err == nil {
561+ if len(list) == 0 {
562+ return ServerConfig{}, fmt.Errorf("command array is empty")
563+ }
564+ raw["command"] = json.RawMessage(strconvQuote(list[0]))
565+ if len(list) > 1 {
566+ args, _ := json.Marshal(list[1:])
567+ if _, exists := raw["args"]; !exists {
568+ raw["args"] = args
569+ }
570+ }
571+ }
572+ }
573+ data, err := json.Marshal(raw)
574+ if err != nil {
575+ return ServerConfig{}, err
576+ }
577+ var server ServerConfig
578+ if err := json.Unmarshal(data, &server); err != nil {
579+ return ServerConfig{}, err
580+ }
581+ server.ID = id
582+ if server.Kind == "" {
583+ if server.Parser != "" || server.Mode != "" {
584+ server.Kind = "cli"
585+ } else {
586+ server.Kind = "lsp"
587+ }
588+ }
589+ server.Kind = strings.ToLower(server.Kind)
590+ if server.Kind != "lsp" && server.Kind != "cli" {
591+ return ServerConfig{}, fmt.Errorf("kind must be lsp or cli")
592+ }
593+ if server.Kind == "cli" && server.Parser == "" {
594+ server.Parser = "generic"
595+ }
596+ return server, nil
597+}
598+
599+func cloneRawObject(in map[string]json.RawMessage) map[string]json.RawMessage {
600+ out := make(map[string]json.RawMessage, len(in))
601+ for key, value := range in {
602+ out[key] = append(json.RawMessage(nil), value...)
603+ }
604+ return out
605+}
606+
607+func mergeRawObjects(base, override map[string]json.RawMessage) map[string]json.RawMessage {
608+ out := cloneRawObject(base)
609+ for key, value := range override {
610+ var baseObject, overrideObject map[string]json.RawMessage
611+ if json.Unmarshal(out[key], &baseObject) == nil && json.Unmarshal(value, &overrideObject) == nil {
612+ out[key] = mustJSON(mergeRawObjects(baseObject, overrideObject))
613+ continue
614+ }
615+ out[key] = append(json.RawMessage(nil), value...)
616+ }
617+ return out
618+}
619+
620+func mustJSON(value any) json.RawMessage {
621+ data, _ := json.Marshal(value)
622+ return data
623+}
624+
625+func languageForExtension(ext string) string {
626+ switch strings.ToLower(ext) {
627+ case ".go":
628+ return "go"
629+ case ".rs":
630+ return "rust"
631+ case ".py":
632+ return "python"
633+ case ".ts":
634+ return "typescript"
635+ case ".tsx":
636+ return "typescriptreact"
637+ case ".js", ".mjs", ".cjs":
638+ return "javascript"
639+ case ".jsx":
640+ return "javascriptreact"
641+ case ".c", ".h":
642+ return "c"
643+ case ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx":
644+ return "cpp"
645+ case ".yaml", ".yml":
646+ return "yaml"
647+ case ".json", ".jsonc":
648+ return "json"
649+ case ".sh", ".bash", ".zsh":
650+ return "shellscript"
651+ default:
652+ return ""
653+ }
654+}
A packages/agent/lsp/diagnostics.go +335 -0
@@ -0,0 +1,335 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+)
10+
11+const DefaultDiagnosticCap = 50
12+
13+var missingModulePatterns = []*regexp.Regexp{
14+ regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`),
15+ regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`),
16+ regexp.MustCompile(`(?i)cannot resolve ["']?([^"' ]+)["']?`),
17+ regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`),
18+}
19+
20+// DiagnosticKey is stable across providers. Server/source are intentionally
21+// excluded so an LSP and a CLI linter reporting the same issue collapse.
22+func DiagnosticKey(d Diagnostic) string {
23+ // Match the bridge's merge key: the start location is stable enough to
24+ // identify one report while providers may disagree on the end range.
25+ return strings.Join([]string{d.Path, defaultSeverity(d.Severity), d.Code,
26+ fmt.Sprintf("%d:%d", d.Range.Start.Line, d.Range.Start.Character), compactWhitespace(d.Message)}, "\x00")
27+}
28+
29+func diagnosticIssueKey(d Diagnostic) string {
30+ return strings.Join([]string{defaultSeverity(d.Severity), d.Code, compactWhitespace(d.Message)}, "\x00")
31+}
32+
33+// DiagnosticIdentity identifies the issue independently of its current
34+// location. It is used by write/edit diagnostics to avoid replaying the same
35+// error after a harmless line shift while still re-surfacing it after removal.
36+func DiagnosticIdentity(d Diagnostic) string {
37+ return diagnosticIssueKey(d)
38+}
39+
40+// DeduplicateDiagnostics removes duplicates across servers and sorts errors,
41+// warnings, info, and hints in that order, then path and location.
42+func DeduplicateDiagnostics(input []Diagnostic) []Diagnostic {
43+ seen := make(map[string]Diagnostic, len(input))
44+ for _, diagnostic := range input {
45+ key := DiagnosticKey(diagnostic)
46+ if old, exists := seen[key]; !exists || old.Server == "" {
47+ seen[key] = diagnostic
48+ }
49+ }
50+ out := make([]Diagnostic, 0, len(seen))
51+ for _, diagnostic := range seen {
52+ out = append(out, diagnostic)
53+ }
54+ sort.SliceStable(out, func(i, j int) bool {
55+ if severityRank(out[i].Severity) != severityRank(out[j].Severity) {
56+ return severityRank(out[i].Severity) < severityRank(out[j].Severity)
57+ }
58+ if out[i].Path != out[j].Path {
59+ return out[i].Path < out[j].Path
60+ }
61+ if out[i].Range.Start.Line != out[j].Range.Start.Line {
62+ return out[i].Range.Start.Line < out[j].Range.Start.Line
63+ }
64+ if out[i].Range.Start.Character != out[j].Range.Start.Character {
65+ return out[i].Range.Start.Character < out[j].Range.Start.Character
66+ }
67+ if out[i].Message != out[j].Message {
68+ return out[i].Message < out[j].Message
69+ }
70+ return out[i].Code < out[j].Code
71+ })
72+ return out
73+}
74+
75+func severityRank(value string) int {
76+ switch strings.ToLower(value) {
77+ case "error":
78+ return 0
79+ case "warning":
80+ return 1
81+ case "info":
82+ return 2
83+ case "hint":
84+ return 3
85+ default:
86+ return 2
87+ }
88+}
89+
90+// SummarizeDiagnostics renders a bounded diagnostic summary. Repeated issues
91+// are represented once with occurrence/file counts; module-resolution errors
92+// retain their root issue while grouping the cascade of secondary errors.
93+func SummarizeDiagnostics(input []Diagnostic, root string, cap int) string {
94+ if cap <= 0 {
95+ cap = DefaultDiagnosticCap
96+ }
97+ diagnostics := DeduplicateDiagnostics(input)
98+ if len(diagnostics) == 0 {
99+ return "No diagnostics."
100+ }
101+ counts := [4]int{}
102+ for _, diagnostic := range diagnostics {
103+ switch severityRank(diagnostic.Severity) {
104+ case 0:
105+ counts[0]++
106+ case 1:
107+ counts[1]++
108+ case 2:
109+ counts[2]++
110+ case 3:
111+ counts[3]++
112+ }
113+ }
114+ parts := make([]string, 0, 4)
115+ for i, label := range []string{"error", "warning", "info", "hint"} {
116+ if counts[i] > 0 {
117+ parts = append(parts, fmt.Sprintf("%d %s%s", counts[i], label, plural(counts[i])))
118+ }
119+ }
120+ lines := []string{fmt.Sprintf("Diagnostics: %s (deduplicated %d)", strings.Join(parts, ", "), len(diagnostics))}
121+ secondary := moduleSecondary(diagnostics)
122+ groups := make(map[string][]Diagnostic)
123+ for _, diagnostic := range diagnostics {
124+ if secondary[DiagnosticKey(diagnostic)] {
125+ continue
126+ }
127+ key := diagnosticIssueKey(diagnostic)
128+ groups[key] = append(groups[key], diagnostic)
129+ }
130+ emitted := make(map[string]bool)
131+ emittedSecondary := make(map[string]bool)
132+ displayed := 0
133+ for _, diagnostic := range diagnostics {
134+ if displayed >= cap {
135+ break
136+ }
137+ if secondary[DiagnosticKey(diagnostic)] {
138+ continue
139+ }
140+ groupKey := diagnosticIssueKey(diagnostic)
141+ bucket := groups[groupKey]
142+ if len(bucket) >= 3 || uniquePaths(bucket) >= 2 {
143+ if emitted[groupKey] {
144+ continue
145+ }
146+ emitted[groupKey] = true
147+ lines = append(lines, formatRepeated(bucket, root))
148+ displayed += len(bucket)
149+ } else {
150+ lines = append(lines, "- "+FormatDiagnostic(diagnostic, root))
151+ displayed++
152+ }
153+ if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] {
154+ if secondaryLine := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" {
155+ lines = append(lines, "- "+secondaryLine)
156+ emittedSecondary[diagnostic.Path] = true
157+ displayed++
158+ }
159+ }
160+ }
161+ omitted := len(diagnostics) - minInt(displayed, len(diagnostics))
162+ if omitted > 0 {
163+ lines = append(lines, fmt.Sprintf("... truncated; %d diagnostics omitted (hard cap %d)", omitted, cap))
164+ }
165+ return strings.Join(lines, "\n")
166+}
167+
168+func formatRepeated(bucket []Diagnostic, root string) string {
169+ paths := make(map[string]bool)
170+ lines := make(map[int]bool)
171+ for _, diagnostic := range bucket {
172+ paths[diagnostic.Path] = true
173+ lines[diagnostic.Range.Start.Line+1] = true
174+ }
175+ example := make([]string, 0, 3)
176+ for _, diagnostic := range bucket {
177+ path := diagnostic.Path
178+ if root != "" {
179+ if rel, err := filepathRel(root, path); err == nil {
180+ path = rel
181+ }
182+ }
183+ if !contains(example, path) && len(example) < 3 {
184+ example = append(example, path)
185+ }
186+ }
187+ extra := ""
188+ if len(example) > 0 {
189+ extra = fmt.Sprintf(" (examples: %s)", strings.Join(example, ", "))
190+ }
191+ code := ""
192+ if bucket[0].Code != "" {
193+ code = " [" + bucket[0].Code + "]"
194+ }
195+ where := fmt.Sprintf("%d occurrences across %d files", len(bucket), len(paths))
196+ if len(paths) == 1 {
197+ where = fmt.Sprintf("%d occurrences in %s", len(bucket), example[0])
198+ }
199+ if len(paths) == 1 && len(lines) > 0 {
200+ where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3))
201+ }
202+ message := compactWhitespace(bucket[0].Message)
203+ if len(message) > 220 {
204+ message = message[:219] + "…"
205+ }
206+ return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra)
207+}
208+
209+func moduleSecondary(diagnostics []Diagnostic) map[string]bool {
210+ roots := make(map[string]bool)
211+ byFile := make(map[string][]Diagnostic)
212+ for _, diagnostic := range diagnostics {
213+ byFile[diagnostic.Path] = append(byFile[diagnostic.Path], diagnostic)
214+ if missingModuleName(diagnostic) != "" {
215+ roots[diagnostic.Path] = true
216+ }
217+ }
218+ secondary := make(map[string]bool)
219+ for path, fileDiagnostics := range byFile {
220+ if !roots[path] {
221+ continue
222+ }
223+ count := 0
224+ for _, diagnostic := range fileDiagnostics {
225+ if missingModuleName(diagnostic) == "" {
226+ count++
227+ }
228+ }
229+ if count < 2 {
230+ continue
231+ }
232+ for _, diagnostic := range fileDiagnostics {
233+ if missingModuleName(diagnostic) == "" {
234+ secondary[DiagnosticKey(diagnostic)] = true
235+ }
236+ }
237+ }
238+ return secondary
239+}
240+
241+func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) string {
242+ count := 0
243+ modules := make(map[string]bool)
244+ for _, diagnostic := range diagnostics {
245+ if diagnostic.Path == path && secondary[DiagnosticKey(diagnostic)] {
246+ count++
247+ }
248+ if diagnostic.Path == path && missingModuleName(diagnostic) != "" {
249+ modules[missingModuleName(diagnostic)] = true
250+ }
251+ }
252+ if count == 0 {
253+ return ""
254+ }
255+ shownPath := path
256+ if root != "" {
257+ if rel, err := filepathRel(root, path); err == nil {
258+ shownPath = rel
259+ }
260+ }
261+ moduleNames := make([]string, 0, len(modules))
262+ for name := range modules {
263+ moduleNames = append(moduleNames, name)
264+ }
265+ sort.Strings(moduleNames)
266+ return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", "))
267+}
268+
269+func missingModuleName(diagnostic Diagnostic) string {
270+ if strings.ToLower(diagnostic.Severity) != "error" {
271+ return ""
272+ }
273+ for _, pattern := range missingModulePatterns {
274+ if match := pattern.FindStringSubmatch(diagnostic.Message); len(match) > 1 {
275+ return strings.Trim(match[1], `"'`)
276+ }
277+ }
278+ return ""
279+}
280+
281+func uniquePaths(diagnostics []Diagnostic) int {
282+ paths := make(map[string]bool)
283+ for _, diagnostic := range diagnostics {
284+ paths[diagnostic.Path] = true
285+ }
286+ return len(paths)
287+}
288+
289+func plural(n int) string {
290+ if n == 1 {
291+ return ""
292+ }
293+ return "s"
294+}
295+func minInt(a, b int) int {
296+ if a < b {
297+ return a
298+ }
299+ return b
300+}
301+func contains(values []string, value string) bool {
302+ for _, item := range values {
303+ if item == value {
304+ return true
305+ }
306+ }
307+ return false
308+}
309+func joinInts(values map[int]bool, max int) string {
310+ ordered := make([]int, 0, len(values))
311+ for value := range values {
312+ ordered = append(ordered, value)
313+ }
314+ sort.Ints(ordered)
315+ if len(ordered) > max {
316+ ordered = ordered[:max]
317+ }
318+ parts := make([]string, len(ordered))
319+ for i, value := range ordered {
320+ parts[i] = fmt.Sprint(value)
321+ }
322+ return strings.Join(parts, ", ")
323+}
324+func filepathRel(root, path string) (string, error) {
325+ // Kept here rather than importing agent/tools: lsp is intentionally
326+ // independent of the tool package.
327+ rel, err := filepath.Rel(root, path)
328+ if err != nil {
329+ return "", err
330+ }
331+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
332+ return path, nil
333+ }
334+ return filepath.ToSlash(rel), nil
335+}
A packages/agent/lsp/edits.go +195 -0
@@ -0,0 +1,195 @@
1+package lsp
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+)
11+
12+const maxWorkspaceEditFileBytes = 8 * 1024 * 1024
13+
14+// ApplyWorkspaceEdit applies only text edits inside root. It rejects create,
15+// rename, delete, non-file URIs, symlink escapes, malformed ranges, and files
16+// larger than the bounded safety limit.
17+func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error {
18+ root, err := filepath.Abs(root)
19+ if err != nil {
20+ return err
21+ }
22+ rootReal, err := filepath.EvalSymlinks(root)
23+ if err != nil {
24+ return fmt.Errorf("workspace root: %w", err)
25+ }
26+ all := make(map[string][]TextEdit, len(edit.Changes)+len(edit.DocumentChanges))
27+ for uri, changes := range edit.Changes {
28+ all[uri] = append(all[uri], changes...)
29+ }
30+ for _, raw := range edit.DocumentChanges {
31+ var change struct {
32+ TextDocument struct {
33+ URI string `json:"uri"`
34+ } `json:"textDocument"`
35+ Edits []TextEdit `json:"edits"`
36+ Kind string `json:"kind"`
37+ }
38+ if err := json.Unmarshal(raw, &change); err != nil {
39+ return fmt.Errorf("workspace edit document change: %w", err)
40+ }
41+ if change.Kind != "" || change.TextDocument.URI == "" {
42+ return fmt.Errorf("workspace edit contains an unsupported resource operation")
43+ }
44+ all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...)
45+ }
46+ for uri, changes := range all {
47+ path, err := uriToPath(uri)
48+ if err != nil {
49+ return err
50+ }
51+ if err := safeWorkspacePath(rootReal, path); err != nil {
52+ return err
53+ }
54+ if err := applyTextEdits(path, changes); err != nil {
55+ return fmt.Errorf("apply edit to %s: %w", path, err)
56+ }
57+ }
58+ return nil
59+}
60+
61+func safeWorkspacePath(root, path string) error {
62+ abs, err := filepath.Abs(path)
63+ if err != nil {
64+ return err
65+ }
66+ candidate := abs
67+ if real, err := filepath.EvalSymlinks(abs); err == nil {
68+ candidate = real
69+ } else {
70+ parent := filepath.Dir(abs)
71+ if real, parentErr := filepath.EvalSymlinks(parent); parentErr == nil {
72+ candidate = filepath.Join(real, filepath.Base(abs))
73+ }
74+ }
75+ rel, err := filepath.Rel(root, candidate)
76+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
77+ return fmt.Errorf("workspace edit path %q is outside workspace", path)
78+ }
79+ return nil
80+}
81+
82+type byteEdit struct {
83+ start int
84+ end int
85+ text string
86+}
87+
88+func applyTextEdits(path string, edits []TextEdit) error {
89+ if len(edits) == 0 {
90+ return nil
91+ }
92+ info, err := os.Stat(path)
93+ if err != nil {
94+ return err
95+ }
96+ if info.Size() > maxWorkspaceEditFileBytes {
97+ return fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes)
98+ }
99+ data, err := os.ReadFile(path)
100+ if err != nil {
101+ return err
102+ }
103+ converted := make([]byteEdit, 0, len(edits))
104+ for _, edit := range edits {
105+ start, err := positionOffset(data, edit.Range.Start)
106+ if err != nil {
107+ return err
108+ }
109+ end, err := positionOffset(data, edit.Range.End)
110+ if err != nil {
111+ return err
112+ }
113+ if end < start {
114+ return fmt.Errorf("edit range ends before it starts")
115+ }
116+ converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText})
117+ }
118+ sort.Slice(converted, func(i, j int) bool {
119+ if converted[i].start != converted[j].start {
120+ return converted[i].start > converted[j].start
121+ }
122+ return converted[i].end > converted[j].end
123+ })
124+ for i := 1; i < len(converted); i++ {
125+ if converted[i].end > converted[i-1].start {
126+ return fmt.Errorf("workspace text edits overlap")
127+ }
128+ }
129+ out := append([]byte(nil), data...)
130+ for _, edit := range converted {
131+ out = append(append(append([]byte(nil), out[:edit.start]...), []byte(edit.text)...), out[edit.end:]...)
132+ }
133+ tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*")
134+ if err != nil {
135+ return err
136+ }
137+ tmpName := tmp.Name()
138+ defer func() { _ = os.Remove(tmpName) }()
139+ if err := tmp.Chmod(info.Mode().Perm()); err != nil {
140+ _ = tmp.Close()
141+ return err
142+ }
143+ if _, err := tmp.Write(out); err != nil {
144+ _ = tmp.Close()
145+ return err
146+ }
147+ if err := tmp.Sync(); err != nil {
148+ _ = tmp.Close()
149+ return err
150+ }
151+ if err := tmp.Close(); err != nil {
152+ return err
153+ }
154+ return os.Rename(tmpName, path)
155+}
156+
157+func positionOffset(data []byte, position Position) (int, error) {
158+ if position.Line < 0 || position.Character < 0 {
159+ return 0, fmt.Errorf("invalid LSP position %d:%d", position.Line, position.Character)
160+ }
161+ lineStart := 0
162+ line := 0
163+ for line < position.Line {
164+ next := strings.IndexByte(string(data[lineStart:]), '\n')
165+ if next < 0 {
166+ return 0, fmt.Errorf("LSP line %d is outside file", position.Line)
167+ }
168+ lineStart += next + 1
169+ line++
170+ }
171+ lineEnd := len(data)
172+ if next := strings.IndexByte(string(data[lineStart:]), '\n'); next >= 0 {
173+ lineEnd = lineStart + next
174+ }
175+ lineBytes := data[lineStart:lineEnd]
176+ if len(lineBytes) > 0 && lineBytes[len(lineBytes)-1] == '\r' {
177+ lineBytes = lineBytes[:len(lineBytes)-1]
178+ lineEnd--
179+ }
180+ units := 0
181+ for offset, r := range string(lineBytes) {
182+ if units == position.Character {
183+ return lineStart + offset, nil
184+ }
185+ if r > 0xffff {
186+ units += 2
187+ } else {
188+ units++
189+ }
190+ }
191+ if units == position.Character {
192+ return lineEnd, nil
193+ }
194+ return 0, fmt.Errorf("LSP character %d is outside line %d", position.Character, position.Line)
195+}
A packages/agent/lsp/framing.go +77 -0
@@ -0,0 +1,77 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "strconv"
10+ "strings"
11+)
12+
13+const maxMessageBytes = 32 * 1024 * 1024
14+
15+// WriteMessage writes one LSP Content-Length framed JSON-RPC message.
16+func WriteMessage(w io.Writer, payload []byte) error {
17+ if len(payload) > maxMessageBytes {
18+ return fmt.Errorf("LSP message is too large: %d bytes", len(payload))
19+ }
20+ header := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload)))
21+ if n, err := w.Write(header); err != nil {
22+ return err
23+ } else if n != len(header) {
24+ return io.ErrShortWrite
25+ }
26+ n, err := w.Write(payload)
27+ if err == nil && n != len(payload) {
28+ return io.ErrShortWrite
29+ }
30+ return err
31+}
32+
33+// ReadMessage reads one Content-Length framed LSP message. It accepts other
34+// headers (including Content-Type) and either CRLF or LF header endings.
35+func ReadMessage(r *bufio.Reader) ([]byte, error) {
36+ length := -1
37+ for {
38+ line, err := r.ReadString('\n')
39+ if err != nil {
40+ return nil, err
41+ }
42+ line = strings.TrimRight(line, "\r\n")
43+ if line == "" {
44+ break
45+ }
46+ key, value, ok := strings.Cut(line, ":")
47+ if !ok {
48+ return nil, fmt.Errorf("malformed LSP header %q", line)
49+ }
50+ if strings.EqualFold(strings.TrimSpace(key), "content-length") {
51+ n, err := strconv.Atoi(strings.TrimSpace(value))
52+ if err != nil || n < 0 {
53+ return nil, fmt.Errorf("invalid Content-Length %q", strings.TrimSpace(value))
54+ }
55+ length = n
56+ }
57+ }
58+ if length < 0 {
59+ return nil, errors.New("LSP message has no Content-Length")
60+ }
61+ if length > maxMessageBytes {
62+ return nil, fmt.Errorf("LSP message is too large: %d bytes", length)
63+ }
64+ payload := make([]byte, length)
65+ if _, err := io.ReadFull(r, payload); err != nil {
66+ return nil, err
67+ }
68+ return payload, nil
69+}
70+
71+// Frame is useful in tests and for callers which already have a complete
72+// message. It returns the exact wire representation.
73+func Frame(payload []byte) []byte {
74+ var b bytes.Buffer
75+ _ = WriteMessage(&b, payload)
76+ return b.Bytes()
77+}
A packages/agent/lsp/lsp_test.go +310 -0
@@ -0,0 +1,310 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+func TestFakeLSPProcess(t *testing.T) {
16+ if os.Getenv("ZOT_FAKE_LSP") != "1" {
17+ return
18+ }
19+ reader := bufio.NewReader(os.Stdin)
20+ for {
21+ payload, err := ReadMessage(reader)
22+ if err != nil {
23+ return
24+ }
25+ var message rpcEnvelope
26+ if json.Unmarshal(payload, &message) != nil {
27+ continue
28+ }
29+ switch message.Method {
30+ case "initialize":
31+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"capabilities":{"definitionProvider":true}}}`)
32+ _ = WriteMessage(os.Stdout, response)
33+ case "test/echo":
34+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"echo":true}}`)
35+ _ = WriteMessage(os.Stdout, response)
36+ case "shutdown":
37+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":null}`)
38+ _ = WriteMessage(os.Stdout, response)
39+ case "textDocument/didOpen":
40+ var params struct {
41+ TextDocument struct {
42+ URI string `json:"uri"`
43+ } `json:"textDocument"`
44+ }
45+ _ = json.Unmarshal(message.Params, &params)
46+ response := map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": map[string]any{"uri": params.TextDocument.URI, "diagnostics": []any{map[string]any{"range": map[string]any{"start": map[string]int{"line": 0, "character": 0}, "end": map[string]int{"line": 0, "character": 1}}, "severity": 1, "code": "FAKE", "message": "fake problem"}}}}
47+ data, _ := json.Marshal(response)
48+ _ = WriteMessage(os.Stdout, data)
49+ }
50+ }
51+}
52+
53+func TestClientFakeStdioRPC(t *testing.T) {
54+ root := t.TempDir()
55+ script := os.Args[0]
56+ spec := ServerConfig{ID: "fake", Kind: "lsp", Command: script, Args: []string{"-test.run=TestFakeLSPProcess"}, Env: map[string]string{"ZOT_FAKE_LSP": "1"}}
57+ diagnostics := make(chan []Diagnostic, 1)
58+ client, err := NewClientWithContext(context.Background(), spec, root, ClientOptions{OnDiagnostics: func(value []Diagnostic) { diagnostics <- value }})
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ t.Cleanup(func() { _ = client.Close() })
63+ result, err := client.Request(context.Background(), "test/echo", map[string]any{})
64+ if err != nil || !bytes.Contains(result, []byte(`"echo":true`)) {
65+ t.Fatalf("echo result = %s, err = %v", result, err)
66+ }
67+ path := filepath.Join(root, "main.go")
68+ if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+ if err := client.DidOpen(path, "go", "package main\n"); err != nil {
72+ t.Fatal(err)
73+ }
74+ select {
75+ case values := <-diagnostics:
76+ if len(values) != 1 || values[0].Code != "FAKE" || values[0].Path != path {
77+ t.Fatalf("diagnostics = %#v", values)
78+ }
79+ case <-time.After(2 * time.Second):
80+ t.Fatal("timed out waiting for fake diagnostics")
81+ }
82+}
83+
84+func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
85+ payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
86+ wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
87+ got, err := ReadMessage(bufio.NewReader(bytes.NewReader(wire)))
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ if !bytes.Equal(got, payload) {
92+ t.Fatalf("payload = %q, want %q", got, payload)
93+ }
94+}
95+
96+func TestLoadConfigMergesGlobalProjectAndBuiltins(t *testing.T) {
97+ root := t.TempDir()
98+ zotHome := t.TempDir()
99+ t.Setenv("ZOT_HOME", zotHome)
100+ if err := os.WriteFile(filepath.Join(zotHome, "lsp.json"), []byte(`{"servers":{"gopls":{"settings":{"global":true},"args":["--global"]}}}`), 0o644); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"gopls":{"command":"local-gopls","args":["--local"],"settings":{"local":true}},"sarif-lint":{"command":"reviewdog","kind":"cli","parser":"sarif","fileTypes":[".go"]}}`), 0o644); err != nil {
107+ t.Fatal(err)
108+ }
109+ config, err := LoadConfig(root)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ gopls := config.Servers["gopls"]
114+ if gopls.Command != "local-gopls" || len(gopls.Args) != 1 || gopls.Args[0] != "--local" {
115+ t.Fatalf("gopls override = %#v", gopls)
116+ }
117+ if len(gopls.FileTypes) == 0 || len(gopls.RootMarkers) == 0 {
118+ t.Fatalf("builtin selectors were not retained: %#v", gopls)
119+ }
120+ if gopls.Settings["global"] != true || gopls.Settings["local"] != true {
121+ t.Fatalf("settings did not merge: %#v", gopls.Settings)
122+ }
123+ if config.Servers["sarif-lint"].Kind != "cli" || config.Servers["sarif-lint"].Parser != "sarif" {
124+ t.Fatalf("custom linter = %#v", config.Servers["sarif-lint"])
125+ }
126+ selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
127+ var foundGopls, foundSarif bool
128+ for _, server := range selected {
129+ foundGopls = foundGopls || server.ID == "gopls"
130+ foundSarif = foundSarif || server.ID == "sarif-lint"
131+ }
132+ if !foundGopls || !foundSarif {
133+ t.Fatalf("selected servers = %#v", selected)
134+ }
135+}
136+
137+func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) {
138+ root := t.TempDir()
139+ zotHome := t.TempDir()
140+ t.Setenv("ZOT_HOME", zotHome)
141+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil {
142+ t.Fatal(err)
143+ }
144+ configFile := `{
145+ "$schema": "./node_modules/pi-lsp-bridge/schema.json",
146+ "autoDetect": true,
147+ "providers": [
148+ "gopls",
149+ {"id":"gopls","args":["serve"]},
150+ {"id":"bridge-lint","kind":"cli","command":"bridge-lint","fileTypes":[".go"],"rootMarkers":["go.mod"],"cli":{"parser":"generic","mode":"files"}}
151+ ]
152+ }`
153+ if err := os.WriteFile(filepath.Join(root, "pi-lsp-bridge.json"), []byte(configFile), 0o644); err != nil {
154+ t.Fatal(err)
155+ }
156+ config, err := LoadConfig(root)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if got := config.Servers["gopls"].Args; len(got) != 1 || got[0] != "serve" {
161+ t.Fatalf("gopls provider override = %#v", got)
162+ }
163+ lint := config.Servers["bridge-lint"]
164+ if lint.Kind != "cli" || lint.Parser != "generic" || lint.Mode != "files" {
165+ t.Fatalf("bridge provider = %#v", lint)
166+ }
167+}
168+
169+func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
170+ root := t.TempDir()
171+ t.Setenv("ZOT_HOME", t.TempDir())
172+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil {
173+ t.Fatal(err)
174+ }
175+ if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil {
176+ t.Fatal(err)
177+ }
178+ config, err := LoadConfig(root)
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
183+ if len(selected) != 1 || selected[0].ID != "gopls" {
184+ t.Fatalf("explicit providers with autoDetect=false = %#v", selected)
185+ }
186+}
187+
188+func TestResolveCommandPrefersWorkspaceTools(t *testing.T) {
189+ root := t.TempDir()
190+ bin := filepath.Join(root, "node_modules", ".bin")
191+ if err := os.MkdirAll(bin, 0o755); err != nil {
192+ t.Fatal(err)
193+ }
194+ command := filepath.Join(bin, "fake-lsp")
195+ if err := os.WriteFile(command, []byte("#!/bin/sh\n"), 0o755); err != nil {
196+ t.Fatal(err)
197+ }
198+ got, err := ResolveCommand(root, "fake-lsp")
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ if got != command {
203+ t.Fatalf("resolved %q, want %q", got, command)
204+ }
205+}
206+
207+func TestParsers(t *testing.T) {
208+ root := t.TempDir()
209+ eslint := `[ {"filePath":"src/a.js","messages":[{"ruleId":"no-x","severity":2,"message":"bad","line":2,"column":3}] } ]`
210+ got := ParseDiagnostics("eslint-json", "eslint", root, eslint)
211+ if len(got) != 1 || got[0].Severity != "error" || got[0].Range.Start.Line != 1 {
212+ t.Fatalf("eslint = %#v", got)
213+ }
214+ golangci := `{"Issues":[{"FromLinter":"govet","Text":"bad","Severity":"warning","Pos":{"Filename":"a.go","Line":3,"Column":4}}]}`
215+ if got = ParseDiagnostics("golangci-lint-json", "golangci", root, golangci); len(got) != 1 || got[0].Severity != "warning" {
216+ t.Fatalf("golangci = %#v", got)
217+ }
218+ ruff := `[{"code":"F401","message":"unused","filename":"a.py","location":{"row":1,"column":2}}]`
219+ if got = ParseDiagnostics("ruff-json", "ruff", root, ruff); len(got) != 1 || got[0].Code != "F401" {
220+ t.Fatalf("ruff = %#v", got)
221+ }
222+ sarif := `{"runs":[{"results":[{"ruleId":"R1","level":"error","message":{"text":"bad"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"a.c"},"region":{"startLine":4,"startColumn":2}}}]}]}]}`
223+ if got = ParseDiagnostics("sarif", "scan", root, sarif); len(got) != 1 || got[0].Range.Start.Line != 3 {
224+ t.Fatalf("sarif = %#v", got)
225+ }
226+ if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" {
227+ t.Fatalf("generic = %#v", got)
228+ }
229+}
230+
231+func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) {
232+ path := filepath.Join(t.TempDir(), "main.go")
233+ base := Diagnostic{Path: path, Severity: "error", Code: "E1", Message: "same", Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 1, Character: 3}}}
234+ endChanged := base
235+ endChanged.Range.End.Character = 20
236+ otherCode := base
237+ otherCode.Code = "E2"
238+ otherStart := base
239+ otherStart.Range.Start.Line++
240+ if got := DeduplicateDiagnostics([]Diagnostic{base, endChanged, otherCode, otherStart}); len(got) != 3 {
241+ t.Fatalf("deduplicated diagnostics = %#v, want three reports", got)
242+ }
243+}
244+
245+func TestDiagnosticsDedupAndGrouping(t *testing.T) {
246+ root := t.TempDir()
247+ makeDiag := func(path, message string, line int, server string) Diagnostic {
248+ return Diagnostic{Path: filepath.Join(root, path), Severity: "error", Code: "E", Message: message, Server: server, Range: Range{Start: Position{Line: line}}}
249+ }
250+ input := []Diagnostic{makeDiag("a.go", "same", 0, "gopls"), makeDiag("a.go", "same", 0, "eslint"), makeDiag("a.go", `Cannot find module "missing"`, 1, "gopls"), makeDiag("a.go", "cascade one", 2, "gopls"), makeDiag("a.go", "cascade two", 3, "gopls"), makeDiag("b.go", "repeat", 1, "a"), makeDiag("c.go", "repeat", 2, "b"), makeDiag("d.go", "repeat", 3, "c")}
251+ if got := DeduplicateDiagnostics(input); len(got) != len(input)-1 {
252+ t.Fatalf("dedup count = %d", len(got))
253+ }
254+ text := SummarizeDiagnostics(input, root, 50)
255+ if !strings.Contains(text, "repeated") || !strings.Contains(text, "SECONDARY") {
256+ t.Fatalf("summary lacks grouping:\n%s", text)
257+ }
258+}
259+
260+func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) {
261+ root := t.TempDir()
262+ path := filepath.Join(root, "a.txt")
263+ if err := os.WriteFile(path, []byte("a😀b\n"), 0o644); err != nil {
264+ t.Fatal(err)
265+ }
266+ uri := URIForPath(path)
267+ ed := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: Range{Start: Position{Line: 0, Character: 1}, End: Position{Line: 0, Character: 3}}, NewText: "X"}}}}
268+ if err := ApplyWorkspaceEdit(root, ed); err != nil {
269+ t.Fatal(err)
270+ }
271+ data, _ := os.ReadFile(path)
272+ if string(data) != "aXb\n" {
273+ t.Fatalf("edited data = %q", data)
274+ }
275+ outside := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(filepath.Join(filepath.Dir(root), "outside.txt")): {{Range: Range{}, NewText: "x"}}}}
276+ if err := ApplyWorkspaceEdit(root, outside); err == nil {
277+ t.Fatal("outside edit was accepted")
278+ }
279+}
280+
281+func TestDiagnosticJSONCodeNumber(t *testing.T) {
282+ var diagnostic Diagnostic
283+ if err := json.Unmarshal([]byte(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"code":123,"message":"warning"}`), &diagnostic); err != nil {
284+ t.Fatal(err)
285+ }
286+ if diagnostic.Code != "123" || diagnostic.Severity != "warning" {
287+ t.Fatalf("diagnostic = %#v", diagnostic)
288+ }
289+}
290+
291+func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) {
292+ manager := NewManager()
293+ root := t.TempDir()
294+ path := filepath.Join(root, "main.go")
295+ first := Diagnostic{Path: path, Severity: "error", Message: "type mismatch", Range: Range{Start: Position{Line: 1, Character: 2}}}
296+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{first}); len(got) != 1 {
297+ t.Fatalf("first reduction returned %d diagnostics, want 1", len(got))
298+ }
299+ shifted := first
300+ shifted.Range.Start.Line = 20
301+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 0 {
302+ t.Fatalf("shifted diagnostic was replayed: %#v", got)
303+ }
304+ if got := manager.ReduceDiagnostics(root, path, nil); len(got) != 0 {
305+ t.Fatalf("clear returned %#v", got)
306+ }
307+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 1 {
308+ t.Fatalf("diagnostic did not reappear after clear: %#v", got)
309+ }
310+}
A packages/agent/lsp/manager.go +783 -0
@@ -0,0 +1,783 @@
1+package lsp
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+ "time"
15+)
16+
17+// ManagerOptions controls process and command timeouts.
18+type ManagerOptions struct {
19+ RequestTimeout time.Duration
20+ DiagnosticDelay time.Duration
21+ LinterTimeout time.Duration
22+ Settings map[string]any
23+ OnLog func(server, message string)
24+}
25+
26+// Response is one raw request result tagged with the server which produced
27+// it. Errors are retained so a partially installed workspace remains useful.
28+type Response struct {
29+ Server string
30+ Result json.RawMessage
31+ Error error
32+}
33+
34+type workspace struct {
35+ cwd string
36+ config Config
37+ clients map[string]*Client
38+ starting map[string]chan struct{}
39+ statuses map[string]string
40+ errors map[string]string
41+ diagnostics map[string]map[string][]Diagnostic
42+ cliDiagnostics map[string][]Diagnostic
43+ opened map[string]map[string]string // server -> path -> content hash
44+ surfaced map[string]map[string]bool // path -> location-independent diagnostic identities
45+ logs []string
46+}
47+
48+// Manager owns per-workspace LSP processes. A process is cached by cwd and
49+// server ID until Close or Reload, so a series of model calls does not spawn a
50+// new language server for every request.
51+type Manager struct {
52+ mu sync.Mutex
53+ workspaces map[string]*workspace
54+ options ManagerOptions
55+}
56+
57+func NewManager() *Manager {
58+ return NewManagerWithOptions(ManagerOptions{})
59+}
60+
61+func NewManagerWithOptions(options ManagerOptions) *Manager {
62+ if options.RequestTimeout <= 0 {
63+ options.RequestTimeout = 12 * time.Second
64+ }
65+ if options.DiagnosticDelay <= 0 {
66+ options.DiagnosticDelay = 300 * time.Millisecond
67+ }
68+ if options.LinterTimeout <= 0 {
69+ options.LinterTimeout = 30 * time.Second
70+ }
71+ return &Manager{workspaces: make(map[string]*workspace), options: options}
72+}
73+
74+func (m *Manager) workspace(cwd string) (*workspace, error) {
75+ cwd, err := absDir(cwd)
76+ if err != nil {
77+ return nil, err
78+ }
79+ m.mu.Lock()
80+ if value := m.workspaces[cwd]; value != nil {
81+ m.mu.Unlock()
82+ return value, nil
83+ }
84+ m.mu.Unlock()
85+ config, err := LoadConfig(cwd)
86+ if err != nil {
87+ return nil, err
88+ }
89+ value := &workspace{cwd: cwd, config: config, clients: make(map[string]*Client), starting: make(map[string]chan struct{}), statuses: make(map[string]string), errors: make(map[string]string), diagnostics: make(map[string]map[string][]Diagnostic), cliDiagnostics: make(map[string][]Diagnostic), opened: make(map[string]map[string]string), surfaced: make(map[string]map[string]bool)}
90+ m.mu.Lock()
91+ if existing := m.workspaces[cwd]; existing != nil {
92+ value = existing
93+ } else {
94+ m.workspaces[cwd] = value
95+ }
96+ m.mu.Unlock()
97+ return value, nil
98+}
99+
100+// Config returns the effective merged configuration for cwd.
101+func (m *Manager) Config(cwd string) (Config, error) {
102+ ws, err := m.workspace(cwd)
103+ if err != nil {
104+ return Config{}, err
105+ }
106+ return ws.config, nil
107+}
108+
109+func (m *Manager) applicable(ws *workspace, path string) []ServerConfig {
110+ return ws.config.ApplicableServers(ws.cwd, path)
111+}
112+
113+func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerConfig) (*Client, error) {
114+ if ctx == nil {
115+ ctx = context.Background()
116+ }
117+ for {
118+ m.mu.Lock()
119+ if client := ws.clients[spec.ID]; client != nil {
120+ m.mu.Unlock()
121+ return client, nil
122+ }
123+ if waiting := ws.starting[spec.ID]; waiting != nil {
124+ m.mu.Unlock()
125+ select {
126+ case <-waiting:
127+ continue
128+ case <-ctx.Done():
129+ return nil, ctx.Err()
130+ }
131+ }
132+ starting := make(chan struct{})
133+ ws.starting[spec.ID] = starting
134+ ws.statuses[spec.ID] = "starting"
135+ m.mu.Unlock()
136+ root := FindRoot(ws.cwd, spec.RootMarkers)
137+ opts := ClientOptions{
138+ Settings: wsSettings(m.options.Settings, spec.Settings),
139+ InitializationOptions: spec.InitializationOptions,
140+ OnDiagnostics: func(diagnostics []Diagnostic) { m.storeDiagnostics(ws, spec.ID, diagnostics) },
141+ OnLog: func(message string) { m.log(ws, spec.ID, message) },
142+ // A server's LSP root may be an ancestor of the session cwd.
143+ // Keep server-initiated edits inside the session workspace rather
144+ // than trusting that broader project root.
145+ ApplyEdit: func(edit WorkspaceEdit) error { return ApplyWorkspaceEdit(ws.cwd, edit) },
146+ }
147+ startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
148+ client, err := NewClientWithContext(startCtx, spec, root, opts)
149+ cancel()
150+ m.mu.Lock()
151+ delete(ws.starting, spec.ID)
152+ close(starting)
153+ if err != nil {
154+ ws.statuses[spec.ID] = "missing"
155+ ws.errors[spec.ID] = err.Error()
156+ m.mu.Unlock()
157+ return nil, err
158+ }
159+ if existing := ws.clients[spec.ID]; existing != nil {
160+ m.mu.Unlock()
161+ _ = client.Close()
162+ return existing, nil
163+ }
164+ ws.clients[spec.ID] = client
165+ ws.statuses[spec.ID] = "ready"
166+ delete(ws.errors, spec.ID)
167+ m.mu.Unlock()
168+ return client, nil
169+ }
170+}
171+
172+func wsSettings(global, local map[string]any) map[string]any {
173+ if len(global) == 0 && len(local) == 0 {
174+ return nil
175+ }
176+ out := make(map[string]any, len(global)+len(local))
177+ for key, value := range global {
178+ out[key] = value
179+ }
180+ for key, value := range local {
181+ out[key] = value
182+ }
183+ return out
184+}
185+
186+func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
187+ byPath := make(map[string][]Diagnostic)
188+ for _, diagnostic := range diagnostics {
189+ if diagnostic.Path == "" || !diagnosticInWorkspace(ws.cwd, diagnostic.Path) {
190+ continue
191+ }
192+ byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
193+ }
194+ m.mu.Lock()
195+ if ws.diagnostics[server] == nil {
196+ ws.diagnostics[server] = make(map[string][]Diagnostic)
197+ }
198+ for path, values := range byPath {
199+ if len(values) == 1 && values[0].Message == "" && values[0].Code == "" {
200+ delete(ws.diagnostics[server], path)
201+ continue
202+ }
203+ ws.diagnostics[server][path] = values
204+ }
205+ m.mu.Unlock()
206+}
207+
208+func (m *Manager) log(ws *workspace, server, message string) {
209+ message = strings.TrimSpace(message)
210+ if message == "" {
211+ return
212+ }
213+ if len(message) > 4096 {
214+ message = message[:4095] + "…"
215+ }
216+ m.mu.Lock()
217+ ws.logs = append(ws.logs, server+": "+message)
218+ if len(ws.logs) > 100 {
219+ ws.logs = ws.logs[len(ws.logs)-100:]
220+ }
221+ m.mu.Unlock()
222+ if m.options.OnLog != nil {
223+ m.options.OnLog(server, message)
224+ }
225+}
226+
227+// Open opens a document in every applicable LSP server.
228+func (m *Manager) Open(ctx context.Context, cwd, path string) error {
229+ ws, err := m.workspace(cwd)
230+ if err != nil {
231+ return err
232+ }
233+ path, err = absoluteFile(ws.cwd, path)
234+ if err != nil {
235+ return err
236+ }
237+ text, err := os.ReadFile(path)
238+ if err != nil {
239+ return err
240+ }
241+ for _, spec := range m.applicable(ws, path) {
242+ if spec.Kind != "lsp" {
243+ continue
244+ }
245+ client, clientErr := m.ensureClient(ctx, ws, spec)
246+ if clientErr != nil {
247+ if err == nil {
248+ err = clientErr
249+ }
250+ continue
251+ }
252+ hash := string(text)
253+ m.mu.Lock()
254+ if ws.opened[spec.ID] == nil {
255+ ws.opened[spec.ID] = make(map[string]string)
256+ }
257+ old, open := ws.opened[spec.ID][path]
258+ m.mu.Unlock()
259+ if !open {
260+ languageID := spec.LanguageID
261+ if languageID == "" {
262+ languageID = languageForExtension(filepath.Ext(path))
263+ }
264+ if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil {
265+ m.mu.Lock()
266+ ws.opened[spec.ID][path] = hash
267+ m.mu.Unlock()
268+ }
269+ } else if old != hash {
270+ if clientErr = client.DidChange(path, string(text)); clientErr == nil {
271+ m.mu.Lock()
272+ ws.opened[spec.ID][path] = hash
273+ m.mu.Unlock()
274+ }
275+ }
276+ if clientErr != nil && err == nil {
277+ err = clientErr
278+ }
279+ }
280+ return err
281+}
282+
283+func (m *Manager) Save(ctx context.Context, cwd, path string) error {
284+ openErr := m.Open(ctx, cwd, path)
285+ ws, err := m.workspace(cwd)
286+ if err != nil {
287+ return err
288+ }
289+ path, err = absoluteFile(ws.cwd, path)
290+ if err != nil {
291+ return err
292+ }
293+ text, err := os.ReadFile(path)
294+ if err != nil {
295+ return err
296+ }
297+ for _, spec := range m.applicable(ws, path) {
298+ if spec.Kind != "lsp" {
299+ continue
300+ }
301+ m.mu.Lock()
302+ client := ws.clients[spec.ID]
303+ m.mu.Unlock()
304+ if client != nil {
305+ _ = client.DidSave(path, string(text))
306+ }
307+ }
308+ return openErr
309+}
310+
311+// Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits
312+// only a bounded debounce interval for asynchronous publishDiagnostics.
313+func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) {
314+ return m.DiagnosticsWithOptions(ctx, cwd, path, true)
315+}
316+
317+func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) {
318+ ws, err := m.workspace(cwd)
319+ if err != nil {
320+ return nil, err
321+ }
322+ var firstErr error
323+ if path != "" {
324+ if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) {
325+ // Keep cached diagnostics, but make an unavailable or failed
326+ // server visible to the model-facing lsp action.
327+ firstErr = saveErr
328+ }
329+ }
330+ for _, spec := range m.applicable(ws, path) {
331+ if spec.Kind != "lsp" {
332+ continue
333+ }
334+ m.mu.Lock()
335+ client := ws.clients[spec.ID]
336+ m.mu.Unlock()
337+ if client != nil {
338+ if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) {
339+ firstErr = waitErr
340+ }
341+ }
342+ }
343+ if runCLI {
344+ if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil {
345+ firstErr = err
346+ }
347+ }
348+ return m.snapshot(ws, path), firstErr
349+}
350+
351+// ReduceDiagnostics returns only diagnostics that have not already been
352+// surfaced for this workspace path. The ledger deliberately ignores line
353+// and column changes, matching the behavior users want after a harmless edit:
354+// unchanged problems do not consume another tool-result context window.
355+// Passing an empty snapshot clears the path so a later recurrence is fresh.
356+func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic {
357+ ws, err := m.workspace(cwd)
358+ if err != nil {
359+ return diagnostics
360+ }
361+ path, err = absoluteFile(ws.cwd, path)
362+ if err != nil {
363+ return diagnostics
364+ }
365+ m.mu.Lock()
366+ defer m.mu.Unlock()
367+ if len(diagnostics) == 0 {
368+ delete(ws.surfaced, path)
369+ return nil
370+ }
371+ previous := ws.surfaced[path]
372+ current := make(map[string]bool, len(diagnostics))
373+ fresh := make([]Diagnostic, 0, len(diagnostics))
374+ for _, diagnostic := range diagnostics {
375+ identity := DiagnosticIdentity(diagnostic)
376+ current[identity] = true
377+ if !previous[identity] {
378+ fresh = append(fresh, diagnostic)
379+ }
380+ }
381+ ws.surfaced[path] = current
382+ return fresh
383+}
384+
385+func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic {
386+ m.mu.Lock()
387+ defer m.mu.Unlock()
388+ var all []Diagnostic
389+ for _, files := range ws.diagnostics {
390+ for file, diagnostics := range files {
391+ if path == "" || samePath(file, path) {
392+ all = append(all, diagnostics...)
393+ }
394+ }
395+ }
396+ for _, diagnostics := range ws.cliDiagnostics {
397+ for _, diagnostic := range diagnostics {
398+ if path == "" || samePath(diagnostic.Path, path) {
399+ all = append(all, diagnostic)
400+ }
401+ }
402+ }
403+ return DeduplicateDiagnostics(all)
404+}
405+
406+// Request sends one request to a named LSP server.
407+func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) {
408+ ws, err := m.workspace(cwd)
409+ if err != nil {
410+ return nil, err
411+ }
412+ spec, ok := ws.config.Servers[server]
413+ if !ok || spec.Disabled {
414+ return nil, fmt.Errorf("unknown or disabled LSP server %q", server)
415+ }
416+ if spec.Kind != "lsp" || spec.IsLinter {
417+ return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server)
418+ }
419+ client, err := m.ensureClient(ctx, ws, spec)
420+ if err != nil {
421+ return nil, err
422+ }
423+ return m.requestWithTimeout(ctx, client, method, params)
424+}
425+
426+func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) {
427+ if ctx == nil {
428+ ctx = context.Background()
429+ }
430+ requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
431+ defer cancel()
432+ return client.Request(requestCtx, method, params)
433+}
434+
435+// RequestAll sends a request to every applicable LSP server. If path is set,
436+// the document is opened first so servers have current text and URI context.
437+func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response {
438+ ws, err := m.workspace(cwd)
439+ if err != nil {
440+ return []Response{{Error: err}}
441+ }
442+ if path != "" {
443+ _ = m.Open(ctx, ws.cwd, path)
444+ }
445+ var responses []Response
446+ for _, spec := range m.applicable(ws, path) {
447+ if spec.Kind != "lsp" || spec.IsLinter {
448+ continue
449+ }
450+ client, clientErr := m.ensureClient(ctx, ws, spec)
451+ if clientErr != nil {
452+ responses = append(responses, Response{Server: spec.ID, Error: clientErr})
453+ continue
454+ }
455+ result, requestErr := m.requestWithTimeout(ctx, client, method, params)
456+ responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr})
457+ }
458+ return responses
459+}
460+
461+// ApplyEdit applies a workspace edit under the selected workspace root.
462+func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error {
463+ ws, err := m.workspace(cwd)
464+ if err != nil {
465+ return err
466+ }
467+ return ApplyWorkspaceEdit(ws.cwd, edit)
468+}
469+
470+// Status describes configured and cached servers without starting missing
471+// processes. It is safe to call for a workspace with no installed tools.
472+type ServerStatus struct {
473+ ID string `json:"id"`
474+ Kind string `json:"kind"`
475+ Command string `json:"command"`
476+ Root string `json:"root,omitempty"`
477+ State string `json:"state"`
478+ Error string `json:"error,omitempty"`
479+ Diagnostics int `json:"diagnostics,omitempty"`
480+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
481+}
482+
483+func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
484+ ws, err := m.workspace(cwd)
485+ if err != nil {
486+ return nil, err
487+ }
488+ m.mu.Lock()
489+ defer m.mu.Unlock()
490+ out := make([]ServerStatus, 0)
491+ for _, spec := range m.applicable(ws, path) {
492+ status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers)), State: ws.statuses[spec.ID]}
493+ if status.State == "" {
494+ status.State = "stopped"
495+ }
496+ if status.State == "stopped" {
497+ if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
498+ status.State = "missing"
499+ status.Error = resolveErr.Error()
500+ }
501+ }
502+ if client := ws.clients[spec.ID]; client != nil {
503+ status.Capabilities = client.Capabilities()
504+ }
505+ for _, values := range ws.diagnostics[spec.ID] {
506+ status.Diagnostics += len(values)
507+ }
508+ status.Diagnostics += len(ws.cliDiagnostics[spec.ID])
509+ if configuredErr := ws.errors[spec.ID]; configuredErr != "" {
510+ status.Error = configuredErr
511+ }
512+ out = append(out, status)
513+ }
514+ return out, nil
515+}
516+
517+func (m *Manager) Capabilities(cwd, path, server string) (map[string]json.RawMessage, error) {
518+ ws, err := m.workspace(cwd)
519+ if err != nil {
520+ return nil, err
521+ }
522+ ensure := func(spec ServerConfig) (*Client, error) {
523+ if spec.Kind != "lsp" || spec.IsLinter {
524+ return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID)
525+ }
526+ m.mu.Lock()
527+ client := ws.clients[spec.ID]
528+ m.mu.Unlock()
529+ if client != nil {
530+ return client, nil
531+ }
532+ return m.ensureClient(context.Background(), ws, spec)
533+ }
534+ if server != "" {
535+ spec, ok := ws.config.Servers[server]
536+ if !ok || spec.Disabled {
537+ return nil, fmt.Errorf("unknown or disabled server %q", server)
538+ }
539+ client, err := ensure(spec)
540+ if err != nil {
541+ return nil, err
542+ }
543+ return map[string]json.RawMessage{server: client.Capabilities()}, nil
544+ }
545+ statuses, err := m.Status(cwd, path)
546+ if err != nil {
547+ return nil, err
548+ }
549+ out := make(map[string]json.RawMessage)
550+ var firstErr error
551+ for _, status := range statuses {
552+ spec := ws.config.Servers[status.ID]
553+ if spec.Kind != "lsp" || spec.IsLinter {
554+ continue
555+ }
556+ client, clientErr := ensure(spec)
557+ if clientErr != nil {
558+ if firstErr == nil {
559+ firstErr = clientErr
560+ }
561+ continue
562+ }
563+ if capabilities := client.Capabilities(); len(capabilities) > 0 {
564+ out[status.ID] = capabilities
565+ }
566+ }
567+ return out, firstErr
568+}
569+
570+// Reload terminates cached processes for cwd and reloads all lsp.json layers.
571+func (m *Manager) Reload(cwd string) error {
572+ cwd, err := absDir(cwd)
573+ if err != nil {
574+ return err
575+ }
576+ m.mu.Lock()
577+ ws := m.workspaces[cwd]
578+ delete(m.workspaces, cwd)
579+ m.mu.Unlock()
580+ if ws != nil {
581+ for _, client := range m.clientsSnapshot(ws) {
582+ _ = client.Close()
583+ }
584+ }
585+ return nil
586+}
587+
588+func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
589+ m.mu.Lock()
590+ defer m.mu.Unlock()
591+ clients := make([]*Client, 0, len(ws.clients))
592+ for _, client := range ws.clients {
593+ clients = append(clients, client)
594+ }
595+ return clients
596+}
597+
598+func (m *Manager) Close() error {
599+ m.mu.Lock()
600+ workspaces := make([]*workspace, 0, len(m.workspaces))
601+ for _, ws := range m.workspaces {
602+ workspaces = append(workspaces, ws)
603+ }
604+ m.workspaces = make(map[string]*workspace)
605+ m.mu.Unlock()
606+ var first error
607+ for _, ws := range workspaces {
608+ for _, client := range m.clientsSnapshot(ws) {
609+ if err := client.Close(); err != nil && first == nil {
610+ first = err
611+ }
612+ }
613+ }
614+ return first
615+}
616+
617+// RunLinters executes configured CLI providers without a shell. A non-zero
618+// linter exit is normal when it emitted parseable findings.
619+func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
620+ if ctx == nil {
621+ ctx = context.Background()
622+ }
623+ ws, err := m.workspace(cwd)
624+ if err != nil {
625+ return err
626+ }
627+ var files []string
628+ if path != "" {
629+ if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil {
630+ files = []string{abs}
631+ }
632+ }
633+ var firstErr error
634+ for _, spec := range m.applicable(ws, path) {
635+ if spec.Kind != "cli" || spec.Disabled {
636+ continue
637+ }
638+ command, resolveErr := ResolveCommand(ws.cwd, spec.Command)
639+ if resolveErr != nil {
640+ m.mu.Lock()
641+ ws.statuses[spec.ID] = "missing"
642+ ws.errors[spec.ID] = resolveErr.Error()
643+ m.mu.Unlock()
644+ if firstErr == nil {
645+ firstErr = resolveErr
646+ }
647+ continue
648+ }
649+ args := make([]string, 0, len(spec.Args)+len(files))
650+ containsFiles := false
651+ relative := relativeFiles(ws.cwd, files)
652+ for _, arg := range spec.Args {
653+ if arg == "{files}" {
654+ containsFiles = true
655+ args = append(args, relative...)
656+ continue
657+ }
658+ if strings.Contains(arg, "{files}") {
659+ containsFiles = true
660+ args = append(args, strings.ReplaceAll(arg, "{files}", strings.Join(relative, " ")))
661+ continue
662+ }
663+ args = append(args, arg)
664+ }
665+ if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) {
666+ if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") {
667+ args = append(args, ".")
668+ }
669+ } else if !containsFiles {
670+ args = append(args, relativeFiles(ws.cwd, files)...)
671+ }
672+ commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
673+ cmd := exec.CommandContext(commandCtx, command, args...)
674+ cmd.Dir = ws.cwd
675+ cmd.Env = mergedEnvironment(spec.Env)
676+ var stdout, stderr limitedBuffer
677+ stdout.limit = 8 * 1024 * 1024
678+ stderr.limit = 8 * 1024 * 1024
679+ cmd.Stdout = &stdout
680+ cmd.Stderr = &stderr
681+ m.mu.Lock()
682+ ws.statuses[spec.ID] = "running"
683+ m.mu.Unlock()
684+ runErr := cmd.Run()
685+ cancel()
686+ text := strings.TrimSpace(stdout.String())
687+ if text == "" {
688+ text = strings.TrimSpace(stderr.String())
689+ }
690+ diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
691+ filtered := diagnostics[:0]
692+ for _, diagnostic := range diagnostics {
693+ if diagnostic.Path != "" && diagnosticInWorkspace(ws.cwd, diagnostic.Path) {
694+ filtered = append(filtered, diagnostic)
695+ }
696+ }
697+ diagnostics = filtered
698+ m.mu.Lock()
699+ ws.cliDiagnostics[spec.ID] = diagnostics
700+ if runErr != nil && len(diagnostics) == 0 {
701+ ws.errors[spec.ID] = runErr.Error()
702+ ws.statuses[spec.ID] = "error"
703+ } else {
704+ delete(ws.errors, spec.ID)
705+ ws.statuses[spec.ID] = "ready"
706+ }
707+ m.mu.Unlock()
708+ if runErr != nil && len(diagnostics) == 0 && firstErr == nil {
709+ firstErr = fmt.Errorf("%s: %w", spec.ID, runErr)
710+ }
711+ }
712+ return firstErr
713+}
714+
715+func displayWorkspacePath(root, path string) string {
716+ if path == "" {
717+ return ""
718+ }
719+ rel, err := filepath.Rel(root, path)
720+ if err != nil {
721+ return ""
722+ }
723+ if rel == "." {
724+ return "."
725+ }
726+ return filepath.ToSlash(rel)
727+}
728+
729+func relativeFiles(cwd string, files []string) []string {
730+ out := make([]string, len(files))
731+ for i, path := range files {
732+ rel, err := filepath.Rel(cwd, path)
733+ if err != nil {
734+ out[i] = path
735+ } else {
736+ out[i] = filepath.ToSlash(rel)
737+ }
738+ }
739+ return out
740+}
741+func absoluteFile(cwd, path string) (string, error) {
742+ if path == "" {
743+ return "", errors.New("path is required")
744+ }
745+ if !filepath.IsAbs(path) {
746+ path = filepath.Join(cwd, path)
747+ }
748+ return filepath.Abs(path)
749+}
750+func diagnosticInWorkspace(root, path string) bool {
751+ rootReal, err := filepath.EvalSymlinks(root)
752+ if err != nil {
753+ rootReal, err = filepath.Abs(root)
754+ if err != nil {
755+ return false
756+ }
757+ }
758+ return safeWorkspacePath(rootReal, path) == nil
759+}
760+
761+func samePath(a, b string) bool {
762+ aa, _ := filepath.Abs(a)
763+ bb, _ := filepath.Abs(b)
764+ return filepath.Clean(aa) == filepath.Clean(bb)
765+}
766+
767+type limitedBuffer struct {
768+ bytes.Buffer
769+ limit int
770+}
771+
772+func (b *limitedBuffer) Write(p []byte) (int, error) {
773+ original := len(p)
774+ if b.limit <= b.Len() {
775+ return original, nil
776+ }
777+ room := b.limit - b.Len()
778+ if len(p) > room {
779+ p = p[:room]
780+ }
781+ _, _ = b.Buffer.Write(p)
782+ return original, nil
783+}
A packages/agent/lsp/parsers.go +276 -0
@@ -0,0 +1,276 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "fmt"
7+ "path/filepath"
8+ "regexp"
9+ "strconv"
10+ "strings"
11+)
12+
13+// ParseDiagnostics parses output from one of the built-in CLI linter formats.
14+// Unknown parser names use the generic file:line:column parser.
15+func ParseDiagnostics(parser, provider, cwd, output string) []Diagnostic {
16+ switch strings.ToLower(strings.TrimSpace(parser)) {
17+ case "eslint-json", "eslint":
18+ return parseESLint(provider, cwd, output)
19+ case "golangci-lint-json", "golangci", "golangci-json":
20+ return parseGolangCILint(provider, cwd, output)
21+ case "ruff-json", "ruff":
22+ return parseRuff(provider, cwd, output)
23+ case "sarif":
24+ return parseSARIF(provider, cwd, output)
25+ default:
26+ return parseGeneric(provider, cwd, output)
27+ }
28+}
29+
30+func parseESLint(provider, cwd, output string) []Diagnostic {
31+ var results []struct {
32+ FilePath string `json:"filePath"`
33+ Messages []struct {
34+ RuleID string `json:"ruleId"`
35+ Severity int `json:"severity"`
36+ Message string `json:"message"`
37+ Line int `json:"line"`
38+ Column int `json:"column"`
39+ EndLine int `json:"endLine"`
40+ EndColumn int `json:"endColumn"`
41+ } `json:"messages"`
42+ }
43+ if json.Unmarshal([]byte(output), &results) != nil {
44+ return parseGeneric(provider, cwd, output)
45+ }
46+ var out []Diagnostic
47+ for _, result := range results {
48+ path := normalizeDiagnosticPath(cwd, result.FilePath)
49+ for _, message := range result.Messages {
50+ severity := "info"
51+ if message.Severity >= 2 {
52+ severity = "error"
53+ } else if message.Severity == 1 {
54+ severity = "warning"
55+ }
56+ out = append(out, Diagnostic{Path: path, Server: provider, Source: provider, Severity: severity, Code: message.RuleID, Message: message.Message, Range: oneBasedRange(message.Line, message.Column, message.EndLine, message.EndColumn)})
57+ }
58+ }
59+ return out
60+}
61+
62+func parseGolangCILint(provider, cwd, output string) []Diagnostic {
63+ var root struct {
64+ Issues []struct {
65+ FromLinter string `json:"FromLinter"`
66+ Text string `json:"Text"`
67+ Severity string `json:"Severity"`
68+ Pos struct {
69+ Filename string `json:"Filename"`
70+ Line int `json:"Line"`
71+ Column int `json:"Column"`
72+ Offset int `json:"Offset"`
73+ } `json:"Pos"`
74+ LineRange *struct {
75+ From int `json:"From"`
76+ To int `json:"To"`
77+ } `json:"LineRange"`
78+ } `json:"Issues"`
79+ }
80+ if json.Unmarshal([]byte(output), &root) != nil {
81+ return parseGeneric(provider, cwd, output)
82+ }
83+ var out []Diagnostic
84+ for _, issue := range root.Issues {
85+ severity := normalizeSeverity(issue.Severity)
86+ endLine := issue.Pos.Line
87+ if issue.LineRange != nil && issue.LineRange.To > endLine {
88+ endLine = issue.LineRange.To
89+ }
90+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, issue.Pos.Filename), Server: provider, Source: issue.FromLinter, Severity: severity, Code: issue.FromLinter, Message: issue.Text, Range: oneBasedRange(issue.Pos.Line, issue.Pos.Column, endLine, issue.Pos.Column)})
91+ }
92+ return out
93+}
94+
95+func parseRuff(provider, cwd, output string) []Diagnostic {
96+ var records []struct {
97+ Code string `json:"code"`
98+ Message string `json:"message"`
99+ Filename string `json:"filename"`
100+ Location struct {
101+ Row int `json:"row"`
102+ Column int `json:"column"`
103+ } `json:"location"`
104+ EndLocation struct {
105+ Row int `json:"row"`
106+ Column int `json:"column"`
107+ } `json:"end_location"`
108+ Fix any `json:"fix"`
109+ }
110+ if json.Unmarshal([]byte(output), &records) != nil {
111+ return parseGeneric(provider, cwd, output)
112+ }
113+ var out []Diagnostic
114+ for _, record := range records {
115+ endLine, endColumn := record.EndLocation.Row, record.EndLocation.Column
116+ if endLine == 0 {
117+ endLine, endColumn = record.Location.Row, record.Location.Column
118+ }
119+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, record.Filename), Server: provider, Source: provider, Severity: "error", Code: record.Code, Message: record.Message, Range: oneBasedRange(record.Location.Row, record.Location.Column, endLine, endColumn)})
120+ }
121+ return out
122+}
123+
124+func parseSARIF(provider, cwd, output string) []Diagnostic {
125+ var root struct {
126+ Runs []struct {
127+ Tool struct {
128+ Driver struct {
129+ Rules []struct {
130+ ID string `json:"id"`
131+ } `json:"rules"`
132+ } `json:"driver"`
133+ } `json:"tool"`
134+ Results []struct {
135+ RuleID string `json:"ruleId"`
136+ Level string `json:"level"`
137+ Message struct {
138+ Text string `json:"text"`
139+ } `json:"message"`
140+ Locations []struct {
141+ Physical struct {
142+ ArtifactLocation struct {
143+ URI string `json:"uri"`
144+ } `json:"artifactLocation"`
145+ Region struct {
146+ StartLine int `json:"startLine"`
147+ StartColumn int `json:"startColumn"`
148+ EndLine int `json:"endLine"`
149+ EndColumn int `json:"endColumn"`
150+ } `json:"region"`
151+ } `json:"physicalLocation"`
152+ } `json:"locations"`
153+ } `json:"results"`
154+ } `json:"runs"`
155+ }
156+ if json.Unmarshal([]byte(output), &root) != nil {
157+ return parseGeneric(provider, cwd, output)
158+ }
159+ var out []Diagnostic
160+ for _, run := range root.Runs {
161+ for _, result := range run.Results {
162+ if len(result.Locations) == 0 {
163+ continue
164+ }
165+ location := result.Locations[0].Physical
166+ path := location.ArtifactLocation.URI
167+ if strings.HasPrefix(path, "file://") {
168+ if decoded, err := uriToPath(path); err == nil {
169+ path = decoded
170+ }
171+ }
172+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, path), Server: provider, Source: provider, Severity: normalizeSeverity(result.Level), Code: result.RuleID, Message: result.Message.Text, Range: oneBasedRange(location.Region.StartLine, location.Region.StartColumn, location.Region.EndLine, location.Region.EndColumn)})
173+ }
174+ }
175+ return out
176+}
177+
178+var genericDiagnosticPattern = regexp.MustCompile(`^\s*(.+?):([0-9]+):([0-9]+)(?::\s*([A-Za-z]+))?\s*[-:]?\s*(.*)$`)
179+
180+func parseGeneric(provider, cwd, output string) []Diagnostic {
181+ var out []Diagnostic
182+ scanner := bufio.NewScanner(strings.NewReader(output))
183+ for scanner.Scan() {
184+ line := strings.TrimSpace(scanner.Text())
185+ match := genericDiagnosticPattern.FindStringSubmatch(line)
186+ if len(match) == 0 {
187+ continue
188+ }
189+ lineNo, _ := strconv.Atoi(match[2])
190+ columnNo, _ := strconv.Atoi(match[3])
191+ message := strings.TrimSpace(match[5])
192+ if message == "" {
193+ message = strings.TrimSpace(match[4])
194+ }
195+ severity := normalizeSeverity(match[4])
196+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, match[1]), Server: provider, Source: provider, Severity: severity, Message: message, Range: oneBasedRange(lineNo, columnNo, lineNo, columnNo)})
197+ }
198+ return out
199+}
200+
201+func oneBasedRange(line, column, endLine, endColumn int) Range {
202+ if line < 1 {
203+ line = 1
204+ }
205+ if column < 1 {
206+ column = 1
207+ }
208+ if endLine < 1 {
209+ endLine = line
210+ }
211+ if endColumn < 1 {
212+ endColumn = column
213+ }
214+ return Range{Start: Position{Line: line - 1, Character: column - 1}, End: Position{Line: endLine - 1, Character: endColumn - 1}}
215+}
216+
217+func normalizeSeverity(value string) string {
218+ switch strings.ToLower(strings.TrimSpace(value)) {
219+ case "error", "err", "fatal", "failure":
220+ return "error"
221+ case "warning", "warn":
222+ return "warning"
223+ case "hint":
224+ return "hint"
225+ default:
226+ return "info"
227+ }
228+}
229+
230+func normalizeDiagnosticPath(cwd, path string) string {
231+ path = strings.TrimSpace(path)
232+ if path == "" {
233+ return ""
234+ }
235+ if strings.HasPrefix(path, "file://") {
236+ if decoded, err := uriToPath(path); err == nil {
237+ path = decoded
238+ }
239+ }
240+ if !filepath.IsAbs(path) {
241+ path = filepath.Join(cwd, path)
242+ }
243+ abs, err := filepath.Abs(path)
244+ if err == nil {
245+ return filepath.Clean(abs)
246+ }
247+ return filepath.Clean(path)
248+}
249+
250+// FormatDiagnostic is the compact, model-facing form of one diagnostic.
251+func FormatDiagnostic(d Diagnostic, root string) string {
252+ path := d.Path
253+ if root != "" {
254+ if rel, err := filepath.Rel(root, d.Path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
255+ path = filepath.ToSlash(rel)
256+ }
257+ }
258+ code := ""
259+ if d.Code != "" {
260+ code = " [" + d.Code + "]"
261+ }
262+ message := compactWhitespace(d.Message)
263+ if len(message) > 220 {
264+ message = message[:219] + "…"
265+ }
266+ return fmt.Sprintf("%s: %s:%d:%d%s %s", strings.ToUpper(defaultSeverity(d.Severity)), filepath.ToSlash(path), d.Range.Start.Line+1, d.Range.Start.Character+1, code, message)
267+}
268+
269+func defaultSeverity(value string) string {
270+ if value == "" {
271+ return "info"
272+ }
273+ return value
274+}
275+
276+func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") }
A packages/agent/lsp/protocol.go +146 -0
@@ -0,0 +1,146 @@
1+package lsp
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "net/url"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// Position and Range use the zero-based line and UTF-16 character indexing
12+// defined by LSP.
13+type Position struct {
14+ Line int `json:"line"`
15+ Character int `json:"character"`
16+}
17+
18+type Range struct {
19+ Start Position `json:"start"`
20+ End Position `json:"end"`
21+}
22+
23+type TextEdit struct {
24+ Range Range `json:"range"`
25+ NewText string `json:"newText"`
26+}
27+
28+// WorkspaceEdit is the safe subset of a workspace edit understood by zot.
29+// Resource operations in DocumentChanges are deliberately rejected.
30+type WorkspaceEdit struct {
31+ Changes map[string][]TextEdit `json:"changes,omitempty"`
32+ DocumentChanges []json.RawMessage `json:"documentChanges,omitempty"`
33+ ChangeAnnotations json.RawMessage `json:"changeAnnotations,omitempty"`
34+}
35+
36+// Diagnostic is the normalized diagnostic representation shared by LSP and
37+// CLI linters. Path is absolute when it came from a workspace-backed source.
38+type Diagnostic struct {
39+ Path string `json:"path"`
40+ Range Range `json:"range"`
41+ Severity string `json:"severity"`
42+ Code string `json:"code,omitempty"`
43+ Message string `json:"message"`
44+ Source string `json:"source,omitempty"`
45+ Server string `json:"server,omitempty"`
46+ Clear bool `json:"-"`
47+}
48+
49+func (d *Diagnostic) UnmarshalJSON(data []byte) error {
50+ var raw struct {
51+ Range Range `json:"range"`
52+ Severity json.RawMessage `json:"severity"`
53+ Code json.RawMessage `json:"code"`
54+ Message string `json:"message"`
55+ Source string `json:"source"`
56+ }
57+ if err := json.Unmarshal(data, &raw); err != nil {
58+ return err
59+ }
60+ d.Range, d.Message, d.Source = raw.Range, raw.Message, raw.Source
61+ d.Severity = severityFromJSON(raw.Severity)
62+ d.Code = stringFromJSON(raw.Code)
63+ return nil
64+}
65+
66+func severityFromJSON(raw json.RawMessage) string {
67+ var n int
68+ if json.Unmarshal(raw, &n) == nil {
69+ switch n {
70+ case 1:
71+ return "error"
72+ case 2:
73+ return "warning"
74+ case 3:
75+ return "info"
76+ case 4:
77+ return "hint"
78+ }
79+ }
80+ var s string
81+ if json.Unmarshal(raw, &s) == nil {
82+ switch strings.ToLower(s) {
83+ case "error", "err", "fatal":
84+ return "error"
85+ case "warning", "warn":
86+ return "warning"
87+ case "hint":
88+ return "hint"
89+ default:
90+ return "info"
91+ }
92+ }
93+ return "info"
94+}
95+
96+func stringFromJSON(raw json.RawMessage) string {
97+ if len(raw) == 0 || string(raw) == "null" {
98+ return ""
99+ }
100+ var s string
101+ if json.Unmarshal(raw, &s) == nil {
102+ return s
103+ }
104+ var n json.Number
105+ if json.Unmarshal(raw, &n) == nil {
106+ return n.String()
107+ }
108+ return strings.Trim(string(raw), `"`)
109+}
110+
111+// URIForPath returns the escaped file URI used by LSP document params.
112+func URIForPath(path string) string {
113+ uri, _ := pathToURI(path)
114+ return uri
115+}
116+
117+func pathToURI(path string) (string, error) {
118+ abs, err := filepath.Abs(path)
119+ if err != nil {
120+ return "", err
121+ }
122+ // url.URL escapes spaces and non-ASCII while retaining path separators.
123+ u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}
124+ return u.String(), nil
125+}
126+
127+func uriToPath(uri string) (string, error) {
128+ u, err := url.Parse(uri)
129+ if err != nil {
130+ return "", err
131+ }
132+ if u.Scheme != "" && !strings.EqualFold(u.Scheme, "file") {
133+ return "", fmt.Errorf("unsupported document URI scheme %q", u.Scheme)
134+ }
135+ path := u.Path
136+ if u.Host != "" && u.Host != "localhost" {
137+ path = "//" + u.Host + path
138+ }
139+ if path == "" {
140+ return "", fmt.Errorf("document URI has no path")
141+ }
142+ if len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/...
143+ path = path[1:]
144+ }
145+ return filepath.Clean(filepath.FromSlash(path)), nil
146+}
M packages/agent/modes/interactive.go +75 -1
@@ -77,6 +77,14 @@ type InteractiveConfig struct {
77 // to enable it and reject requests when it remains enabled.77 // to enable it and reject requests when it remains enabled.
78 FastMode *bool78 FastMode *bool
79 79
80+ // LSPEnabled controls the main session's built-in lsp tool. nil means
81+ // enabled by default.
82+ LSPEnabled *bool
83+
84+ // SubagentLSPEnabled controls whether newly spawned swarm children
85+ // receive the lsp tool. nil means enabled by default.
86+ SubagentLSPEnabled *bool
87+
80 // AutoCompactThreshold is the context-window percentage that triggers88 // AutoCompactThreshold is the context-window percentage that triggers
81 // automatic compaction. nil means 85; zero disables percentage-based89 // automatic compaction. nil means 85; zero disables percentage-based
82 // triggers while preserving payload-too-large recovery.90 // triggers while preserving payload-too-large recovery.
@@ -159,6 +167,10 @@ type InteractiveConfig struct {
159 // it to rediscover skills / reload extensions installed by pre.167 // it to rediscover skills / reload extensions installed by pre.
160 OnStartupPreDone func()168 OnStartupPreDone func()
161 169
170+ // RefreshTools re-resolves the live agent registry after a setting
171+ // changes the main session's tool availability. Optional for embedders.
172+ RefreshTools func()
173+
162 // AutoSubmitInitial, when true, auto-submits InitialInput after174 // AutoSubmitInitial, when true, auto-submits InitialInput after
163 // StartupPre completes (or immediately when StartupPre is empty).175 // StartupPre completes (or immediately when StartupPre is empty).
164 // When false, InitialInput only pre-fills the editor.176 // When false, InitialInput only pre-fills the editor.
@@ -404,6 +416,11 @@ type terminalTitleSettingsStore interface {
404 SetTerminalTitleEnabled(enabled bool) error416 SetTerminalTitleEnabled(enabled bool) error
405 }417 }
406 418
419+type lspSettingsStore interface {
420+ SetLSPEnabled(enabled bool) error
421+ SetSubagentLSPEnabled(enabled bool) error
422+}
423+
407 type showInstructionsSettingsStore interface {424 type showInstructionsSettingsStore interface {
408 SetShowInstructionsAtStartup(enabled bool) error425 SetShowInstructionsAtStartup(enabled bool) error
409 }426 }
@@ -3631,6 +3648,8 @@ func (i *Interactive) openSettingsDialog() {
3631 if !provider.SupportsFastMode(i.cfg.Provider) {3648 if !provider.SupportsFastMode(i.cfg.Provider) {
3632 fastModeHint = "only supported for OpenAI providers"3649 fastModeHint = "only supported for OpenAI providers"
3633 }3650 }
3651+ lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
3652+ subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
3634 3653
3635 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault3654 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3636 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest3655 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
@@ -3757,6 +3776,18 @@ func (i *Interactive) openSettingsDialog() {
3757 value: fastMode,3776 value: fastMode,
3758 hint: fastModeHint,3777 hint: fastModeHint,
3759 },3778 },
3779+ {
3780+ key: "lsp_enabled",
3781+ label: "lsp in main session",
3782+ desc: "enable the lsp tool and code diagnostics for the main agent",
3783+ value: lspEnabled,
3784+ },
3785+ {
3786+ key: "subagent_lsp_enabled",
3787+ label: "lsp in sub-agents",
3788+ desc: "allow newly spawned background sub-agents to use the lsp tool",
3789+ value: subagentLSPEnabled,
3790+ },
3760 {3791 {
3761 key: "auto_compact_threshold",3792 key: "auto_compact_threshold",
3762 label: "auto-compact threshold",3793 label: "auto-compact threshold",
@@ -4196,6 +4227,39 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4196 i.statusOK = "fast mode " + onOff(value)4227 i.statusOK = "fast mode " + onOff(value)
4197 i.statusErr = ""4228 i.statusErr = ""
4198 i.mu.Unlock()4229 i.mu.Unlock()
4230+ case "lsp_enabled":
4231+ val := value
4232+ i.cfg.LSPEnabled = &val
4233+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4234+ if err := store.SetLSPEnabled(value); err != nil {
4235+ i.mu.Lock()
4236+ i.statusErr = "settings: " + err.Error()
4237+ i.mu.Unlock()
4238+ return
4239+ }
4240+ }
4241+ if i.cfg.RefreshTools != nil {
4242+ i.cfg.RefreshTools()
4243+ }
4244+ i.mu.Lock()
4245+ i.statusOK = "lsp in main session " + onOff(value)
4246+ i.statusErr = ""
4247+ i.mu.Unlock()
4248+ case "subagent_lsp_enabled":
4249+ val := value
4250+ i.cfg.SubagentLSPEnabled = &val
4251+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4252+ if err := store.SetSubagentLSPEnabled(value); err != nil {
4253+ i.mu.Lock()
4254+ i.statusErr = "settings: " + err.Error()
4255+ i.mu.Unlock()
4256+ return
4257+ }
4258+ }
4259+ i.mu.Lock()
4260+ i.statusOK = "lsp in sub-agents " + onOff(value)
4261+ i.statusErr = ""
4262+ i.mu.Unlock()
4199 case "jail_by_default":4263 case "jail_by_default":
4200 val := value4264 val := value
4201 i.cfg.JailByDefault = &val4265 i.cfg.JailByDefault = &val
@@ -5130,7 +5194,10 @@ func (i *Interactive) doLogout(target string) {
5130 if clearedCurrent {5194 if clearedCurrent {
5131 // The running agent was using a credential we just wiped. Drop5195 // The running agent was using a credential we just wiped. Drop
5132 // it so prompts can't go out with the stale client, and hint at5196 // it so prompts can't go out with the stale client, and hint at
5133- // /login.5197+ // /login. Close any LSP processes owned by its tools first.
5198+ if i.agent != nil {
5199+ _ = tools.CloseLSPManagers(i.agent.Tools)
5200+ }
5134 i.agent = nil5201 i.agent = nil
5135 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."5202 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
5136 } else {5203 } else {
@@ -5722,6 +5789,9 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
5722 }5789 }
5723 ag.SeedCost(carryCost)5790 ag.SeedCost(carryCost)
5724 5791
5792+ if i.agent != nil && i.agent != ag {
5793+ _ = tools.CloseLSPManagers(i.agent.Tools)
5794+ }
5725 i.mu.Lock()5795 i.mu.Lock()
5726 i.agent = ag5796 i.agent = ag
5727 i.cfg.Provider = p5797 i.cfg.Provider = p
@@ -5764,12 +5834,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5764 }5834 }
5765 commitLogin := func() {5835 commitLogin := func() {
5766 i.mu.Lock()5836 i.mu.Lock()
5837+ oldAgent := i.agent
5767 i.agent = ag5838 i.agent = ag
5768 i.cfg.Provider = prov5839 i.cfg.Provider = prov
5769 i.cfg.Model = model5840 i.cfg.Model = model
5770 i.statusErr = ""5841 i.statusErr = ""
5771 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method5842 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5772 i.mu.Unlock()5843 i.mu.Unlock()
5844+ if oldAgent != nil {
5845+ _ = tools.CloseLSPManagers(oldAgent.Tools)
5846+ }
5773 i.applyAutoSwarmTool(i.autoSwarmEnabled())5847 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5774 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())5848 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5775 // Authentication can change the provider used by the live5849 // Authentication can change the provider used by the live
M packages/agent/rpc.go +1 -0
@@ -79,6 +79,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
79 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})79 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
80 80
81 ag := r.NewAgent()81 ag := r.NewAgent()
82+ defer closeAgentLSP(ag)
82 server.agent = ag83 server.agent = ag
83 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {84 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
84 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)85 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
M packages/agent/sdk/sdk.go +7 -2
@@ -30,6 +30,7 @@ import (
30 "sync"30 "sync"
31 31
32 "github.com/patriceckhart/zot/packages/agent"32 "github.com/patriceckhart/zot/packages/agent"
33+ agenttools "github.com/patriceckhart/zot/packages/agent/tools"
33 "github.com/patriceckhart/zot/packages/core"34 "github.com/patriceckhart/zot/packages/core"
34 "github.com/patriceckhart/zot/packages/provider"35 "github.com/patriceckhart/zot/packages/provider"
35 )36 )
@@ -74,8 +75,8 @@ type Config struct {
74 // BaseURL overrides the provider base url (for tests / proxies).75 // BaseURL overrides the provider base url (for tests / proxies).
75 BaseURL string76 BaseURL string
76 77
77- // Tools is the list of tools to enable. Nil/empty = all four78+ // Tools is the list of tools to enable. Nil/empty = all built-in
78- // (read, write, edit, bash). Pass an empty-but-non-nil slice79+ // tools (read, write, edit, bash, lsp). Pass an empty-but-non-nil slice
79 // (e.g. []string{}) plus NoTools=true to disable everything.80 // (e.g. []string{}) plus NoTools=true to disable everything.
80 Tools []string81 Tools []string
81 82
@@ -339,10 +340,14 @@ func (r *Runtime) Close() error {
339 }340 }
340 r.closed = true341 r.closed = true
341 cancel := r.activeCancel342 cancel := r.activeCancel
343+ ag := r.agent
342 r.mu.Unlock()344 r.mu.Unlock()
343 if cancel != nil {345 if cancel != nil {
344 cancel()346 cancel()
345 }347 }
348+ if ag != nil {
349+ return agenttools.CloseLSPManagers(ag.Tools)
350+ }
346 return nil351 return nil
347 }352 }
348 353
M packages/agent/settings_store.go +18 -0
@@ -77,6 +77,24 @@ func (configSettingsStore) SetFastMode(enabled bool) error {
77 return SaveConfig(cfg)77 return SaveConfig(cfg)
78 }78 }
79 79
80+func (configSettingsStore) SetLSPEnabled(enabled bool) error {
81+ cfg, err := LoadConfig()
82+ if err != nil {
83+ return err
84+ }
85+ cfg.LSPEnabled = &enabled
86+ return SaveConfig(cfg)
87+}
88+
89+func (configSettingsStore) SetSubagentLSPEnabled(enabled bool) error {
90+ cfg, err := LoadConfig()
91+ if err != nil {
92+ return err
93+ }
94+ cfg.SubagentLSPEnabled = &enabled
95+ return SaveConfig(cfg)
96+}
97+
80 func (configSettingsStore) SetAutoCompactThreshold(percent int) error {98 func (configSettingsStore) SetAutoCompactThreshold(percent int) error {
81 cfg, err := LoadConfig()99 cfg, err := LoadConfig()
82 if err != nil {100 if err != nil {
M packages/agent/settings_store_test.go +44 -0
@@ -106,3 +106,47 @@ func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) {
106 t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)106 t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
107 }107 }
108 }108 }
109+
110+func TestConfigSettingsStorePersistsLSPDefaults(t *testing.T) {
111+ t.Setenv("ZOT_HOME", t.TempDir())
112+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
113+ t.Fatal(err)
114+ }
115+
116+ if err := (configSettingsStore{}).SetLSPEnabled(false); err != nil {
117+ t.Fatal(err)
118+ }
119+ if err := (configSettingsStore{}).SetSubagentLSPEnabled(false); err != nil {
120+ t.Fatal(err)
121+ }
122+ cfg, err := LoadConfig()
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if cfg.LSPEnabled == nil || *cfg.LSPEnabled {
127+ t.Fatal("lsp_enabled was not persisted as disabled")
128+ }
129+ if cfg.SubagentLSPEnabled == nil || *cfg.SubagentLSPEnabled {
130+ t.Fatal("subagent_lsp_enabled was not persisted as disabled")
131+ }
132+ if cfg.Theme != "dark" {
133+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
134+ }
135+}
136+
137+func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
138+ cfg := Config{}
139+ if !cfg.LSPEnabledFor(false) {
140+ t.Fatal("main-session LSP should default to enabled")
141+ }
142+ if !cfg.LSPEnabledFor(true) {
143+ t.Fatal("sub-agent LSP should default to enabled")
144+ }
145+
146+ noMain, noSub := false, false
147+ cfg.LSPEnabled = &noMain
148+ cfg.SubagentLSPEnabled = &noSub
149+ if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) {
150+ t.Fatal("explicitly disabled LSP settings were ignored")
151+ }
152+}
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +1 -1
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
29 2. **Tools** — register tools the LLM itself calls. Schema is29 2. **Tools** — register tools the LLM itself calls. Schema is
30 JSON Schema; zot routes the model's `tool_call` to the30 JSON Schema; zot routes the model's `tool_call` to the
31 extension's `tool_result`. Same lifecycle as built-in tools31 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/skill).32+ (read/write/edit/bash/lsp/skill).
33 33
34 3. **Lifecycle hooks** — subscribe to events34 3. **Lifecycle hooks** — subscribe to events
35 (session_start, turn_start, tool_call, turn_end,35 (session_start, turn_start, tool_call, turn_end,
M packages/agent/swarm_agent.go +1 -0
@@ -59,6 +59,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
59 defer stopExt()59 defer stopExt()
60 60
61 ag := r.NewAgent()61 ag := r.NewAgent()
62+ defer closeAgentLSP(ag)
62 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)63 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
63 sess, err := openOrCreateSession(args, r, ag, version)64 sess, err := openOrCreateSession(args, r, ag, version)
64 if err != nil {65 if err != nil {
M packages/agent/tools/edit.go +8 -2
@@ -8,14 +8,17 @@ import (
8 "os"8 "os"
9 "strings"9 "strings"
10 10
11+ "github.com/patriceckhart/zot/packages/agent/lsp"
11 "github.com/patriceckhart/zot/packages/core"12 "github.com/patriceckhart/zot/packages/core"
12 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
13 )14 )
14 15
15 // EditTool applies one or more exact-match replacements to a file.16 // EditTool applies one or more exact-match replacements to a file.
16 type EditTool struct {17 type EditTool struct {
17- CWD string18+ CWD string
18- Sandbox *Sandbox19+ Sandbox *Sandbox
20+ LSP *lsp.Manager
21+ LSPDiagnostics bool
19 }22 }
20 23
21 type editOp struct {24 type editOp struct {
@@ -61,6 +64,9 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
61 if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {64 if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
62 return core.ToolResult{}, err65 return core.ToolResult{}, err
63 }66 }
67+ if t.LSPDiagnostics {
68+ attachWriteDiagnostics(ctx, t.CWD, plan.path, t.LSP, &plan.result)
69+ }
64 return plan.result, nil70 return plan.result, nil
65 }71 }
66 72
A packages/agent/tools/lsp.go +340 -0
@@ -0,0 +1,340 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/agent/lsp"
13+ "github.com/patriceckhart/zot/packages/core"
14+ "github.com/patriceckhart/zot/packages/provider"
15+)
16+
17+const maxLSPToolOutput = 60 * 1024
18+
19+// LSPTool exposes diagnostics, language navigation, and server management to
20+// the model. Manager owns processes and is normally shared by all tool calls
21+// in one agent session.
22+type LSPTool struct {
23+ CWD string
24+ Manager *lsp.Manager
25+ Sandbox *Sandbox
26+}
27+
28+// NewLSPTool constructs the model-facing LSP tool.
29+func NewLSPTool(cwd string, manager *lsp.Manager) *LSPTool {
30+ if manager == nil {
31+ manager = lsp.NewManager()
32+ }
33+ return &LSPTool{CWD: cwd, Manager: manager}
34+}
35+
36+type lspArgs struct {
37+ Action string `json:"action"`
38+ Path string `json:"path,omitempty"`
39+ Line int `json:"line,omitempty"`
40+ Column int `json:"column,omitempty"`
41+ Query string `json:"query,omitempty"`
42+ NewName string `json:"new_name,omitempty"`
43+ Server string `json:"server,omitempty"`
44+ Method string `json:"method,omitempty"`
45+ Params json.RawMessage `json:"params,omitempty"`
46+ Apply *bool `json:"apply,omitempty"`
47+ Timeout int `json:"timeout_ms,omitempty"`
48+ Max int `json:"max,omitempty"`
49+ RunCLI *bool `json:"run_cli,omitempty"`
50+}
51+
52+const lspSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["diagnostics","definition","references","hover","symbols","rename","code_actions","type_definition","implementation","status","reload","capabilities","request"]},"path":{"type":"string","description":"File path relative to the workspace, required for document actions."},"line":{"type":"integer","minimum":1,"description":"1-based line for a document action."},"column":{"type":"integer","minimum":1,"description":"1-based column for a document action."},"query":{"type":"string","description":"Workspace symbol query; symbols uses document symbols when omitted."},"new_name":{"type":"string","description":"New identifier for rename."},"server":{"type":"string","description":"Optional server id for request or capabilities."},"method":{"type":"string","description":"Raw LSP method for request."},"params":{"type":"object","description":"Raw JSON-RPC params for request."},"apply":{"type":"boolean","description":"Apply a returned rename/code-action WorkspaceEdit. Only files inside the workspace are accepted."},"timeout_ms":{"type":"integer","minimum":1},"max":{"type":"integer","minimum":1,"maximum":50},"run_cli":{"type":"boolean","description":"Run configured CLI linters for diagnostics (default true)."}},"required":["action"]}`
53+
54+func (t *LSPTool) Name() string { return "lsp" }
55+func (t *LSPTool) Description() string {
56+ return "Query configured language servers and linters for diagnostics, navigation, refactors, symbols, capabilities, and raw LSP requests."
57+}
58+func (t *LSPTool) Schema() json.RawMessage { return json.RawMessage(lspSchema) }
59+
60+func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
61+ var args lspArgs
62+ if err := json.Unmarshal(raw, &args); err != nil {
63+ return core.ToolResult{}, fmt.Errorf("invalid lsp args: %w", err)
64+ }
65+ args.Action = strings.ToLower(strings.TrimSpace(args.Action))
66+ if args.Action == "" {
67+ return core.ToolResult{}, fmt.Errorf("action is required")
68+ }
69+ if t.Manager == nil {
70+ return core.ToolResult{}, fmt.Errorf("lsp manager is unavailable")
71+ }
72+ cwd := t.CWD
73+ if cwd == "" {
74+ cwd, _ = os.Getwd()
75+ }
76+ absCWD, err := filepath.Abs(cwd)
77+ if err != nil {
78+ return core.ToolResult{}, err
79+ }
80+ path := ""
81+ if args.Path != "" && args.Path != "*" {
82+ path = resolvePath(absCWD, args.Path)
83+ if t.Sandbox != nil {
84+ if err := t.Sandbox.CheckPath(path); err != nil {
85+ return core.ToolResult{}, err
86+ }
87+ }
88+ }
89+ runCtx := ctx
90+ if args.Timeout > 0 {
91+ var cancel context.CancelFunc
92+ runCtx, cancel = context.WithTimeout(ctx, time.Duration(args.Timeout)*time.Millisecond)
93+ defer cancel()
94+ }
95+ var text string
96+ var details any
97+ var actionErr error
98+ switch args.Action {
99+ case "diagnostics":
100+ runCLI := true
101+ if args.RunCLI != nil {
102+ runCLI = *args.RunCLI
103+ }
104+ var diagnostics []lsp.Diagnostic
105+ if path != "" && hasGlobMeta(args.Path) {
106+ matches, globErr := filepath.Glob(path)
107+ if globErr != nil {
108+ actionErr = globErr
109+ break
110+ }
111+ for _, match := range matches {
112+ if t.Sandbox != nil {
113+ if checkErr := t.Sandbox.CheckPath(match); checkErr != nil {
114+ if actionErr == nil {
115+ actionErr = checkErr
116+ }
117+ continue
118+ }
119+ }
120+ values, diagErr := t.Manager.DiagnosticsWithOptions(runCtx, absCWD, match, runCLI)
121+ if actionErr == nil {
122+ actionErr = diagErr
123+ }
124+ diagnostics = append(diagnostics, values...)
125+ }
126+ } else {
127+ diagnostics, actionErr = t.Manager.DiagnosticsWithOptions(runCtx, absCWD, path, runCLI)
128+ }
129+ max := boundedMax(args.Max)
130+ text = lsp.SummarizeDiagnostics(diagnostics, absCWD, max)
131+ visible := diagnostics
132+ if len(visible) > max {
133+ visible = visible[:max]
134+ }
135+ details = map[string]any{"diagnostics": visible, "count": len(diagnostics), "truncated": len(visible) < len(diagnostics), "run_cli": runCLI}
136+ case "status":
137+ var statuses []lsp.ServerStatus
138+ statuses, actionErr = t.Manager.Status(absCWD, path)
139+ text = prettyJSON(statuses)
140+ details = statuses
141+ case "reload":
142+ actionErr = t.Manager.Reload(absCWD)
143+ text = "Reloaded LSP and linter processes and configuration."
144+ case "capabilities":
145+ var capabilities map[string]json.RawMessage
146+ capabilities, actionErr = t.Manager.Capabilities(absCWD, path, args.Server)
147+ text = prettyJSON(capabilities)
148+ details = capabilities
149+ case "request":
150+ text, details, actionErr = t.executeRawRequest(runCtx, absCWD, path, args)
151+ case "definition", "references", "hover", "symbols", "rename", "code_actions", "type_definition", "implementation":
152+ text, details, actionErr = t.executeLanguageAction(runCtx, absCWD, path, args)
153+ default:
154+ return core.ToolResult{}, fmt.Errorf("unknown lsp action %q", args.Action)
155+ }
156+ if actionErr != nil && text == "" {
157+ return core.ToolResult{}, actionErr
158+ }
159+ text = boundLSPText(text)
160+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}, nil
161+}
162+
163+func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
164+ if path == "" && (args.Action != "symbols" || args.Query == "") {
165+ return "", nil, fmt.Errorf("path is required for %s", args.Action)
166+ }
167+ if args.Action != "symbols" && (args.Line < 1 || args.Column < 1) {
168+ return "", nil, fmt.Errorf("line and column (1-based) are required for %s", args.Action)
169+ }
170+ position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1}
171+ method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action]
172+ var params any
173+ if args.Action == "symbols" {
174+ if args.Query != "" {
175+ method, params = "workspace/symbol", map[string]any{"query": args.Query}
176+ } else {
177+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}}
178+ }
179+ } else {
180+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position}
181+ if args.Action == "references" {
182+ params.(map[string]any)["context"] = map[string]any{"includeDeclaration": true}
183+ }
184+ if args.Action == "code_actions" {
185+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}}
186+ }
187+ if args.Action == "rename" {
188+ if strings.TrimSpace(args.NewName) == "" {
189+ return "", nil, fmt.Errorf("new_name is required for rename")
190+ }
191+ params.(map[string]any)["newName"] = args.NewName
192+ }
193+ }
194+ responses := t.Manager.RequestAll(ctx, cwd, path, method, params)
195+ apply := args.Apply != nil && *args.Apply
196+ if args.Action == "rename" && args.Apply == nil {
197+ apply = true
198+ }
199+ text, applyCount, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
200+ if applyCount > 0 {
201+ text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)
202+ }
203+ return text, map[string]any{"action": args.Action, "responses": responses, "applied": applyCount}, applyErr
204+}
205+
206+func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
207+ if strings.TrimSpace(args.Method) == "" {
208+ return "", nil, fmt.Errorf("method is required for request")
209+ }
210+ var params any = map[string]any{}
211+ if len(args.Params) > 0 && string(args.Params) != "null" {
212+ if err := json.Unmarshal(args.Params, &params); err != nil {
213+ return "", nil, fmt.Errorf("params must be JSON: %w", err)
214+ }
215+ }
216+ var responses []lsp.Response
217+ if path != "" {
218+ _ = t.Manager.Open(ctx, cwd, path)
219+ }
220+ if args.Server != "" {
221+ result, err := t.Manager.Request(ctx, cwd, args.Server, args.Method, params)
222+ responses = []lsp.Response{{Server: args.Server, Result: result, Error: err}}
223+ } else {
224+ responses = t.Manager.RequestAll(ctx, cwd, path, args.Method, params)
225+ }
226+ return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)
227+}
228+
229+func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, error) {
230+ var b strings.Builder
231+ applied := 0
232+ var firstErr error
233+ for _, response := range responses {
234+ if response.Error != nil {
235+ fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
236+ if firstErr == nil {
237+ firstErr = response.Error
238+ }
239+ continue
240+ }
241+ fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
242+ if apply && (action == "rename" || action == "code_actions") {
243+ edits := workspaceEdits(response.Result, action)
244+ for _, edit := range edits {
245+ if err := manager.ApplyEdit(cwd, edit); err != nil {
246+ if firstErr == nil {
247+ firstErr = err
248+ }
249+ fmt.Fprintf(&b, "apply: error: %s\n", err)
250+ } else {
251+ applied++
252+ }
253+ }
254+ }
255+ }
256+ if b.Len() == 0 {
257+ return "No results.", applied, firstErr
258+ }
259+ return b.String(), applied, firstErr
260+}
261+
262+func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {
263+ if action == "rename" {
264+ var edit lsp.WorkspaceEdit
265+ if json.Unmarshal(raw, &edit) == nil && (len(edit.Changes) > 0 || len(edit.DocumentChanges) > 0) {
266+ return []lsp.WorkspaceEdit{edit}
267+ }
268+ return nil
269+ }
270+ var actions []struct {
271+ Edit lsp.WorkspaceEdit `json:"edit"`
272+ }
273+ if json.Unmarshal(raw, &actions) != nil {
274+ return nil
275+ }
276+ out := make([]lsp.WorkspaceEdit, 0, len(actions))
277+ for _, action := range actions {
278+ if len(action.Edit.Changes) > 0 || len(action.Edit.DocumentChanges) > 0 {
279+ out = append(out, action.Edit)
280+ }
281+ }
282+ return out
283+}
284+
285+func formatRawResponses(responses []lsp.Response) string {
286+ var b strings.Builder
287+ for _, response := range responses {
288+ if response.Error != nil {
289+ fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
290+ continue
291+ }
292+ fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
293+ }
294+ if b.Len() == 0 {
295+ return "No results."
296+ }
297+ return b.String()
298+}
299+
300+func firstResponseError(responses []lsp.Response) error {
301+ for _, response := range responses {
302+ if response.Error != nil {
303+ return response.Error
304+ }
305+ }
306+ return nil
307+}
308+func fileURI(path string) string { return lsp.URIForPath(path) }
309+func prettyJSON(raw any) string {
310+ var data []byte
311+ switch value := raw.(type) {
312+ case json.RawMessage:
313+ data = value
314+ default:
315+ data, _ = json.Marshal(value)
316+ }
317+ var value any
318+ if json.Unmarshal(data, &value) == nil {
319+ if formatted, err := json.MarshalIndent(value, "", " "); err == nil {
320+ return string(formatted)
321+ }
322+ }
323+ return strings.TrimSpace(string(data))
324+}
325+func hasGlobMeta(value string) bool {
326+ return strings.ContainsAny(value, "*?[")
327+}
328+
329+func boundedMax(value int) int {
330+ if value <= 0 || value > lsp.DefaultDiagnosticCap {
331+ return lsp.DefaultDiagnosticCap
332+ }
333+ return value
334+}
335+func boundLSPText(value string) string {
336+ if len(value) <= maxLSPToolOutput {
337+ return value
338+ }
339+ return value[:maxLSPToolOutput] + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput)
340+}
A packages/agent/tools/lsp_diagnostics.go +84 -0
@@ -0,0 +1,84 @@
1+package tools
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/agent/lsp"
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+const writeDiagnosticsTimeout = 2 * time.Second
14+
15+// CloseLSPManagers closes every manager referenced by the registry. Write,
16+// edit, and lsp share one manager in normal runs, so de-duplicate pointers
17+// before closing them. This is used by mode owners when an agent or registry
18+// is replaced; LSP processes should not outlive the session that created them.
19+func CloseLSPManagers(reg core.Registry) error {
20+ managers := make(map[*lsp.Manager]struct{})
21+ for _, tool := range reg {
22+ switch value := tool.(type) {
23+ case *LSPTool:
24+ if value != nil && value.Manager != nil {
25+ managers[value.Manager] = struct{}{}
26+ }
27+ case *WriteTool:
28+ if value != nil && value.LSP != nil {
29+ managers[value.LSP] = struct{}{}
30+ }
31+ case *EditTool:
32+ if value != nil && value.LSP != nil {
33+ managers[value.LSP] = struct{}{}
34+ }
35+ }
36+ }
37+ var firstErr error
38+ for manager := range managers {
39+ if err := manager.Close(); err != nil && firstErr == nil {
40+ firstErr = err
41+ }
42+ }
43+ return firstErr
44+}
45+
46+// attachWriteDiagnostics performs a bounded post-write diagnostics pass. A
47+// failed or slow language server must never turn a successful filesystem
48+// mutation into a failed tool call, so callers only attach diagnostics that
49+// were actually returned before the short budget expired.
50+func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) {
51+ if manager == nil || result == nil {
52+ return
53+ }
54+ if ctx == nil {
55+ ctx = context.Background()
56+ }
57+ diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout)
58+ defer cancel()
59+ diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
60+ if err != nil {
61+ return
62+ }
63+ diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics)
64+ if len(diagnostics) == 0 {
65+ return
66+ }
67+ summary := lsp.SummarizeDiagnostics(diagnostics, cwd, 8)
68+ if strings.TrimSpace(summary) == "" || summary == "No diagnostics." {
69+ return
70+ }
71+ for index, block := range result.Content {
72+ text, ok := block.(provider.TextBlock)
73+ if !ok {
74+ continue
75+ }
76+ text.Text = strings.TrimRight(text.Text, "\n") + "\n\n" + summary + "\n"
77+ result.Content[index] = text
78+ break
79+ }
80+ if details, ok := result.Details.(map[string]any); ok {
81+ details["diagnostics"] = diagnostics
82+ details["diagnostics_summary"] = summary
83+ }
84+}
A packages/agent/tools/lsp_test.go +69 -0
@@ -0,0 +1,69 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/agent/lsp"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+func TestLSPToolSchemaAndFormatting(t *testing.T) {
17+ tool := NewLSPTool(t.TempDir(), lsp.NewManager())
18+ var schema map[string]any
19+ if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
20+ t.Fatal(err)
21+ }
22+ if tool.Name() != "lsp" || !strings.Contains(tool.Description(), "diagnostics") {
23+ t.Fatalf("tool metadata missing")
24+ }
25+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"status"}`), nil)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ text, ok := result.Content[0].(provider.TextBlock)
30+ if !ok || text.Text == "" {
31+ t.Fatalf("status result = %#v", result.Content)
32+ }
33+ if !strings.Contains(text.Text, "[") && !strings.Contains(text.Text, "{") {
34+ t.Fatalf("status was not formatted JSON: %q", text.Text)
35+ }
36+}
37+
38+func TestLSPToolGlobRespectsJailForSymlinkMatches(t *testing.T) {
39+ if runtime.GOOS == "windows" {
40+ t.Skip("symlink permissions vary on Windows")
41+ }
42+ root := t.TempDir()
43+ outside := t.TempDir()
44+ outsidePath := filepath.Join(outside, "outside.go")
45+ if err := os.WriteFile(outsidePath, []byte("package outside\\n"), 0o644); err != nil {
46+ t.Fatal(err)
47+ }
48+ if err := os.Symlink(outsidePath, filepath.Join(root, "link.go")); err != nil {
49+ t.Fatal(err)
50+ }
51+ sandbox := NewSandbox(root)
52+ sandbox.Lock()
53+ tool := NewLSPTool(root, lsp.NewManager())
54+ tool.Sandbox = sandbox
55+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"diagnostics","path":"*.go","run_cli":false}`), nil)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if !result.IsError {
60+ t.Fatalf("jailed symlink glob was accepted: %#v", result)
61+ }
62+}
63+
64+func TestBoundLSPText(t *testing.T) {
65+ value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10))
66+ if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") {
67+ t.Fatalf("bound output = %d bytes", len(value))
68+ }
69+}
M packages/agent/tools/read.go +1 -1
@@ -1,4 +1,4 @@
1-// Package tools implements zot's built-in tools: read, write, edit, bash.1+// Package tools implements zot's built-in tools: read, write, edit, bash, and lsp.
2 package tools2 package tools
3 3
4 import (4 import (
M packages/agent/tools/swarm_spawn.go +1 -1
@@ -65,7 +65,7 @@ const swarmSpawnSchema = `{
65 "properties": {65 "properties": {
66 "task": {66 "task": {
67 "type": "string",67 "type": "string",
68- "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same read/write/edit/bash tools as the main agent and shares this working directory, but starts with NO context from this conversation."68+ "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same built-in tools as the main agent (including lsp when enabled) and shares this working directory, but starts with NO context from this conversation."
69 },69 },
70 "agent": {70 "agent": {
71 "type": "string",71 "type": "string",
M packages/agent/tools/write.go +11 -4
@@ -8,14 +8,17 @@ import (
8 "path/filepath"8 "path/filepath"
9 "strings"9 "strings"
10 10
11+ "github.com/patriceckhart/zot/packages/agent/lsp"
11 "github.com/patriceckhart/zot/packages/core"12 "github.com/patriceckhart/zot/packages/core"
12 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
13 )14 )
14 15
15 // WriteTool writes content to a file, creating parent directories.16 // WriteTool writes content to a file, creating parent directories.
16 type WriteTool struct {17 type WriteTool struct {
17- CWD string18+ CWD string
18- Sandbox *Sandbox19+ Sandbox *Sandbox
20+ LSP *lsp.Manager
21+ LSPDiagnostics bool
19 }22 }
20 23
21 type writeArgs struct {24 type writeArgs struct {
@@ -62,7 +65,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
62 if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {65 if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {
63 totalLines++ // count the last unterminated line66 totalLines++ // count the last unterminated line
64 }67 }
65- return core.ToolResult{68+ result := core.ToolResult{
66 Content: []provider.Content{provider.TextBlock{Text: a.Content}},69 Content: []provider.Content{provider.TextBlock{Text: a.Content}},
67 Details: map[string]any{70 Details: map[string]any{
68 "path": path,71 "path": path,
@@ -70,5 +73,9 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
70 "total_lines": totalLines,73 "total_lines": totalLines,
71 "start_line": 1,74 "start_line": 1,
72 },75 },
73- }, nil76+ }
77+ if t.LSPDiagnostics {
78+ attachWriteDiagnostics(ctx, t.CWD, path, t.LSP, &result)
79+ }
80+ return result, nil
74 }81 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close