detached 69e5a1b

Merge pull request #10 from bnema/feat/lsp-linter-support

feat(agent): add LSP and linter support
commit
69e5a1b50dfbdea860d28d295ba7077e90f589a1
author
bnema · · 2026-08-04 13:06 UTC
committer
GitHub · · 2026-08-04 13:06 UTC
merge of
5b4cea4 + 81fd4ac · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
36 files · +4988 -41
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 core built-in tools (read, write, edit, bash, and lsp); the conditional `skill` tool is available when skills are enabled.
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 +147 -0
@@ -0,0 +1,147 @@
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.json`
14+- `<cwd>/.omp/lsp.json`
15+- `<cwd>/lsp.json`, `<cwd>/.lsp.json`, and `<cwd>/.zot/lsp.json`
16+
17+A file may put providers below `servers`, `lspServers`, or `linters`, use a
18+flat provider map, or use the `providers` array:
19+
20+```json
21+{
22+ "servers": {
23+ "gopls": {
24+ "command": "gopls",
25+ "fileTypes": [".go"],
26+ "rootMarkers": ["go.mod", "go.work"]
27+ },
28+ "eslint": {
29+ "kind": "cli",
30+ "command": "eslint",
31+ "args": ["--format", "json"],
32+ "parser": "eslint-json",
33+ "mode": "files",
34+ "fileTypes": [".js", ".jsx", ".ts", ".tsx"],
35+ "rootMarkers": ["eslint.config.js", ".eslintrc"]
36+ }
37+ }
38+}
39+```
40+
41+The `providers` array accepts built-in names and objects with `id`, `kind`,
42+`command`, `args`, selectors, root markers, and nested `lsp`/`cli` options.
43+Unknown provider metadata is ignored. Set `autoDetect` to `false` to
44+keep only providers named or defined by the configuration file.
45+
46+Server overrides merge onto zot's built-in definitions. Built-ins cover common
47+Go, Rust, TypeScript/JavaScript, Python, C/C++, YAML, JSON, and shell servers,
48+plus ESLint, golangci-lint, and Ruff CLI providers. A provider is selected
49+when it is enabled and its file type/root markers match the workspace. Command
50+lookup prefers project-local bin directories and then `PATH`; missing commands
51+are reported by `status` or the request result. Set `disabled` to `true` to
52+turn off a provider.
53+
54+Useful server fields are:
55+
56+| Field | Meaning |
57+| --- | --- |
58+| `kind` | `lsp` (default) or `cli` |
59+| `command`, `args` | Executable and arguments |
60+| `fileTypes` | Extensions, basenames, or language IDs handled by the provider |
61+| `languageId` | Optional LSP language ID sent on `textDocument/didOpen`; inferred when omitted |
62+| `rootMarkers` | Files identifying a workspace root |
63+| `isLinter` | LSP provider used for diagnostics, not navigation/refactors |
64+| `parser` | CLI output parser: `eslint-json`, `golangci-lint-json`, `ruff-json`, `sarif`, or generic |
65+| `mode` | CLI provider scope: `files` or `workspace` |
66+| `settings` | Values returned for `workspace/configuration` requests |
67+| `initializationOptions` | Values sent during LSP initialization |
68+| `env` | Extra environment variables for the child process |
69+
70+CLI providers may use `{files}` as an argument or inside an argument. It is
71+expanded to project-relative paths touched by the diagnostics request; an
72+embedded placeholder produces one argument per path. 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. Project and
133+global LSP configuration is executable local configuration: review configured
134+commands, arguments, environment variables, and workspace-local binaries before
135+using it. zot does not add a separate trust prompt or sandbox around those child
136+processes; it does avoid shell expansion and keeps returned workspace edits
137+inside the session workspace. The `lsp` tool never executes configured commands
138+through a shell. Workspace edits returned by
139+rename and code actions are restricted to the session workspace, reject
140+resource operations, validate UTF-16 LSP positions, and write through a
141+temporary file before replacement. Server-initiated `workspace/applyEdit`
142+requests use the same checks.
143+
144+LSP clients are cached per workspace and provider for the lifetime of the zot
145+process. `reload` and process shutdown terminate cached children. Server
146+stderr is kept out of model-facing tool results; diagnostics and protocol
147+errors are returned through the established tool result channel.
M docs/subagents.md +3 -3
@@ -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
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,13 +40,13 @@ 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; the conditional `skill` tool is available when skills are enabled. 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`. |
47 | `systemPromptMode` | `append` (default) or `replace`. |47 | `systemPromptMode` | `append` (default) or `replace`. |
48 | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |48 | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |
49-| `inheritSkills` | Set to `false` to omit skill discovery from the child. |49+| `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |
50 50
51 Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot.51 Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot.
52 52
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 +44 -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(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession))
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,29 @@ 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 && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1058+ options := lsp.ManagerOptions{}
1059+ if sandbox != nil {
1060+ options.CheckWritePath = sandbox.CheckWritePath
1061+ }
1062+ manager = lsp.NewManagerWithOptions(options)
1063+ }
1051 all := map[string]core.Tool{1064 all := map[string]core.Tool{
1052 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},1065 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1053- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},1066+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1054- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},1067+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1055 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1068 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1056 }1069 }
1070+ if manager != nil {
1071+ lspTool := tools.NewLSPTool(cwd, manager)
1072+ lspTool.Sandbox = sandbox
1073+ all["lsp"] = lspTool
1074+ }
1057 reg := core.Registry{}1075 reg := core.Registry{}
1058 if len(args.Tools) == 0 {1076 if len(args.Tools) == 0 {
1059 for _, t := range all {1077 for _, t := range all {
@@ -1069,8 +1087,29 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
1069 return reg1087 return reg
1070 }1088 }
1071 1089
1090+func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1091+ if len(args.Tools) == 0 {
1092+ return true
1093+ }
1094+ for _, name := range args.Tools {
1095+ switch name {
1096+ case "lsp":
1097+ return true
1098+ case "write":
1099+ if diagnosticsOnWrite {
1100+ return true
1101+ }
1102+ case "edit":
1103+ if diagnosticsOnEdit {
1104+ return true
1105+ }
1106+ }
1107+ }
1108+ return false
1109+}
1110+
1072 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1111 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1073- order := []string{"read", "write", "edit", "bash"}1112+ order := []string{"read", "write", "edit", "bash", "lsp"}
1074 var out []ToolSummary1113 var out []ToolSummary
1075 for _, name := range order {1114 for _, name := range order {
1076 if t, ok := reg[name]; ok {1115 if t, ok := reg[name]; ok {
M packages/agent/build_test.go +29 -0
@@ -9,9 +9,38 @@ 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+ lspTool, ok := registry["lsp"].(*tools.LSPTool)
20+ if !ok || lspTool.Manager == nil {
21+ t.Fatalf("lsp tool = %#v", registry["lsp"])
22+ }
23+ t.Cleanup(func() { _ = lspTool.Manager.Close() })
24+ if _, ok := registry["lsp"]; !ok {
25+ t.Fatal("default registry does not include lsp")
26+ }
27+ write, ok := registry["write"].(*tools.WriteTool)
28+ if !ok || write.LSP == nil || !write.LSPDiagnostics {
29+ t.Fatalf("write tool LSP wiring = %#v", write)
30+ }
31+ edit, ok := registry["edit"].(*tools.EditTool)
32+ if !ok || edit.LSP == nil || edit.LSPDiagnostics {
33+ t.Fatalf("edit tool LSP wiring = %#v", edit)
34+ }
35+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {
36+ t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))
37+ }
38+ readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
39+ if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
40+ t.Fatalf("read-only registry = %#v", readOnly)
41+ }
42+}
43+
15 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {44 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
16 root := t.TempDir()45 root := t.TempDir()
17 zotHome := filepath.Join(root, "zot-home")46 zotHome := filepath.Join(root, "zot-home")
M packages/agent/cli.go +49 -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,13 @@ 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+ initialAg := ag
662+ defer func() {
663+ closeAgentLSP(ag)
664+ if ag != initialAg {
665+ closeAgentLSP(initialAg)
666+ }
667+ }()
651 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)668 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
652 sess, err := openOrCreateSession(args, r, ag, version)669 sess, err := openOrCreateSession(args, r, ag, version)
653 if err != nil {670 if err != nil {
@@ -702,6 +719,13 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
702 defer stopExt()719 defer stopExt()
703 720
704 ag := r.NewAgent()721 ag := r.NewAgent()
722+ initialAg := ag
723+ defer func() {
724+ closeAgentLSP(ag)
725+ if ag != initialAg {
726+ closeAgentLSP(initialAg)
727+ }
728+ }()
705 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)729 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
706 sess, err := openOrCreateSession(args, r, ag, version)730 sess, err := openOrCreateSession(args, r, ag, version)
707 if err != nil {731 if err != nil {
@@ -795,6 +819,13 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
795 defer stopExt()819 defer stopExt()
796 820
797 ag := r.NewAgent()821 ag := r.NewAgent()
822+ initialAg := ag
823+ defer func() {
824+ closeAgentLSP(ag)
825+ if ag != initialAg {
826+ closeAgentLSP(initialAg)
827+ }
828+ }()
798 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)829 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
799 sess, err := openOrCreateSession(args, r, ag, version)830 sess, err := openOrCreateSession(args, r, ag, version)
800 if err != nil {831 if err != nil {
@@ -915,8 +946,10 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
915 if mutateRegistry != nil {946 if mutateRegistry != nil {
916 reg = mutateRegistry(reg)947 reg = mutateRegistry(reg)
917 }948 }
949+ oldTools := ag.Tools
918 ag.SetTools(reg)950 ag.SetTools(reg)
919 ag.System = resolved.SystemPrompt951 ag.System = resolved.SystemPrompt
952+ _ = tools.CloseLSPManagers(oldTools)
920 }953 }
921 954
922 // reloadResourcesAfterStartupPre reloads extensions (if any) and955 // reloadResourcesAfterStartupPre reloads extensions (if any) and
@@ -1152,6 +1185,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1152 return a1185 return a
1153 }1186 }
1154 1187
1188+ var ag *core.Agent
1155 buildAgent := func() (*core.Agent, string, string, error) {1189 buildAgent := func() (*core.Agent, string, string, error) {
1156 resolved, err := Resolve(args, true)1190 resolved, err := Resolve(args, true)
1157 if err != nil {1191 if err != nil {
@@ -1209,7 +1243,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1209 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1243 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1210 }1244 }
1211 1245
1212- var ag *core.Agent
1213 if r.HasCredential() {1246 if r.HasCredential() {
1214 ag = wireAgentExt(r.NewAgent())1247 ag = wireAgentExt(r.NewAgent())
1215 }1248 }
@@ -1321,6 +1354,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1321 return a1354 return a
1322 }1355 }
1323 wireAgentPersist(ag)1356 wireAgentPersist(ag)
1357+ defer func() {
1358+ closeAgentLSP(liveInteractiveAgent(iv, ag))
1359+ closeResolvedLSP(r)
1360+ }()
1324 1361
1325 // Re-wrap the build closures so any agent constructed by the TUI1362 // Re-wrap the build closures so any agent constructed by the TUI
1326 // (login, /model swap to a different provider) also gets the1363 // (login, /model swap to a different provider) also gets the
@@ -1601,6 +1638,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1601 activeModel = newModel1638 activeModel = newModel
1602 persistMu.Unlock()1639 persistMu.Unlock()
1603 ag = newAg1640 ag = newAg
1641+ closeAgentLSP(currentAg)
1604 1642
1605 // Push the new state into the running Interactive.1643 // Push the new state into the running Interactive.
1606 if iv != nil {1644 if iv != nil {
@@ -1709,6 +1747,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1709 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,1747 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
1710 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1748 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1711 FastMode: &fastMode,1749 FastMode: &fastMode,
1750+ LSPEnabled: initialCfg.LSPEnabled,
1751+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
1712 AutoCompactThreshold: initialCfg.AutoCompactThreshold,1752 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1713 JailByDefault: initialCfg.JailByDefault,1753 JailByDefault: initialCfg.JailByDefault,
1714 QuickModelShortcuts: quickModelShortcuts,1754 QuickModelShortcuts: quickModelShortcuts,
@@ -1766,6 +1806,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1766 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)1806 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1767 }1807 }
1768 },1808 },
1809+ RefreshTools: func() {
1810+ current := liveInteractiveAgent(iv, ag)
1811+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1812+ },
1769 AuthManager: mgr,1813 AuthManager: mgr,
1770 LlamaCPPConfig: ResolveLlamaCPPConfig,1814 LlamaCPPConfig: ResolveLlamaCPPConfig,
1771 RefreshLlamaCPPModels: RefreshLlamaCPPModels,1815 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
@@ -1977,7 +2021,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1977 if !ok {2021 if !ok {
1978 return2022 return
1979 }2023 }
1980- if finalAg := iv.Agent(); finalAg != nil {2024+ finalAg := iv.Agent()
2025+ if finalAg != nil {
1981 persistMu.Lock()2026 persistMu.Lock()
1982 if sess != nil {2027 if sess != nil {
1983 writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)2028 writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
@@ -1987,6 +2032,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1987 }2032 }
1988 persistMu.Unlock()2033 persistMu.Unlock()
1989 }2034 }
2035+ closeAgentLSP(finalAg)
2036+ closeResolvedLSP(r)
1990 // Exit cleanly. Re-raising the signal would skip os.Exit's2037 // Exit cleanly. Re-raising the signal would skip os.Exit's
1991 // at-exit hooks; explicit exit is fine because we've already2038 // at-exit hooks; explicit exit is fine because we've already
1992 // flushed the only at-risk state (the session file).2039 // 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(subagent bool) bool {
230+ return c.LSPEnabledFor(subagent) && (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(subagent bool) bool {
236+ return c.LSPEnabledFor(subagent) && 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 +678 -0
@@ -0,0 +1,678 @@
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+ applyEditMu sync.Mutex
63+ mu sync.Mutex
64+ pending map[int64]chan rpcResponse
65+ nextID int64
66+ closed bool
67+ closeOnce sync.Once
68+ done chan struct{}
69+ doneOnce sync.Once
70+ readErr atomic.Value // string; one concrete type keeps atomic.Value safe
71+
72+ options ClientOptions
73+ capMu sync.RWMutex
74+ caps json.RawMessage
75+ docsMu sync.Mutex
76+ docs map[string]int
77+}
78+
79+// NewClient starts a stdio server but does not initialize it. This separation
80+// is convenient for tests and lets callers choose an initialization timeout.
81+func NewClient(spec ServerConfig, root string) (*Client, error) {
82+ return NewClientWithOptions(spec, root, ClientOptions{})
83+}
84+
85+// NewClientWithOptions starts a client with notification callbacks installed.
86+func NewClientWithOptions(spec ServerConfig, root string, options ClientOptions) (*Client, error) {
87+ if strings.EqualFold(spec.Kind, "cli") {
88+ return nil, fmt.Errorf("%s is a CLI provider, not an LSP server", spec.ID)
89+ }
90+ root, err := filepath.Abs(root)
91+ if err != nil {
92+ return nil, err
93+ }
94+ command, err := ResolveCommand(root, spec.Command)
95+ if err != nil {
96+ return nil, fmt.Errorf("%s: %w", spec.ID, err)
97+ }
98+ cmd := exec.Command(command, spec.Args...)
99+ cmd.WaitDelay = 2 * time.Second
100+ cmd.Dir = root
101+ cmd.Env = mergedEnvironment(spec.Env)
102+ stdin, err := cmd.StdinPipe()
103+ if err != nil {
104+ return nil, fmt.Errorf("LSP stdin: %w", err)
105+ }
106+ stdout, err := cmd.StdoutPipe()
107+ if err != nil {
108+ _ = stdin.Close()
109+ return nil, fmt.Errorf("LSP stdout: %w", err)
110+ }
111+ stderr, err := cmd.StderrPipe()
112+ if err != nil {
113+ _ = stdin.Close()
114+ _ = stdout.Close()
115+ return nil, fmt.Errorf("LSP stderr: %w", err)
116+ }
117+ if err := cmd.Start(); err != nil {
118+ _ = stdin.Close()
119+ _ = stdout.Close()
120+ _ = stderr.Close()
121+ return nil, fmt.Errorf("start %s: %w", spec.ID, err)
122+ }
123+ c := &Client{
124+ Spec: spec, Root: root, Command: command, cmd: cmd,
125+ stdin: stdin, stdout: stdout, stderr: stderr,
126+ pending: make(map[int64]chan rpcResponse), done: make(chan struct{}),
127+ options: options, docs: make(map[string]int),
128+ }
129+ go c.readLoop()
130+ go c.stderrLoop()
131+ return c, nil
132+}
133+
134+// NewClientWithContext starts and initializes a client.
135+func NewClientWithContext(ctx context.Context, spec ServerConfig, root string, options ClientOptions) (*Client, error) {
136+ client, err := NewClientWithOptions(spec, root, options)
137+ if err != nil {
138+ return nil, err
139+ }
140+ if err := client.Initialize(ctx); err != nil {
141+ _ = client.Close()
142+ return nil, err
143+ }
144+ return client, nil
145+}
146+
147+func (c *Client) Initialize(ctx context.Context) error {
148+ if ctx == nil {
149+ ctx = context.Background()
150+ }
151+ folders := []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}
152+ params := map[string]any{
153+ "processId": os.Getpid(),
154+ "rootUri": mustURI(c.Root),
155+ "workspaceFolders": folders,
156+ "capabilities": map[string]any{
157+ "workspace": map[string]any{
158+ "applyEdit": true,
159+ "workspaceFolders": true,
160+ "configuration": true,
161+ },
162+ "textDocument": map[string]any{
163+ "publishDiagnostics": map[string]any{"relatedInformation": true},
164+ },
165+ },
166+ "initializationOptions": c.options.InitializationOptions,
167+ "clientInfo": map[string]any{"name": "zot", "version": "lsp"},
168+ }
169+ result, err := c.Request(ctx, "initialize", params)
170+ if err != nil {
171+ return fmt.Errorf("initialize %s: %w", c.Spec.ID, err)
172+ }
173+ var response struct {
174+ Capabilities json.RawMessage `json:"capabilities"`
175+ }
176+ if err := json.Unmarshal(result, &response); err == nil {
177+ c.capMu.Lock()
178+ c.caps = append(c.caps[:0], response.Capabilities...)
179+ c.capMu.Unlock()
180+ }
181+ if err := c.Notify("initialized", map[string]any{}); err != nil {
182+ return err
183+ }
184+ return nil
185+}
186+
187+// Capabilities returns the initialize result's capabilities.
188+func (c *Client) Capabilities() json.RawMessage {
189+ c.capMu.RLock()
190+ defer c.capMu.RUnlock()
191+ return append(json.RawMessage(nil), c.caps...)
192+}
193+
194+// Request sends a raw JSON-RPC request and waits for its response.
195+func (c *Client) Request(ctx context.Context, method string, params any) (json.RawMessage, error) {
196+ if ctx == nil {
197+ ctx = context.Background()
198+ }
199+ id := atomic.AddInt64(&c.nextID, 1)
200+ responseCh := make(chan rpcResponse, 1)
201+ c.mu.Lock()
202+ if c.closed {
203+ c.mu.Unlock()
204+ return nil, errors.New("LSP client is closed")
205+ }
206+ c.pending[id] = responseCh
207+ c.mu.Unlock()
208+ request := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}
209+ if err := c.send(request); err != nil {
210+ c.mu.Lock()
211+ delete(c.pending, id)
212+ c.mu.Unlock()
213+ return nil, err
214+ }
215+ select {
216+ case response := <-responseCh:
217+ if response.Error != nil {
218+ return nil, fmt.Errorf("LSP %s (%d): %s", method, response.Error.Code, response.Error.Message)
219+ }
220+ return response.Result, nil
221+ case <-ctx.Done():
222+ c.mu.Lock()
223+ delete(c.pending, id)
224+ c.mu.Unlock()
225+ _ = c.Notify("$/cancelRequest", map[string]any{"id": id})
226+ return nil, ctx.Err()
227+ case <-c.done:
228+ return nil, c.processError()
229+ }
230+}
231+
232+// Notify sends a JSON-RPC notification.
233+func (c *Client) Notify(method string, params any) error {
234+ return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
235+}
236+
237+func (c *Client) send(message any) error {
238+ payload, err := json.Marshal(message)
239+ if err != nil {
240+ return err
241+ }
242+ c.writeMu.Lock()
243+ defer c.writeMu.Unlock()
244+ c.mu.Lock()
245+ closed := c.closed
246+ c.mu.Unlock()
247+ if closed {
248+ return errors.New("LSP client is closed")
249+ }
250+ if err := WriteMessage(c.stdin, payload); err != nil {
251+ return fmt.Errorf("write LSP message: %w", err)
252+ }
253+ return nil
254+}
255+
256+// DidOpen publishes a complete document. Callers which already opened the URI
257+// should use DidChange for subsequent full-content updates.
258+func (c *Client) DidOpen(path, languageID, text string) error {
259+ uri, err := pathToURI(path)
260+ if err != nil {
261+ return err
262+ }
263+ version, alreadyOpen := c.nextDocumentVersion(uri)
264+ if alreadyOpen {
265+ return c.Notify("textDocument/didChange", map[string]any{
266+ "textDocument": map[string]any{"uri": uri, "version": version},
267+ "contentChanges": []map[string]any{{"text": text}},
268+ })
269+ }
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+ version, _ := c.nextDocumentVersion(uri)
283+ return c.Notify("textDocument/didChange", map[string]any{
284+ "textDocument": map[string]any{"uri": uri, "version": version},
285+ "contentChanges": []map[string]any{{"text": text}},
286+ })
287+}
288+
289+func (c *Client) nextDocumentVersion(uri string) (int, bool) {
290+ c.docsMu.Lock()
291+ defer c.docsMu.Unlock()
292+ version := c.docs[uri]
293+ alreadyOpen := version != 0
294+ if version == 0 {
295+ version = 1
296+ } else {
297+ version++
298+ }
299+ c.docs[uri] = version
300+ return version, alreadyOpen
301+}
302+
303+func (c *Client) DidSave(path, text string) error {
304+ uri, err := pathToURI(path)
305+ if err != nil {
306+ return err
307+ }
308+ params := map[string]any{"textDocument": map[string]any{"uri": uri}}
309+ if text != "" {
310+ params["text"] = text
311+ }
312+ return c.Notify("textDocument/didSave", params)
313+}
314+
315+func (c *Client) readLoop() {
316+ reader := bufio.NewReaderSize(c.stdout, 64*1024)
317+ for {
318+ payload, err := ReadMessage(reader)
319+ if err != nil {
320+ if !errors.Is(err, io.EOF) {
321+ c.readErr.Store(err.Error())
322+ }
323+ c.failPending(err)
324+ c.signalDone()
325+ return
326+ }
327+ var envelope rpcEnvelope
328+ if err := json.Unmarshal(payload, &envelope); err != nil {
329+ c.readErr.Store(fmt.Sprintf("decode LSP message: %v", err))
330+ continue
331+ }
332+ if envelope.Method == "" && len(envelope.ID) > 0 {
333+ var id int64
334+ if json.Unmarshal(envelope.ID, &id) == nil {
335+ c.mu.Lock()
336+ channel := c.pending[id]
337+ delete(c.pending, id)
338+ c.mu.Unlock()
339+ if channel != nil {
340+ channel <- rpcResponse{Result: envelope.Result, Error: envelope.Error}
341+ }
342+ }
343+ continue
344+ }
345+ if envelope.Method != "" {
346+ // Notifications carry ordered state, so handle them on the
347+ // reader goroutine. Server requests may block while they wait
348+ // for an edit response, so those remain asynchronous.
349+ if len(envelope.ID) == 0 || string(envelope.ID) == "null" {
350+ c.handleServerMessage(envelope)
351+ } else {
352+ go c.handleServerMessage(envelope)
353+ }
354+ }
355+ }
356+}
357+
358+func (c *Client) handleServerMessage(message rpcEnvelope) {
359+ switch message.Method {
360+ case "textDocument/publishDiagnostics":
361+ c.handleDiagnostics(message.Params)
362+ return
363+ case "$/progress":
364+ if c.options.OnProgress != nil {
365+ c.options.OnProgress(message.Method, message.Params)
366+ }
367+ return
368+ case "window/logMessage", "window/showMessage", "telemetry/event":
369+ if c.options.OnLog != nil {
370+ var value struct {
371+ Message string `json:"message"`
372+ }
373+ _ = json.Unmarshal(message.Params, &value)
374+ c.options.OnLog(value.Message)
375+ }
376+ if len(message.ID) == 0 || string(message.ID) == "null" {
377+ return
378+ }
379+ _ = c.respond(message.ID, nil, nil)
380+ return
381+ case "workspace/configuration":
382+ _ = c.respond(message.ID, c.configuration(message.Params), nil)
383+ case "workspace/workspaceFolders":
384+ _ = c.respond(message.ID, []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}, nil)
385+ case "workspace/applyEdit":
386+ var params struct {
387+ Edit WorkspaceEdit `json:"edit"`
388+ }
389+ if err := json.Unmarshal(message.Params, &params); err != nil {
390+ _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil)
391+ return
392+ }
393+ c.applyEditMu.Lock()
394+ err := c.applyEdit(params.Edit)
395+ c.applyEditMu.Unlock()
396+ result := map[string]any{"applied": err == nil}
397+ if err != nil {
398+ result["failureReason"] = err.Error()
399+ }
400+ _ = c.respond(message.ID, result, nil)
401+ case "client/registerCapability", "window/workDoneProgress/create":
402+ _ = c.respond(message.ID, map[string]any{}, nil)
403+ default:
404+ if len(message.ID) > 0 && string(message.ID) != "null" {
405+ _ = c.respond(message.ID, nil, &rpcError{Code: -32601, Message: "method not supported"})
406+ }
407+ }
408+}
409+
410+func (c *Client) configuration(params json.RawMessage) []any {
411+ var request struct {
412+ Items []struct {
413+ Section string `json:"section"`
414+ } `json:"items"`
415+ }
416+ _ = json.Unmarshal(params, &request)
417+ out := make([]any, len(request.Items))
418+ for i, item := range request.Items {
419+ if item.Section == "" {
420+ out[i] = c.options.Settings
421+ continue
422+ }
423+ out[i] = lookupSetting(c.options.Settings, item.Section)
424+ }
425+ return out
426+}
427+
428+func lookupSetting(settings map[string]any, section string) any {
429+ if settings == nil {
430+ return nil
431+ }
432+ if value, ok := settings[section]; ok {
433+ return value
434+ }
435+ var value any = settings
436+ for _, part := range strings.Split(section, ".") {
437+ object, ok := value.(map[string]any)
438+ if !ok {
439+ return nil
440+ }
441+ value, ok = object[part]
442+ if !ok {
443+ return nil
444+ }
445+ }
446+ return value
447+}
448+
449+func (c *Client) applyEdit(edit WorkspaceEdit) error {
450+ if c.options.ApplyEdit != nil {
451+ return c.options.ApplyEdit(edit)
452+ }
453+ return ApplyWorkspaceEdit(c.Root, edit)
454+}
455+
456+func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error {
457+ message := map[string]any{"jsonrpc": "2.0", "id": id}
458+ if rpcErr != nil {
459+ message["error"] = rpcErr
460+ } else {
461+ message["result"] = result
462+ }
463+ return c.send(message)
464+}
465+
466+func (c *Client) handleDiagnostics(params json.RawMessage) {
467+ var value struct {
468+ URI string `json:"uri"`
469+ Diagnostics []Diagnostic `json:"diagnostics"`
470+ }
471+ if err := json.Unmarshal(params, &value); err != nil {
472+ return
473+ }
474+ path, err := uriToPath(value.URI)
475+ if err != nil {
476+ return
477+ }
478+ for i := range value.Diagnostics {
479+ value.Diagnostics[i].Path = path
480+ value.Diagnostics[i].Server = c.Spec.ID
481+ }
482+ if len(value.Diagnostics) == 0 {
483+ // Preserve the URI on a clear so Manager can remove only this file's
484+ // snapshot rather than accidentally retaining stale diagnostics.
485+ value.Diagnostics = []Diagnostic{{Path: path, Server: c.Spec.ID, Clear: true}}
486+ }
487+ if c.options.OnDiagnostics != nil {
488+ c.options.OnDiagnostics(value.Diagnostics)
489+ }
490+}
491+
492+func (c *Client) stderrLoop() {
493+ if c.stderr == nil {
494+ return
495+ }
496+ var output limitedBuffer
497+ output.limit = 1 << 20
498+ _, _ = io.Copy(&output, c.stderr)
499+ if output.Len() > 0 && c.options.OnLog != nil {
500+ c.options.OnLog(strings.TrimSpace(output.String()))
501+ }
502+}
503+
504+func (c *Client) failPending(err error) {
505+ c.mu.Lock()
506+ defer c.mu.Unlock()
507+ for id, channel := range c.pending {
508+ delete(c.pending, id)
509+ channel <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}}
510+ }
511+}
512+
513+func (c *Client) processError() error {
514+ if err, ok := c.readErr.Load().(string); ok && err != "" {
515+ return fmt.Errorf("LSP process stopped: %s", err)
516+ }
517+ return errors.New("LSP process stopped")
518+}
519+
520+func (c *Client) signalDone() { c.doneOnce.Do(func() { close(c.done) }) }
521+
522+// Close stops the process and releases all pipes. It is safe to call more
523+// than once and is also used by Manager.Reload.
524+func (c *Client) Close() error {
525+ var waitErr error
526+ c.closeOnce.Do(func() {
527+ // Give a cooperative server a short shutdown window before killing
528+ // the process. Close remains bounded for servers that do not support
529+ // shutdown or are already wedged.
530+ if c.cmd.ProcessState == nil {
531+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
532+ _, _ = c.Request(shutdownCtx, "shutdown", nil)
533+ cancel()
534+ _ = c.Notify("exit", nil)
535+ }
536+ // Serialize the closed transition with send so a writer that has
537+ // passed its closed check cannot race stdin.Close.
538+ c.writeMu.Lock()
539+ c.mu.Lock()
540+ c.closed = true
541+ c.mu.Unlock()
542+ c.signalDone()
543+ _ = c.stdin.Close()
544+ c.writeMu.Unlock()
545+ killed := false
546+ exited := make(chan error, 1)
547+ go func() { exited <- c.cmd.Wait() }()
548+ timer := time.NewTimer(500 * time.Millisecond)
549+ select {
550+ case waitErr = <-exited:
551+ timer.Stop()
552+ case <-timer.C:
553+ if c.cmd.Process != nil && c.cmd.ProcessState == nil {
554+ killed = c.cmd.Process.Kill() == nil
555+ }
556+ waitErr = <-exited
557+ }
558+ if killed {
559+ // A process terminated by this cleanup path normally returns an
560+ // ExitError; preserve unrelated wait failures such as pipe
561+ // errors so callers still get useful lifecycle diagnostics.
562+ var exitErr *exec.ExitError
563+ if errors.As(waitErr, &exitErr) {
564+ waitErr = nil
565+ }
566+ }
567+ _ = c.stdout.Close()
568+ _ = c.stderr.Close()
569+ c.failPending(errors.New("LSP client closed"))
570+ })
571+ return waitErr
572+}
573+
574+func mustURI(path string) string {
575+ uri, _ := pathToURI(path)
576+ return uri
577+}
578+
579+func mergedEnvironment(overrides map[string]string) []string {
580+ env := os.Environ()
581+ if len(overrides) == 0 {
582+ return env
583+ }
584+ index := make(map[string]int, len(env))
585+ for i, item := range env {
586+ if key, _, ok := strings.Cut(item, "="); ok {
587+ index[key] = i
588+ }
589+ }
590+ for key, value := range overrides {
591+ item := key + "=" + value
592+ if i, ok := index[key]; ok {
593+ env[i] = item
594+ } else {
595+ env = append(env, item)
596+ }
597+ }
598+ return env
599+}
600+
601+// ResolveCommand searches workspace-local tool directories before PATH.
602+func ResolveCommand(cwd, command string) (string, error) {
603+ if strings.TrimSpace(command) == "" {
604+ return "", errors.New("empty command")
605+ }
606+ cwd, _ = filepath.Abs(cwd)
607+ candidates := []string{}
608+ if filepath.IsAbs(command) || strings.ContainsAny(command, `/\\`) {
609+ if filepath.IsAbs(command) {
610+ candidates = append(candidates, command)
611+ } else {
612+ candidates = append(candidates, filepath.Join(cwd, command))
613+ }
614+ } else {
615+ // A model often runs in a package subdirectory while the project's
616+ // executable lives at the repository root. Search each workspace
617+ // ancestor before falling back to PATH.
618+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
619+ for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), filepath.Join(".venv", "Scripts"), "bin", "Scripts"} {
620+ candidates = append(candidates, filepath.Join(dir, local, command))
621+ }
622+ next := filepath.Dir(dir)
623+ if next == dir {
624+ break
625+ }
626+ }
627+ }
628+ for _, candidate := range candidates {
629+ for _, path := range executableVariants(candidate) {
630+ if isRunnable(path) {
631+ return path, nil
632+ }
633+ }
634+ }
635+ if path, err := exec.LookPath(command); err == nil {
636+ return path, nil
637+ }
638+ return "", fmt.Errorf("command %q not found in node_modules/.bin, .venv/bin, bin, or PATH", command)
639+}
640+
641+func executableVariants(path string) []string {
642+ if filepath.Ext(path) != "" {
643+ return []string{path}
644+ }
645+ return []string{path, path + ".exe", path + ".cmd", path + ".bat"}
646+}
647+
648+func isRunnable(path string) bool {
649+ info, err := os.Stat(path)
650+ if err != nil || info.IsDir() {
651+ return false
652+ }
653+ if info.Mode()&0111 != 0 || filepath.Ext(path) == ".cmd" || filepath.Ext(path) == ".bat" || filepath.Ext(path) == ".exe" {
654+ return true
655+ }
656+ return false
657+}
658+
659+// WaitForDiagnostics gives asynchronous LSP servers a bounded opportunity to
660+// publish diagnostics after didOpen/didChange.
661+func (c *Client) WaitForDiagnostics(ctx context.Context, delay time.Duration) error {
662+ if ctx == nil {
663+ ctx = context.Background()
664+ }
665+ if delay <= 0 {
666+ return nil
667+ }
668+ timer := time.NewTimer(delay)
669+ defer timer.Stop()
670+ select {
671+ case <-timer.C:
672+ return nil
673+ case <-ctx.Done():
674+ return ctx.Err()
675+ case <-c.done:
676+ return c.processError()
677+ }
678+}
A packages/agent/lsp/config.go +693 -0
@@ -0,0 +1,693 @@
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+ "runtime"
11+ "strings"
12+)
13+
14+// ServerConfig describes either a stdio LSP server or a command-line linter.
15+// Command is resolved relative to the workspace before it is started.
16+type ServerConfig struct {
17+ ID string `json:"-"`
18+ Kind string `json:"kind,omitempty"` // "lsp" or "cli"
19+ Command string `json:"command"`
20+ Args []string `json:"args,omitempty"`
21+ FileTypes []string `json:"fileTypes,omitempty"`
22+ LanguageID string `json:"languageId,omitempty"`
23+ RootMarkers []string `json:"rootMarkers,omitempty"`
24+ IsLinter bool `json:"isLinter,omitempty"`
25+ Disabled bool `json:"disabled,omitempty"`
26+ Parser string `json:"parser,omitempty"`
27+ Mode string `json:"mode,omitempty"` // files or workspace for CLI linters
28+ RunOnStartup bool `json:"runOnStartup,omitempty"`
29+ RunOnChange bool `json:"runOnChange,omitempty"`
30+ Description string `json:"description,omitempty"`
31+ Env map[string]string `json:"env,omitempty"`
32+ Settings map[string]any `json:"settings,omitempty"`
33+ InitializationOptions any `json:"initializationOptions,omitempty"`
34+}
35+
36+// Config is the merged global/project LSP configuration.
37+type Config struct {
38+ Servers map[string]ServerConfig
39+ AutoDetect bool
40+ // Explicit contains servers named or defined by a config file. When
41+ // auto-detection is disabled, only these configured providers remain
42+ // eligible, matching the providers/autoDetect configuration behavior.
43+ Explicit map[string]bool
44+ Files []string
45+}
46+
47+// BuiltinServers returns the built-in language servers. The slice is a copy
48+// and may be modified by callers.
49+func BuiltinServers() []ServerConfig {
50+ return []ServerConfig{
51+ {ID: "gopls", Kind: "lsp", Command: "gopls", FileTypes: []string{"go", ".go"}, RootMarkers: []string{"go.work", "go.mod"}, Description: "Go language server"},
52+ {ID: "rust-analyzer", Kind: "lsp", Command: "rust-analyzer", FileTypes: []string{"rust", "rs", ".rs"}, RootMarkers: []string{"Cargo.toml"}, Description: "Rust language server"},
53+ {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"},
54+ {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"},
55+ {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"},
56+ {ID: "yaml-language-server", Kind: "lsp", Command: "yaml-language-server", Args: []string{"--stdio"}, FileTypes: []string{"yaml", "yml", ".yaml", ".yml"}, Description: "YAML language server"},
57+ {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"},
58+ {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"},
59+ {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"},
60+ {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--output.json.path", "stdout"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"},
61+ {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"},
62+ }
63+}
64+
65+// LoadConfig reads the global and project lsp.json files. Project files are
66+// applied after the global file; within a project, lsp.json, .lsp.json, and
67+// .zot/lsp.json are applied in that order. Missing files are harmless.
68+func LoadConfig(cwd string) (Config, error) {
69+ cwd, err := absDir(cwd)
70+ if err != nil {
71+ return Config{}, err
72+ }
73+ var files []string
74+ if home := zotHome(); home != "" {
75+ files = append(files, filepath.Join(home, "lsp.json"))
76+ }
77+ files = append(files,
78+ filepath.Join(cwd, ".pi", "lsp.json"),
79+ filepath.Join(cwd, ".omp", "lsp.json"),
80+ filepath.Join(cwd, "lsp.json"),
81+ filepath.Join(cwd, ".lsp.json"),
82+ filepath.Join(cwd, ".zot", "lsp.json"),
83+ )
84+
85+ objects := make(map[string]map[string]json.RawMessage)
86+ explicit := make(map[string]bool)
87+ autoDetect := true
88+ for _, path := range files {
89+ entries, exists, data, err := readConfigFile(path)
90+ if err != nil {
91+ return Config{}, err
92+ }
93+ if len(data) > 0 {
94+ var metadata struct {
95+ AutoDetect *bool `json:"autoDetect"`
96+ }
97+ if json.Unmarshal(data, &metadata) == nil && metadata.AutoDetect != nil {
98+ autoDetect = *metadata.AutoDetect
99+ }
100+ }
101+ if !exists {
102+ continue
103+ }
104+ for id := range entries {
105+ explicit[id] = true
106+ }
107+ for id := range providerIDs(data) {
108+ explicit[id] = true
109+ }
110+ for id, entry := range entries {
111+ if old := objects[id]; old != nil {
112+ objects[id] = mergeRawObjects(old, entry)
113+ } else {
114+ objects[id] = cloneRawObject(entry)
115+ }
116+ }
117+ }
118+
119+ merged := make(map[string]ServerConfig)
120+ for _, server := range BuiltinServers() {
121+ merged[server.ID] = server
122+ }
123+ for id, raw := range objects {
124+ decodeRaw := raw
125+ _, hasBuiltin := merged[id]
126+ if base, ok := merged[id]; ok {
127+ // Decode the already-merged JSON over the built-in by marshaling the
128+ // built-in first. This retains fields that were omitted in the file.
129+ baseRaw, _ := json.Marshal(base)
130+ baseMap := map[string]json.RawMessage{}
131+ _ = json.Unmarshal(baseRaw, &baseMap)
132+ decodeRaw = mergeRawObjects(baseMap, raw)
133+ }
134+ server, err := decodeServer(id, decodeRaw)
135+ if err != nil {
136+ return Config{}, fmt.Errorf("lsp server %q: %w", id, err)
137+ }
138+ // A parser/mode is an explicit CLI declaration even when the
139+ // overridden built-in was an LSP. This makes {"parser":"sarif"}
140+ // sufficient for a linter override.
141+ if hasBuiltin {
142+ if _, hasKind := raw["kind"]; !hasKind {
143+ if _, hasParser := raw["parser"]; hasParser {
144+ server.Kind = "cli"
145+ }
146+ }
147+ }
148+ merged[id] = server
149+ }
150+ for id, server := range merged {
151+ if server.ID == "" {
152+ server.ID = id
153+ merged[id] = server
154+ }
155+ }
156+ return Config{Servers: merged, AutoDetect: autoDetect, Explicit: explicit, Files: files}, nil
157+}
158+
159+// LoadServers is a convenience for callers that only need auto-detected
160+// server configurations.
161+func LoadServers(cwd string) ([]ServerConfig, error) {
162+ config, err := LoadConfig(cwd)
163+ if err != nil {
164+ return nil, err
165+ }
166+ return config.EffectiveServers(cwd), nil
167+}
168+
169+// EffectiveServers returns enabled servers which are relevant to any file in
170+// the workspace. It is useful for status UIs; Manager uses ApplicableServers
171+// for a particular file.
172+func (c Config) EffectiveServers(cwd string) []ServerConfig {
173+ var out []ServerConfig
174+ for _, server := range c.Servers {
175+ if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) {
176+ continue
177+ }
178+ if len(server.FileTypes) == 0 && len(server.RootMarkers) == 0 {
179+ out = append(out, server)
180+ continue
181+ }
182+ if hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) {
183+ out = append(out, server)
184+ }
185+ }
186+ sortServers(out)
187+ return out
188+}
189+
190+// ApplicableServers selects servers for path. An empty path means the cwd.
191+func (c Config) ApplicableServers(cwd, path string) []ServerConfig {
192+ var out []ServerConfig
193+ for _, server := range c.Servers {
194+ if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) || !serverMatchesPath(cwd, path, server) {
195+ continue
196+ }
197+ out = append(out, server)
198+ }
199+ sortServers(out)
200+ return out
201+}
202+
203+func serverMatchesPath(cwd, path string, server ServerConfig) bool {
204+ if len(server.FileTypes) == 0 {
205+ return len(server.RootMarkers) == 0 || hasRootMarker(cwd, server.RootMarkers)
206+ }
207+ if path != "" {
208+ if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) {
209+ return false
210+ }
211+ return fileTypeMatches(path, server.FileTypes)
212+ }
213+ return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes)
214+}
215+
216+func hasMatchingFile(cwd string, types []string) bool {
217+ normalizedTypes := normalizeFileTypes(types)
218+ var found bool
219+ _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error {
220+ if err != nil {
221+ return nil
222+ }
223+ if found {
224+ return filepath.SkipAll
225+ }
226+ if entry.IsDir() {
227+ if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") {
228+ return filepath.SkipDir
229+ }
230+ return nil
231+ }
232+ found = fileTypeMatchesNormalized(path, normalizedTypes)
233+ if found {
234+ return filepath.SkipAll
235+ }
236+ return nil
237+ })
238+ return found
239+}
240+
241+func fileTypeMatches(path string, types []string) bool {
242+ return fileTypeMatchesNormalized(path, normalizeFileTypes(types))
243+}
244+
245+func normalizeFileTypes(types []string) []string {
246+ normalized := make([]string, 0, len(types))
247+ for _, typ := range types {
248+ normalized = append(normalized, strings.ToLower(strings.TrimSpace(typ)))
249+ }
250+ return normalized
251+}
252+
253+func fileTypeMatchesNormalized(path string, types []string) bool {
254+ ext := strings.ToLower(filepath.Ext(path))
255+ base := strings.ToLower(filepath.Base(path))
256+ lang := languageForExtension(ext)
257+ for _, typ := range types {
258+ if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") {
259+ return true
260+ }
261+ if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) {
262+ return true
263+ }
264+ }
265+ return false
266+}
267+
268+func hasRootMarker(cwd string, markers []string) bool {
269+ if len(markers) == 0 {
270+ return false
271+ }
272+ cwd, _ = absDir(cwd)
273+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
274+ for _, marker := range markers {
275+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
276+ return true
277+ }
278+ }
279+ next := filepath.Dir(dir)
280+ if next == dir {
281+ break
282+ }
283+ }
284+ return false
285+}
286+
287+// FindRoot resolves a server's workspace root by walking up from cwd and
288+// choosing the nearest directory containing one of its root markers.
289+func FindRoot(cwd string, markers []string) string {
290+ cwd, err := absDir(cwd)
291+ if err != nil {
292+ return cwd
293+ }
294+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
295+ for _, marker := range markers {
296+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
297+ return dir
298+ }
299+ }
300+ next := filepath.Dir(dir)
301+ if next == dir {
302+ break
303+ }
304+ }
305+ return cwd
306+}
307+
308+func sortServers(servers []ServerConfig) {
309+ rank := func(server ServerConfig) int {
310+ if server.Kind == "lsp" && !server.IsLinter {
311+ return 0
312+ }
313+ if server.Kind == "lsp" {
314+ return 1
315+ }
316+ return 2
317+ }
318+ for i := 1; i < len(servers); i++ {
319+ for j := i; j > 0; j-- {
320+ left, right := servers[j-1], servers[j]
321+ if rank(left) < rank(right) || (rank(left) == rank(right) && left.ID <= right.ID) {
322+ break
323+ }
324+ servers[j-1], servers[j] = right, left
325+ }
326+ }
327+}
328+
329+func absDir(cwd string) (string, error) {
330+ if strings.TrimSpace(cwd) == "" {
331+ cwd, _ = os.Getwd()
332+ }
333+ abs, err := filepath.Abs(cwd)
334+ if err != nil {
335+ return "", err
336+ }
337+ info, err := os.Stat(abs)
338+ if err == nil && !info.IsDir() {
339+ return "", fmt.Errorf("workspace %q is not a directory", cwd)
340+ }
341+ return filepath.Clean(abs), nil
342+}
343+
344+func zotHome() string {
345+ if value := strings.TrimSpace(os.Getenv("ZOT_HOME")); value != "" {
346+ return value
347+ }
348+ if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" {
349+ return filepath.Join(value, "zot")
350+ }
351+ switch runtime.GOOS {
352+ case "darwin":
353+ home, _ := os.UserHomeDir()
354+ if home == "" {
355+ return ""
356+ }
357+ return filepath.Join(home, "Library", "Application Support", "zot")
358+ case "windows":
359+ if value := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); value != "" {
360+ return filepath.Join(value, "zot")
361+ }
362+ }
363+ home, _ := os.UserHomeDir()
364+ if home == "" {
365+ return ""
366+ }
367+ return filepath.Join(home, ".local", "state", "zot")
368+}
369+
370+func providerIDs(data []byte) map[string]struct{} {
371+ ids := make(map[string]struct{})
372+ var top struct {
373+ Providers []json.RawMessage `json:"providers"`
374+ }
375+ if json.Unmarshal(data, &top) != nil {
376+ return ids
377+ }
378+ for _, raw := range top.Providers {
379+ var id string
380+ if json.Unmarshal(raw, &id) == nil {
381+ if id = strings.TrimSpace(id); id != "" {
382+ ids[id] = struct{}{}
383+ }
384+ continue
385+ }
386+ var object struct {
387+ ID string `json:"id"`
388+ }
389+ if json.Unmarshal(raw, &object) == nil {
390+ if id = strings.TrimSpace(object.ID); id != "" {
391+ ids[id] = struct{}{}
392+ }
393+ }
394+ }
395+ return ids
396+}
397+
398+func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, []byte, error) {
399+ data, err := os.ReadFile(path)
400+ if errors.Is(err, os.ErrNotExist) {
401+ return nil, false, nil, nil
402+ }
403+ if err != nil {
404+ return nil, true, nil, err
405+ }
406+ var top map[string]json.RawMessage
407+ if err := json.Unmarshal(data, &top); err != nil {
408+ return nil, true, data, fmt.Errorf("read %s: %w", path, err)
409+ }
410+ root := top
411+ if raw, ok := top["providers"]; ok {
412+ // A providers array may contain compact string entries. Preserve
413+ // them (built-ins are already in the default registry)
414+ // and turn object entries into the map shape used internally.
415+ var providers []json.RawMessage
416+ if err := json.Unmarshal(raw, &providers); err != nil {
417+ return nil, true, data, fmt.Errorf("%s.providers must be an array: %w", path, err)
418+ }
419+ root = make(map[string]json.RawMessage)
420+ for index, provider := range providers {
421+ var id string
422+ if json.Unmarshal(provider, &id) == nil {
423+ continue
424+ }
425+ var object map[string]json.RawMessage
426+ if err := json.Unmarshal(provider, &object); err != nil {
427+ return nil, true, data, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err)
428+ }
429+ idRaw, ok := object["id"]
430+ if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" {
431+ return nil, true, data, fmt.Errorf("%s.providers[%d] is missing id", path, index)
432+ }
433+ id = strings.TrimSpace(id)
434+ delete(object, "id")
435+ flattenProviderObject(object)
436+ root[id] = mustJSON(object)
437+ }
438+ } else {
439+ wrappedRoot := make(map[string]json.RawMessage)
440+ wrapped := false
441+ for _, key := range []string{"servers", "lspServers", "linters"} {
442+ if raw, ok := top[key]; ok {
443+ var section map[string]json.RawMessage
444+ if err := json.Unmarshal(raw, &section); err != nil {
445+ return nil, true, data, fmt.Errorf("%s.%s must be an object: %w", path, key, err)
446+ }
447+ wrapped = true
448+ for id, value := range section {
449+ wrappedRoot[id] = value
450+ }
451+ }
452+ }
453+ if wrapped {
454+ root = wrappedRoot
455+ }
456+ }
457+ out := make(map[string]map[string]json.RawMessage)
458+ for id, raw := range root {
459+ 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" {
460+ continue
461+ }
462+ var object map[string]json.RawMessage
463+ if err := json.Unmarshal(raw, &object); err != nil {
464+ // A string is a convenient shorthand for a command.
465+ var command string
466+ if json.Unmarshal(raw, &command) == nil {
467+ object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))}
468+ } else {
469+ return nil, true, data, fmt.Errorf("%s.%s must be an object", path, id)
470+ }
471+ }
472+ out[id] = object
473+ }
474+ return out, true, data, nil
475+}
476+
477+func strconvQuote(s string) string {
478+ b, _ := json.Marshal(s)
479+ return string(b)
480+}
481+
482+// flattenProviderObject adapts nested provider shapes to the flat
483+// ServerConfig fields used by zot. Unknown provider metadata remains
484+// harmless JSON and is ignored by decodeServer.
485+func flattenProviderObject(object map[string]json.RawMessage) {
486+ if _, exists := object["command"]; !exists {
487+ var candidates []struct {
488+ Command string `json:"command"`
489+ Args []string `json:"args"`
490+ }
491+ if raw, ok := object["commandCandidates"]; ok && json.Unmarshal(raw, &candidates) == nil && len(candidates) > 0 && candidates[0].Command != "" {
492+ object["command"] = mustJSON(candidates[0].Command)
493+ if len(candidates[0].Args) > 0 {
494+ object["args"] = mustJSON(candidates[0].Args)
495+ }
496+ }
497+ }
498+ if _, exists := object["kind"]; !exists {
499+ if _, ok := object["cli"]; ok {
500+ object["kind"] = mustJSON("cli")
501+ } else if _, ok := object["lsp"]; ok {
502+ object["kind"] = mustJSON("lsp")
503+ }
504+ }
505+ if _, exists := object["fileTypes"]; !exists {
506+ var selectors []struct {
507+ LanguageID string `json:"languageId"`
508+ Extensions []string `json:"extensions"`
509+ Filenames []string `json:"filenames"`
510+ }
511+ if raw, ok := object["selectors"]; ok && json.Unmarshal(raw, &selectors) == nil {
512+ var fileTypes []string
513+ for _, selector := range selectors {
514+ if selector.LanguageID != "" {
515+ fileTypes = append(fileTypes, selector.LanguageID)
516+ }
517+ fileTypes = append(fileTypes, selector.Extensions...)
518+ fileTypes = append(fileTypes, selector.Filenames...)
519+ }
520+ if len(fileTypes) > 0 {
521+ object["fileTypes"] = mustJSON(uniqueStrings(fileTypes))
522+ }
523+ }
524+ if _, exists := object["fileTypes"]; !exists {
525+ var detect struct {
526+ FileExtensions []string `json:"fileExtensionsAny"`
527+ }
528+ if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil && len(detect.FileExtensions) > 0 {
529+ object["fileTypes"] = mustJSON(uniqueStrings(detect.FileExtensions))
530+ }
531+ }
532+ }
533+ if _, exists := object["rootMarkers"]; !exists {
534+ var detect struct {
535+ RepoFiles []string `json:"repoFilesAny"`
536+ ConfigFiles []string `json:"configFilesAny"`
537+ }
538+ if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil {
539+ markers := append(append([]string{}, detect.RepoFiles...), detect.ConfigFiles...)
540+ if len(markers) > 0 {
541+ object["rootMarkers"] = mustJSON(uniqueStrings(markers))
542+ }
543+ }
544+ if _, exists := object["rootMarkers"]; !exists {
545+ if raw, ok := object["configMarkers"]; ok {
546+ object["rootMarkers"] = append(json.RawMessage(nil), raw...)
547+ }
548+ }
549+ }
550+ if raw, ok := object["lsp"]; ok {
551+ var nested map[string]json.RawMessage
552+ if json.Unmarshal(raw, &nested) == nil {
553+ if _, exists := object["initializationOptions"]; !exists {
554+ if value, ok := nested["initializationOptions"]; ok {
555+ object["initializationOptions"] = append(json.RawMessage(nil), value...)
556+ }
557+ }
558+ }
559+ }
560+ if raw, ok := object["cli"]; ok {
561+ var nested map[string]json.RawMessage
562+ if json.Unmarshal(raw, &nested) == nil {
563+ for _, key := range []string{"parser", "mode", "runOnStartup", "runOnChange"} {
564+ if _, exists := object[key]; exists {
565+ continue
566+ }
567+ if value, ok := nested[key]; ok {
568+ object[key] = append(json.RawMessage(nil), value...)
569+ }
570+ }
571+ }
572+ }
573+}
574+
575+func uniqueStrings(values []string) []string {
576+ seen := make(map[string]struct{}, len(values))
577+ out := make([]string, 0, len(values))
578+ for _, value := range values {
579+ if strings.TrimSpace(value) == "" {
580+ continue
581+ }
582+ if _, exists := seen[value]; exists {
583+ continue
584+ }
585+ seen[value] = struct{}{}
586+ out = append(out, value)
587+ }
588+ return out
589+}
590+
591+func decodeServer(id string, raw map[string]json.RawMessage) (ServerConfig, error) {
592+ if _, exists := raw["initializationOptions"]; !exists {
593+ if value, ok := raw["initOptions"]; ok {
594+ raw["initializationOptions"] = append(json.RawMessage(nil), value...)
595+ }
596+ }
597+ if command, ok := raw["command"]; ok {
598+ var list []string
599+ if err := json.Unmarshal(command, &list); err == nil {
600+ if len(list) == 0 {
601+ return ServerConfig{}, fmt.Errorf("command array is empty")
602+ }
603+ raw["command"] = json.RawMessage(strconvQuote(list[0]))
604+ if len(list) > 1 {
605+ args, _ := json.Marshal(list[1:])
606+ if _, exists := raw["args"]; !exists {
607+ raw["args"] = args
608+ }
609+ }
610+ }
611+ }
612+ data, err := json.Marshal(raw)
613+ if err != nil {
614+ return ServerConfig{}, err
615+ }
616+ var server ServerConfig
617+ if err := json.Unmarshal(data, &server); err != nil {
618+ return ServerConfig{}, err
619+ }
620+ server.ID = id
621+ if server.Kind == "" {
622+ if server.Parser != "" || server.Mode != "" {
623+ server.Kind = "cli"
624+ } else {
625+ server.Kind = "lsp"
626+ }
627+ }
628+ server.Kind = strings.ToLower(server.Kind)
629+ if server.Kind != "lsp" && server.Kind != "cli" {
630+ return ServerConfig{}, fmt.Errorf("kind must be lsp or cli")
631+ }
632+ if server.Kind == "cli" && server.Parser == "" {
633+ server.Parser = "generic"
634+ }
635+ return server, nil
636+}
637+
638+func cloneRawObject(in map[string]json.RawMessage) map[string]json.RawMessage {
639+ out := make(map[string]json.RawMessage, len(in))
640+ for key, value := range in {
641+ out[key] = append(json.RawMessage(nil), value...)
642+ }
643+ return out
644+}
645+
646+func mergeRawObjects(base, override map[string]json.RawMessage) map[string]json.RawMessage {
647+ out := cloneRawObject(base)
648+ for key, value := range override {
649+ var baseObject, overrideObject map[string]json.RawMessage
650+ if json.Unmarshal(out[key], &baseObject) == nil && json.Unmarshal(value, &overrideObject) == nil {
651+ out[key] = mustJSON(mergeRawObjects(baseObject, overrideObject))
652+ continue
653+ }
654+ out[key] = append(json.RawMessage(nil), value...)
655+ }
656+ return out
657+}
658+
659+func mustJSON(value any) json.RawMessage {
660+ data, _ := json.Marshal(value)
661+ return data
662+}
663+
664+func languageForExtension(ext string) string {
665+ switch strings.ToLower(ext) {
666+ case ".go":
667+ return "go"
668+ case ".rs":
669+ return "rust"
670+ case ".py":
671+ return "python"
672+ case ".ts":
673+ return "typescript"
674+ case ".tsx":
675+ return "typescriptreact"
676+ case ".js", ".mjs", ".cjs":
677+ return "javascript"
678+ case ".jsx":
679+ return "javascriptreact"
680+ case ".c", ".h":
681+ return "c"
682+ case ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx":
683+ return "cpp"
684+ case ".yaml", ".yml":
685+ return "yaml"
686+ case ".json", ".jsonc":
687+ return "json"
688+ case ".sh", ".bash", ".zsh":
689+ return "shellscript"
690+ default:
691+ return ""
692+ }
693+}
A packages/agent/lsp/diagnostics.go +332 -0
@@ -0,0 +1,332 @@
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|could not) resolve (?:module|import|package) ["']?([^"' ]+)["']?`),
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, secondaryCount := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" {
155+ lines = append(lines, "- "+secondaryLine)
156+ emittedSecondary[diagnostic.Path] = true
157+ displayed += secondaryCount
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 := truncateText(compactWhitespace(bucket[0].Message), 220)
203+ return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra)
204+}
205+
206+func moduleSecondary(diagnostics []Diagnostic) map[string]bool {
207+ roots := make(map[string]bool)
208+ byFile := make(map[string][]Diagnostic)
209+ for _, diagnostic := range diagnostics {
210+ byFile[diagnostic.Path] = append(byFile[diagnostic.Path], diagnostic)
211+ if missingModuleName(diagnostic) != "" {
212+ roots[diagnostic.Path] = true
213+ }
214+ }
215+ secondary := make(map[string]bool)
216+ for path, fileDiagnostics := range byFile {
217+ if !roots[path] {
218+ continue
219+ }
220+ count := 0
221+ for _, diagnostic := range fileDiagnostics {
222+ if missingModuleName(diagnostic) == "" {
223+ count++
224+ }
225+ }
226+ if count < 2 {
227+ continue
228+ }
229+ for _, diagnostic := range fileDiagnostics {
230+ if missingModuleName(diagnostic) == "" {
231+ secondary[DiagnosticKey(diagnostic)] = true
232+ }
233+ }
234+ }
235+ return secondary
236+}
237+
238+func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) (string, int) {
239+ count := 0
240+ modules := make(map[string]bool)
241+ for _, diagnostic := range diagnostics {
242+ if diagnostic.Path == path && secondary[DiagnosticKey(diagnostic)] {
243+ count++
244+ }
245+ if diagnostic.Path == path && missingModuleName(diagnostic) != "" {
246+ modules[missingModuleName(diagnostic)] = true
247+ }
248+ }
249+ if count == 0 {
250+ return "", 0
251+ }
252+ shownPath := path
253+ if root != "" {
254+ if rel, err := filepathRel(root, path); err == nil {
255+ shownPath = rel
256+ }
257+ }
258+ moduleNames := make([]string, 0, len(modules))
259+ for name := range modules {
260+ moduleNames = append(moduleNames, name)
261+ }
262+ sort.Strings(moduleNames)
263+ 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, ", ")), count
264+}
265+
266+func missingModuleName(diagnostic Diagnostic) string {
267+ if strings.ToLower(diagnostic.Severity) != "error" {
268+ return ""
269+ }
270+ for _, pattern := range missingModulePatterns {
271+ if match := pattern.FindStringSubmatch(diagnostic.Message); len(match) > 1 {
272+ return strings.Trim(match[1], `"'`)
273+ }
274+ }
275+ return ""
276+}
277+
278+func uniquePaths(diagnostics []Diagnostic) int {
279+ paths := make(map[string]bool)
280+ for _, diagnostic := range diagnostics {
281+ paths[diagnostic.Path] = true
282+ }
283+ return len(paths)
284+}
285+
286+func plural(n int) string {
287+ if n == 1 {
288+ return ""
289+ }
290+ return "s"
291+}
292+func minInt(a, b int) int {
293+ if a < b {
294+ return a
295+ }
296+ return b
297+}
298+func contains(values []string, value string) bool {
299+ for _, item := range values {
300+ if item == value {
301+ return true
302+ }
303+ }
304+ return false
305+}
306+func joinInts(values map[int]bool, max int) string {
307+ ordered := make([]int, 0, len(values))
308+ for value := range values {
309+ ordered = append(ordered, value)
310+ }
311+ sort.Ints(ordered)
312+ if len(ordered) > max {
313+ ordered = ordered[:max]
314+ }
315+ parts := make([]string, len(ordered))
316+ for i, value := range ordered {
317+ parts[i] = fmt.Sprint(value)
318+ }
319+ return strings.Join(parts, ", ")
320+}
321+func filepathRel(root, path string) (string, error) {
322+ // Kept here rather than importing agent/tools: lsp is intentionally
323+ // independent of the tool package.
324+ rel, err := filepath.Rel(root, path)
325+ if err != nil {
326+ return "", err
327+ }
328+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
329+ return path, nil
330+ }
331+ return filepath.ToSlash(rel), nil
332+}
A packages/agent/lsp/edits.go +252 -0
@@ -0,0 +1,252 @@
1+package lsp
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+)
12+
13+const maxWorkspaceEditFileBytes = 8 * 1024 * 1024
14+
15+// ApplyWorkspaceEdit applies only text edits inside root. It rejects create,
16+// rename, delete, non-file URIs, symlink escapes, malformed ranges, and files
17+// larger than the bounded safety limit.
18+func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error {
19+ return applyWorkspaceEdit(root, edit, nil)
20+}
21+
22+func applyWorkspaceEdit(root string, edit WorkspaceEdit, checkWritePath func(string) error) error {
23+ root, err := filepath.Abs(root)
24+ if err != nil {
25+ return err
26+ }
27+ rootReal, err := filepath.EvalSymlinks(root)
28+ if err != nil {
29+ return fmt.Errorf("workspace root: %w", err)
30+ }
31+ all := make(map[string][]TextEdit, len(edit.Changes)+len(edit.DocumentChanges))
32+ for uri, changes := range edit.Changes {
33+ all[uri] = append(all[uri], changes...)
34+ }
35+ for _, raw := range edit.DocumentChanges {
36+ var change struct {
37+ TextDocument struct {
38+ URI string `json:"uri"`
39+ } `json:"textDocument"`
40+ Edits []TextEdit `json:"edits"`
41+ Kind string `json:"kind"`
42+ }
43+ if err := json.Unmarshal(raw, &change); err != nil {
44+ return fmt.Errorf("workspace edit document change: %w", err)
45+ }
46+ if change.Kind != "" || change.TextDocument.URI == "" {
47+ return fmt.Errorf("workspace edit contains an unsupported resource operation")
48+ }
49+ all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...)
50+ }
51+ targets, err := prepareEditTargets(rootReal, all, checkWritePath)
52+ if err != nil {
53+ return err
54+ }
55+ type plannedEdit struct {
56+ location editTarget
57+ output []byte
58+ mode os.FileMode
59+ }
60+ plans := make([]plannedEdit, 0, len(targets))
61+ for _, location := range targets {
62+ if len(location.changes) == 0 {
63+ continue
64+ }
65+ output, mode, err := prepareTextEdits(location.target, location.changes)
66+ if err != nil {
67+ return fmt.Errorf("validate edit to %s: %w", location.path, err)
68+ }
69+ plans = append(plans, plannedEdit{location: location, output: output, mode: mode})
70+ }
71+ for _, plan := range plans {
72+ if err := writeTextEdits(plan.location.target, plan.output, plan.mode); err != nil {
73+ return fmt.Errorf("apply edit to %s: %w", plan.location.path, err)
74+ }
75+ }
76+ return nil
77+}
78+
79+type editTarget struct {
80+ path string
81+ target string
82+ changes []TextEdit
83+}
84+
85+func prepareEditTargets(rootReal string, all map[string][]TextEdit, checkWritePath func(string) error) ([]editTarget, error) {
86+ targets := make([]editTarget, 0, len(all))
87+ for uri, changes := range all {
88+ path, err := uriToPath(uri)
89+ if err != nil {
90+ return nil, err
91+ }
92+ if err := safeWorkspacePath(rootReal, path); err != nil {
93+ return nil, err
94+ }
95+ target, err := filepath.EvalSymlinks(path)
96+ if err != nil {
97+ return nil, fmt.Errorf("workspace edit target %q: %w", path, err)
98+ }
99+ if err := safeWorkspacePath(rootReal, target); err != nil {
100+ return nil, err
101+ }
102+ if checkWritePath != nil {
103+ if err := checkWritePath(target); err != nil {
104+ return nil, err
105+ }
106+ }
107+ targets = append(targets, editTarget{path: path, target: target, changes: changes})
108+ }
109+ return targets, nil
110+}
111+
112+func safeWorkspacePath(root, path string) error {
113+ abs, err := filepath.Abs(path)
114+ if err != nil {
115+ return err
116+ }
117+ candidate := abs
118+ if resolved, err := filepath.EvalSymlinks(abs); err == nil {
119+ candidate = resolved
120+ } else {
121+ parent := filepath.Dir(abs)
122+ if resolvedParent, parentErr := filepath.EvalSymlinks(parent); parentErr == nil {
123+ candidate = filepath.Join(resolvedParent, filepath.Base(abs))
124+ }
125+ }
126+ rel, err := filepath.Rel(root, candidate)
127+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
128+ return fmt.Errorf("workspace edit path %q is outside workspace", path)
129+ }
130+ return nil
131+}
132+
133+type byteEdit struct {
134+ start int
135+ end int
136+ text string
137+}
138+
139+func prepareTextEdits(path string, edits []TextEdit) ([]byte, os.FileMode, error) {
140+ info, err := os.Stat(path)
141+ if err != nil {
142+ return nil, 0, err
143+ }
144+ if info.Size() > maxWorkspaceEditFileBytes {
145+ return nil, 0, fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes)
146+ }
147+ data, err := os.ReadFile(path)
148+ if err != nil {
149+ return nil, 0, err
150+ }
151+ converted := make([]byteEdit, 0, len(edits))
152+ for _, edit := range edits {
153+ start, err := positionOffset(data, edit.Range.Start)
154+ if err != nil {
155+ return nil, 0, err
156+ }
157+ end, err := positionOffset(data, edit.Range.End)
158+ if err != nil {
159+ return nil, 0, err
160+ }
161+ if end < start {
162+ return nil, 0, fmt.Errorf("edit range ends before it starts")
163+ }
164+ converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText})
165+ }
166+ sort.Slice(converted, func(i, j int) bool {
167+ if converted[i].start != converted[j].start {
168+ return converted[i].start > converted[j].start
169+ }
170+ return converted[i].end > converted[j].end
171+ })
172+ for i := 1; i < len(converted); i++ {
173+ if converted[i].end > converted[i-1].start {
174+ return nil, 0, fmt.Errorf("workspace text edits overlap")
175+ }
176+ }
177+ var builder bytes.Buffer
178+ cursor := 0
179+ for i := len(converted) - 1; i >= 0; i-- {
180+ edit := converted[i]
181+ _, _ = builder.Write(data[cursor:edit.start])
182+ _, _ = builder.WriteString(edit.text)
183+ cursor = edit.end
184+ }
185+ _, _ = builder.Write(data[cursor:])
186+ return builder.Bytes(), info.Mode().Perm(), nil
187+}
188+
189+func writeTextEdits(path string, out []byte, mode os.FileMode) error {
190+ tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*")
191+ if err != nil {
192+ return err
193+ }
194+ tmpName := tmp.Name()
195+ defer func() { _ = os.Remove(tmpName) }()
196+ if err := tmp.Chmod(mode); err != nil {
197+ _ = tmp.Close()
198+ return err
199+ }
200+ if _, err := tmp.Write(out); err != nil {
201+ _ = tmp.Close()
202+ return err
203+ }
204+ if err := tmp.Sync(); err != nil {
205+ _ = tmp.Close()
206+ return err
207+ }
208+ if err := tmp.Close(); err != nil {
209+ return err
210+ }
211+ return os.Rename(tmpName, path)
212+}
213+
214+func positionOffset(data []byte, position Position) (int, error) {
215+ if position.Line < 0 || position.Character < 0 {
216+ return 0, fmt.Errorf("invalid LSP position %d:%d", position.Line, position.Character)
217+ }
218+ lineStart := 0
219+ line := 0
220+ for line < position.Line {
221+ next := strings.IndexByte(string(data[lineStart:]), '\n')
222+ if next < 0 {
223+ return 0, fmt.Errorf("LSP line %d is outside file", position.Line)
224+ }
225+ lineStart += next + 1
226+ line++
227+ }
228+ lineEnd := len(data)
229+ if next := strings.IndexByte(string(data[lineStart:]), '\n'); next >= 0 {
230+ lineEnd = lineStart + next
231+ }
232+ lineBytes := data[lineStart:lineEnd]
233+ if len(lineBytes) > 0 && lineBytes[len(lineBytes)-1] == '\r' {
234+ lineBytes = lineBytes[:len(lineBytes)-1]
235+ lineEnd--
236+ }
237+ units := 0
238+ for offset, r := range string(lineBytes) {
239+ if units == position.Character {
240+ return lineStart + offset, nil
241+ }
242+ if r > 0xffff {
243+ units += 2
244+ } else {
245+ units++
246+ }
247+ }
248+ if units == position.Character {
249+ return lineEnd, nil
250+ }
251+ return 0, fmt.Errorf("LSP character %d is outside line %d", position.Character, position.Line)
252+}
A packages/agent/lsp/framing.go +93 -0
@@ -0,0 +1,93 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "strconv"
10+ "strings"
11+)
12+
13+const (
14+ maxMessageBytes = 32 * 1024 * 1024
15+ maxHeaderBytes = 8 * 1024
16+ maxHeaderLines = 64
17+)
18+
19+// WriteMessage writes one LSP Content-Length framed JSON-RPC message.
20+func WriteMessage(w io.Writer, payload []byte) error {
21+ if len(payload) > maxMessageBytes {
22+ return fmt.Errorf("LSP message is too large: %d bytes", len(payload))
23+ }
24+ header := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload)))
25+ if n, err := w.Write(header); err != nil {
26+ return err
27+ } else if n != len(header) {
28+ return io.ErrShortWrite
29+ }
30+ n, err := w.Write(payload)
31+ if err == nil && n != len(payload) {
32+ return io.ErrShortWrite
33+ }
34+ return err
35+}
36+
37+// ReadMessage reads one Content-Length framed LSP message. It accepts other
38+// headers (including Content-Type) and either CRLF or LF header endings.
39+func ReadMessage(r *bufio.Reader) ([]byte, error) {
40+ length := -1
41+ headerBytes := 0
42+ headerLines := 0
43+ for {
44+ line, err := r.ReadSlice('\n')
45+ if err != nil {
46+ if err == bufio.ErrBufferFull {
47+ return nil, errors.New("LSP headers are too large")
48+ }
49+ return nil, err
50+ }
51+ headerBytes += len(line)
52+ headerLines++
53+ if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines {
54+ return nil, errors.New("LSP headers are too large")
55+ }
56+ lineText := strings.TrimRight(string(line), "\r\n")
57+ if lineText == "" {
58+ break
59+ }
60+ key, value, ok := strings.Cut(lineText, ":")
61+ if !ok {
62+ return nil, fmt.Errorf("malformed LSP header %q", lineText)
63+ }
64+ if strings.EqualFold(strings.TrimSpace(key), "content-length") {
65+ n, err := strconv.Atoi(strings.TrimSpace(value))
66+ if err != nil || n < 0 {
67+ return nil, fmt.Errorf("invalid Content-Length %q", strings.TrimSpace(value))
68+ }
69+ length = n
70+ }
71+ }
72+ if length < 0 {
73+ return nil, errors.New("LSP message has no Content-Length")
74+ }
75+ if length > maxMessageBytes {
76+ return nil, fmt.Errorf("LSP message is too large: %d bytes", length)
77+ }
78+ payload := make([]byte, length)
79+ if _, err := io.ReadFull(r, payload); err != nil {
80+ return nil, err
81+ }
82+ return payload, nil
83+}
84+
85+// Frame is useful in tests and for callers which already have a complete
86+// message. It returns the exact wire representation.
87+func Frame(payload []byte) []byte {
88+ var b bytes.Buffer
89+ if err := WriteMessage(&b, payload); err != nil {
90+ panic(err)
91+ }
92+ return b.Bytes()
93+}
A packages/agent/lsp/lsp_test.go +499 -0
@@ -0,0 +1,499 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "runtime"
12+ "strconv"
13+ "strings"
14+ "testing"
15+ "time"
16+)
17+
18+func TestFakeLSPProcess(t *testing.T) {
19+ if os.Getenv("ZOT_FAKE_LSP") != "1" {
20+ return
21+ }
22+ reader := bufio.NewReader(os.Stdin)
23+ for {
24+ payload, err := ReadMessage(reader)
25+ if err != nil {
26+ return
27+ }
28+ var message rpcEnvelope
29+ if json.Unmarshal(payload, &message) != nil {
30+ continue
31+ }
32+ switch message.Method {
33+ case "initialize":
34+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"capabilities":{"definitionProvider":true}}}`)
35+ _ = WriteMessage(os.Stdout, response)
36+ case "test/echo":
37+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"echo":true}}`)
38+ _ = WriteMessage(os.Stdout, response)
39+ case "shutdown":
40+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":null}`)
41+ _ = WriteMessage(os.Stdout, response)
42+ case "textDocument/didOpen":
43+ var params struct {
44+ TextDocument struct {
45+ URI string `json:"uri"`
46+ } `json:"textDocument"`
47+ }
48+ _ = json.Unmarshal(message.Params, &params)
49+ 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"}}}}
50+ data, _ := json.Marshal(response)
51+ _ = WriteMessage(os.Stdout, data)
52+ }
53+ }
54+}
55+
56+func TestClientFakeStdioRPC(t *testing.T) {
57+ root := t.TempDir()
58+ script := os.Args[0]
59+ spec := ServerConfig{ID: "fake", Kind: "lsp", Command: script, Args: []string{"-test.run=TestFakeLSPProcess"}, Env: map[string]string{"ZOT_FAKE_LSP": "1"}}
60+ diagnostics := make(chan []Diagnostic, 1)
61+ client, err := NewClientWithContext(context.Background(), spec, root, ClientOptions{OnDiagnostics: func(value []Diagnostic) { diagnostics <- value }})
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ t.Cleanup(func() { _ = client.Close() })
66+ result, err := client.Request(context.Background(), "test/echo", map[string]any{})
67+ if err != nil || !bytes.Contains(result, []byte(`"echo":true`)) {
68+ t.Fatalf("echo result = %s, err = %v", result, err)
69+ }
70+ path := filepath.Join(root, "main.go")
71+ if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+ if err := client.DidOpen(path, "go", "package main\n"); err != nil {
75+ t.Fatal(err)
76+ }
77+ select {
78+ case values := <-diagnostics:
79+ if len(values) != 1 || values[0].Code != "FAKE" || values[0].Path != path {
80+ t.Fatalf("diagnostics = %#v", values)
81+ }
82+ case <-time.After(2 * time.Second):
83+ t.Fatal("timed out waiting for fake diagnostics")
84+ }
85+}
86+
87+func TestFileURIPathRoundTrip(t *testing.T) {
88+ path := filepath.Join(t.TempDir(), "space name", "main.go")
89+ uri, err := pathToURI(path)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if !strings.HasPrefix(uri, "file:///") {
94+ t.Fatalf("URI = %q, want an absolute file URI", uri)
95+ }
96+ got, err := uriToPath(uri)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ abs, err := filepath.Abs(path)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ if filepath.Clean(got) != filepath.Clean(abs) {
105+ t.Fatalf("round-trip path = %q, want %q", got, abs)
106+ }
107+ if runtime.GOOS != "windows" {
108+ posixPath, err := uriToPath("file:///x:tmp/main.go")
109+ if err != nil || posixPath != "/x:tmp/main.go" {
110+ t.Fatalf("POSIX drive-like URI = %q, err = %v", posixPath, err)
111+ }
112+ }
113+ if runtime.GOOS == "windows" {
114+ unc := `\\server\share\main.go`
115+ uncURI, err := pathToURI(unc)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if !strings.HasPrefix(uncURI, "file://server/") {
120+ t.Fatalf("UNC URI = %q", uncURI)
121+ }
122+ uncPath, err := uriToPath(uncURI)
123+ if err != nil || filepath.Clean(uncPath) != filepath.Clean(unc) {
124+ t.Fatalf("UNC round trip = %q, err = %v", uncPath, err)
125+ }
126+ }
127+}
128+
129+func TestFramingRejectsMalformedAndOversizedMessages(t *testing.T) {
130+ cases := []string{
131+ "\r\n{}",
132+ "Content-Length: -1\r\n\r\n",
133+ "Content-Length: nope\r\n\r\n",
134+ "Content-Length: 1\r\n" + strings.Repeat("X", maxHeaderBytes) + "\r\n",
135+ "Content-Length: " + strconv.Itoa(maxMessageBytes+1) + "\r\n\r\n",
136+ }
137+ for _, wire := range cases {
138+ if _, err := ReadMessage(bufio.NewReader(strings.NewReader(wire))); err == nil {
139+ t.Fatalf("accepted malformed frame %q", wire[:min(len(wire), 80)])
140+ }
141+ }
142+ var out bytes.Buffer
143+ if err := WriteMessage(&out, make([]byte, maxMessageBytes+1)); err == nil {
144+ t.Fatal("accepted oversized outbound frame")
145+ }
146+}
147+
148+func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
149+ payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
150+ wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
151+ got, err := ReadMessage(bufio.NewReader(bytes.NewReader(wire)))
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ if !bytes.Equal(got, payload) {
156+ t.Fatalf("payload = %q, want %q", got, payload)
157+ }
158+}
159+
160+func TestZotHomeEnvironmentPrecedence(t *testing.T) {
161+ configuredHome := filepath.Join(t.TempDir(), "zot")
162+ xdgHome := filepath.Join(t.TempDir(), "state")
163+ t.Setenv("ZOT_HOME", configuredHome)
164+ t.Setenv("XDG_STATE_HOME", xdgHome)
165+ if got := zotHome(); got != configuredHome {
166+ t.Fatalf("ZOT_HOME path = %q, want %q", got, configuredHome)
167+ }
168+ t.Setenv("ZOT_HOME", "")
169+ if got := zotHome(); got != filepath.Join(xdgHome, "zot") {
170+ t.Fatalf("XDG_STATE_HOME path = %q, want %q", got, filepath.Join(xdgHome, "zot"))
171+ }
172+}
173+
174+func TestLoadConfigMergesGlobalProjectAndBuiltins(t *testing.T) {
175+ root := t.TempDir()
176+ zotHome := t.TempDir()
177+ t.Setenv("ZOT_HOME", zotHome)
178+ if err := os.WriteFile(filepath.Join(zotHome, "lsp.json"), []byte(`{"servers":{"gopls":{"settings":{"global":true},"args":["--global"]}}}`), 0o644); err != nil {
179+ t.Fatal(err)
180+ }
181+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
182+ t.Fatal(err)
183+ }
184+ 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 {
185+ t.Fatal(err)
186+ }
187+ config, err := LoadConfig(root)
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ gopls := config.Servers["gopls"]
192+ if gopls.Command != "local-gopls" || len(gopls.Args) != 1 || gopls.Args[0] != "--local" {
193+ t.Fatalf("gopls override = %#v", gopls)
194+ }
195+ if len(gopls.FileTypes) == 0 || len(gopls.RootMarkers) == 0 {
196+ t.Fatalf("builtin selectors were not retained: %#v", gopls)
197+ }
198+ if gopls.Settings["global"] != true || gopls.Settings["local"] != true {
199+ t.Fatalf("settings did not merge: %#v", gopls.Settings)
200+ }
201+ if config.Servers["sarif-lint"].Kind != "cli" || config.Servers["sarif-lint"].Parser != "sarif" {
202+ t.Fatalf("custom linter = %#v", config.Servers["sarif-lint"])
203+ }
204+ selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
205+ var foundGopls, foundSarif bool
206+ for _, server := range selected {
207+ foundGopls = foundGopls || server.ID == "gopls"
208+ foundSarif = foundSarif || server.ID == "sarif-lint"
209+ }
210+ if !foundGopls || !foundSarif {
211+ t.Fatalf("selected servers = %#v", selected)
212+ }
213+}
214+
215+func TestLoadConfigAcceptsProviderArrays(t *testing.T) {
216+ root := t.TempDir()
217+ zotHome := t.TempDir()
218+ t.Setenv("ZOT_HOME", zotHome)
219+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
220+ t.Fatal(err)
221+ }
222+ configFile := `{
223+ "autoDetect": true,
224+ "providers": [
225+ "gopls",
226+ {"id":"gopls","args":["serve"]},
227+ {"id":"custom-lint","kind":"cli","command":"custom-lint","fileTypes":[".go"],"rootMarkers":["go.mod"],"cli":{"parser":"generic","mode":"files"}}
228+ ]
229+ }`
230+ if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(configFile), 0o644); err != nil {
231+ t.Fatal(err)
232+ }
233+ config, err := LoadConfig(root)
234+ if err != nil {
235+ t.Fatal(err)
236+ }
237+ if got := config.Servers["gopls"].Args; len(got) != 1 || got[0] != "serve" {
238+ t.Fatalf("gopls provider override = %#v", got)
239+ }
240+ lint := config.Servers["custom-lint"]
241+ if lint.Kind != "cli" || lint.Parser != "generic" || lint.Mode != "files" {
242+ t.Fatalf("custom provider = %#v", lint)
243+ }
244+}
245+
246+func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
247+ root := t.TempDir()
248+ t.Setenv("ZOT_HOME", t.TempDir())
249+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
250+ t.Fatal(err)
251+ }
252+ if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil {
253+ t.Fatal(err)
254+ }
255+ config, err := LoadConfig(root)
256+ if err != nil {
257+ t.Fatal(err)
258+ }
259+ selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
260+ if len(selected) != 1 || selected[0].ID != "gopls" {
261+ t.Fatalf("explicit providers with autoDetect=false = %#v", selected)
262+ }
263+}
264+
265+func TestResolveCommandPrefersWorkspaceTools(t *testing.T) {
266+ if runtime.GOOS == "windows" {
267+ t.Skip("executable bits are not represented on Windows")
268+ }
269+ root := t.TempDir()
270+ bin := filepath.Join(root, "node_modules", ".bin")
271+ if err := os.MkdirAll(bin, 0o755); err != nil {
272+ t.Fatal(err)
273+ }
274+ command := filepath.Join(bin, "fake-lsp")
275+ if err := os.WriteFile(command, []byte("#!/bin/sh\n"), 0o755); err != nil {
276+ t.Fatal(err)
277+ }
278+ got, err := ResolveCommand(root, "fake-lsp")
279+ if err != nil {
280+ t.Fatal(err)
281+ }
282+ if got != command {
283+ t.Fatalf("resolved %q, want %q", got, command)
284+ }
285+}
286+
287+func TestParsers(t *testing.T) {
288+ root := t.TempDir()
289+ eslint := `[ {"filePath":"src/a.js","messages":[{"ruleId":"no-x","severity":2,"message":"bad","line":2,"column":3}] } ]`
290+ got := ParseDiagnostics("eslint-json", "eslint", root, eslint)
291+ if len(got) != 1 || got[0].Severity != "error" || got[0].Range.Start.Line != 1 {
292+ t.Fatalf("eslint = %#v", got)
293+ }
294+ golangci := `{"Issues":[{"FromLinter":"govet","Text":"bad","Severity":"warning","Pos":{"Filename":"a.go","Line":3,"Column":4}}]}`
295+ if got = ParseDiagnostics("golangci-lint-json", "golangci", root, golangci); len(got) != 1 || got[0].Severity != "warning" {
296+ t.Fatalf("golangci = %#v", got)
297+ }
298+ ruff := `[{"code":"F401","message":"unused","filename":"a.py","location":{"row":1,"column":2}}]`
299+ if got = ParseDiagnostics("ruff-json", "ruff", root, ruff); len(got) != 1 || got[0].Code != "F401" {
300+ t.Fatalf("ruff = %#v", got)
301+ }
302+ sarif := `{"runs":[{"results":[{"ruleId":"R1","level":"error","message":{"text":"bad"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"a.c"},"region":{"startLine":4,"startColumn":2}}}]}]}]}`
303+ if got = ParseDiagnostics("sarif", "scan", root, sarif); len(got) != 1 || got[0].Range.Start.Line != 3 {
304+ t.Fatalf("sarif = %#v", got)
305+ }
306+ if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" {
307+ t.Fatalf("generic = %#v", got)
308+ }
309+ if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: cannot find name"); len(got) != 1 || got[0].Message != "cannot find name" {
310+ t.Fatalf("generic message = %#v", got)
311+ }
312+}
313+
314+func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) {
315+ path := filepath.Join(t.TempDir(), "main.go")
316+ base := Diagnostic{Path: path, Severity: "error", Code: "E1", Message: "same", Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 1, Character: 3}}}
317+ endChanged := base
318+ endChanged.Range.End.Character = 20
319+ otherCode := base
320+ otherCode.Code = "E2"
321+ otherStart := base
322+ otherStart.Range.Start.Line++
323+ if got := DeduplicateDiagnostics([]Diagnostic{base, endChanged, otherCode, otherStart}); len(got) != 3 {
324+ t.Fatalf("deduplicated diagnostics = %#v, want three reports", got)
325+ }
326+}
327+
328+func TestManagerClearsPublishedDiagnostics(t *testing.T) {
329+ manager := NewManager()
330+ root := t.TempDir()
331+ path := filepath.Join(root, "main.go")
332+ if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
333+ t.Fatal(err)
334+ }
335+ ws, err := manager.workspace(root)
336+ if err != nil {
337+ t.Fatal(err)
338+ }
339+ manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Message: "problem"}})
340+ if got := manager.snapshot(ws, path); len(got) != 1 {
341+ t.Fatalf("stored diagnostics = %#v", got)
342+ }
343+ manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Clear: true}})
344+ if got := manager.snapshot(ws, path); len(got) != 0 {
345+ t.Fatalf("cleared diagnostics = %#v", got)
346+ }
347+}
348+
349+func TestDiagnosticsDedupAndGrouping(t *testing.T) {
350+ root := t.TempDir()
351+ makeDiag := func(path, message string, line int, server string) Diagnostic {
352+ return Diagnostic{Path: filepath.Join(root, path), Severity: "error", Code: "E", Message: message, Server: server, Range: Range{Start: Position{Line: line}}}
353+ }
354+ 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")}
355+ if got := DeduplicateDiagnostics(input); len(got) != len(input)-1 {
356+ t.Fatalf("dedup count = %d", len(got))
357+ }
358+ text := SummarizeDiagnostics(input, root, 50)
359+ if !strings.Contains(text, "repeated") || !strings.Contains(text, "SECONDARY") {
360+ t.Fatalf("summary lacks grouping:\n%s", text)
361+ }
362+}
363+
364+func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) {
365+ root := t.TempDir()
366+ path := filepath.Join(root, "a.txt")
367+ if err := os.WriteFile(path, []byte("a😀b\n"), 0o644); err != nil {
368+ t.Fatal(err)
369+ }
370+ uri := URIForPath(path)
371+ ed := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: Range{Start: Position{Line: 0, Character: 1}, End: Position{Line: 0, Character: 3}}, NewText: "X"}}}}
372+ if err := ApplyWorkspaceEdit(root, ed); err != nil {
373+ t.Fatal(err)
374+ }
375+ data, _ := os.ReadFile(path)
376+ if string(data) != "aXb\n" {
377+ t.Fatalf("edited data = %q", data)
378+ }
379+ outside := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(filepath.Join(filepath.Dir(root), "outside.txt")): {{Range: Range{}, NewText: "x"}}}}
380+ if err := ApplyWorkspaceEdit(root, outside); err == nil {
381+ t.Fatal("outside edit was accepted")
382+ }
383+}
384+
385+func TestWorkspaceEditPreflightsAllTargets(t *testing.T) {
386+ root := t.TempDir()
387+ inside := filepath.Join(root, "inside.txt")
388+ outside := filepath.Join(t.TempDir(), "outside.txt")
389+ if err := os.WriteFile(inside, []byte("old\n"), 0o644); err != nil {
390+ t.Fatal(err)
391+ }
392+ if err := os.WriteFile(outside, []byte("old\n"), 0o644); err != nil {
393+ t.Fatal(err)
394+ }
395+ rootReal, err := filepath.EvalSymlinks(root)
396+ if err != nil {
397+ t.Fatal(err)
398+ }
399+ _, err = prepareEditTargets(rootReal, map[string][]TextEdit{
400+ URIForPath(inside): {{Range: Range{}, NewText: "inside"}},
401+ URIForPath(outside): {{Range: Range{}, NewText: "outside"}},
402+ }, nil)
403+ if err == nil {
404+ t.Fatal("workspace edit accepted an unsafe target after a safe target")
405+ }
406+}
407+
408+func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) {
409+ if runtime.GOOS == "windows" {
410+ t.Skip("symlink permissions vary on Windows")
411+ }
412+ root := t.TempDir()
413+ target := filepath.Join(root, "target.txt")
414+ link := filepath.Join(root, "link.txt")
415+ if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
416+ t.Fatal(err)
417+ }
418+ if err := os.Symlink(target, link); err != nil {
419+ t.Fatal(err)
420+ }
421+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(link): {{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 3}}, NewText: "new"}}}}
422+ if err := ApplyWorkspaceEdit(root, edit); err != nil {
423+ t.Fatal(err)
424+ }
425+ data, err := os.ReadFile(target)
426+ if err != nil {
427+ t.Fatal(err)
428+ }
429+ if string(data) != "new\n" {
430+ t.Fatalf("target data = %q", data)
431+ }
432+ info, err := os.Lstat(link)
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ if info.Mode()&os.ModeSymlink == 0 {
437+ t.Fatal("workspace edit replaced the symlink")
438+ }
439+}
440+
441+func TestLimitedBufferCapsReaderCopy(t *testing.T) {
442+ var buffer limitedBuffer
443+ buffer.limit = 4
444+ copied, err := io.Copy(&buffer, strings.NewReader("123456"))
445+ if err != nil {
446+ t.Fatal(err)
447+ }
448+ if copied != 6 || buffer.String() != "1234" {
449+ t.Fatalf("copied %d bytes into %q", copied, buffer.String())
450+ }
451+}
452+
453+func TestDiagnosticJSONCodeNumber(t *testing.T) {
454+ var diagnostic Diagnostic
455+ 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 {
456+ t.Fatal(err)
457+ }
458+ if diagnostic.Code != "123" || diagnostic.Severity != "warning" {
459+ t.Fatalf("diagnostic = %#v", diagnostic)
460+ }
461+}
462+
463+func TestManagerClosePreventsNewWorkspaces(t *testing.T) {
464+ manager := NewManager()
465+ root := t.TempDir()
466+ if err := manager.Close(); err != nil {
467+ t.Fatal(err)
468+ }
469+ if err := manager.Close(); err != nil {
470+ t.Fatal(err)
471+ }
472+ if _, err := manager.Config(root); err == nil {
473+ t.Fatal("closed manager accepted a new workspace")
474+ }
475+ if err := manager.Reload(root); err == nil {
476+ t.Fatal("closed manager accepted a reload")
477+ }
478+}
479+
480+func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) {
481+ manager := NewManager()
482+ root := t.TempDir()
483+ path := filepath.Join(root, "main.go")
484+ first := Diagnostic{Path: path, Severity: "error", Message: "type mismatch", Range: Range{Start: Position{Line: 1, Character: 2}}}
485+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{first}); len(got) != 1 {
486+ t.Fatalf("first reduction returned %d diagnostics, want 1", len(got))
487+ }
488+ shifted := first
489+ shifted.Range.Start.Line = 20
490+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 0 {
491+ t.Fatalf("shifted diagnostic was replayed: %#v", got)
492+ }
493+ if got := manager.ReduceDiagnostics(root, path, nil); len(got) != 0 {
494+ t.Fatalf("clear returned %#v", got)
495+ }
496+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 1 {
497+ t.Fatalf("diagnostic did not reappear after clear: %#v", got)
498+ }
499+}
A packages/agent/lsp/manager.go +859 -0
@@ -0,0 +1,859 @@
1+package lsp
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "time"
17+)
18+
19+// ManagerOptions controls process and command timeouts.
20+type ManagerOptions struct {
21+ RequestTimeout time.Duration
22+ DiagnosticDelay time.Duration
23+ LinterTimeout time.Duration
24+ Settings map[string]any
25+ OnLog func(server, message string)
26+ CheckWritePath func(path string) error
27+}
28+
29+// Response is one raw request result tagged with the server which produced
30+// it. Errors are retained so a partially installed workspace remains useful.
31+type Response struct {
32+ Server string
33+ Result json.RawMessage
34+ Error error
35+}
36+
37+type workspace struct {
38+ cwd string
39+ rootReal string
40+ config Config
41+ clients map[string]*Client
42+ starting map[string]chan struct{}
43+ statuses map[string]string
44+ errors map[string]string
45+ diagnostics map[string]map[string][]Diagnostic
46+ cliDiagnostics map[string][]Diagnostic
47+ opened map[string]map[string]string // server -> path -> content hash
48+ surfaced map[string]map[string]bool // path -> location-independent diagnostic identities
49+ logs []string
50+}
51+
52+// Manager owns per-workspace LSP processes. A process is cached by cwd and
53+// server ID until Close or Reload, so a series of model calls does not spawn a
54+// new language server for every request.
55+type Manager struct {
56+ mu sync.Mutex
57+ workspaces map[string]*workspace
58+ options ManagerOptions
59+ closed bool
60+}
61+
62+var errManagerClosed = errors.New("LSP manager is closed")
63+
64+func NewManager() *Manager {
65+ return NewManagerWithOptions(ManagerOptions{})
66+}
67+
68+func NewManagerWithOptions(options ManagerOptions) *Manager {
69+ if options.RequestTimeout <= 0 {
70+ options.RequestTimeout = 12 * time.Second
71+ }
72+ if options.DiagnosticDelay <= 0 {
73+ options.DiagnosticDelay = 300 * time.Millisecond
74+ }
75+ if options.LinterTimeout <= 0 {
76+ options.LinterTimeout = 30 * time.Second
77+ }
78+ return &Manager{workspaces: make(map[string]*workspace), options: options}
79+}
80+
81+func (m *Manager) workspace(cwd string) (*workspace, error) {
82+ cwd, err := absDir(cwd)
83+ if err != nil {
84+ return nil, err
85+ }
86+ m.mu.Lock()
87+ if m.closed {
88+ m.mu.Unlock()
89+ return nil, errManagerClosed
90+ }
91+ if value := m.workspaces[cwd]; value != nil {
92+ m.mu.Unlock()
93+ return value, nil
94+ }
95+ m.mu.Unlock()
96+ config, err := LoadConfig(cwd)
97+ if err != nil {
98+ return nil, err
99+ }
100+ rootReal := cwd
101+ if resolved, resolveErr := filepath.EvalSymlinks(cwd); resolveErr == nil {
102+ rootReal = resolved
103+ }
104+ value := &workspace{cwd: cwd, rootReal: rootReal, 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)}
105+ m.mu.Lock()
106+ defer m.mu.Unlock()
107+ if m.closed {
108+ return nil, errManagerClosed
109+ }
110+ if existing := m.workspaces[cwd]; existing != nil {
111+ value = existing
112+ } else {
113+ m.workspaces[cwd] = value
114+ }
115+ return value, nil
116+}
117+
118+// Config returns the effective merged configuration for cwd.
119+func (m *Manager) Config(cwd string) (Config, error) {
120+ ws, err := m.workspace(cwd)
121+ if err != nil {
122+ return Config{}, err
123+ }
124+ return ws.config, nil
125+}
126+
127+func (m *Manager) applicable(ws *workspace, path string) []ServerConfig {
128+ return ws.config.ApplicableServers(ws.cwd, path)
129+}
130+
131+func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerConfig) (*Client, error) {
132+ if ctx == nil {
133+ ctx = context.Background()
134+ }
135+ for {
136+ m.mu.Lock()
137+ if m.closed || m.workspaces[ws.cwd] != ws {
138+ m.mu.Unlock()
139+ return nil, errManagerClosed
140+ }
141+ if client := ws.clients[spec.ID]; client != nil {
142+ m.mu.Unlock()
143+ return client, nil
144+ }
145+ if waiting := ws.starting[spec.ID]; waiting != nil {
146+ m.mu.Unlock()
147+ select {
148+ case <-waiting:
149+ continue
150+ case <-ctx.Done():
151+ return nil, ctx.Err()
152+ }
153+ }
154+ starting := make(chan struct{})
155+ ws.starting[spec.ID] = starting
156+ ws.statuses[spec.ID] = "starting"
157+ m.mu.Unlock()
158+ root := FindRoot(ws.cwd, spec.RootMarkers)
159+ opts := ClientOptions{
160+ Settings: wsSettings(m.options.Settings, spec.Settings),
161+ InitializationOptions: spec.InitializationOptions,
162+ OnDiagnostics: func(diagnostics []Diagnostic) { m.storeDiagnostics(ws, spec.ID, diagnostics) },
163+ OnLog: func(message string) { m.log(ws, spec.ID, message) },
164+ // A server's LSP root may be an ancestor of the session cwd.
165+ // Keep server-initiated edits inside the session workspace rather
166+ // than trusting that broader project root.
167+ ApplyEdit: func(edit WorkspaceEdit) error {
168+ return applyWorkspaceEdit(ws.cwd, edit, m.options.CheckWritePath)
169+ },
170+ }
171+ startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
172+ client, err := NewClientWithContext(startCtx, spec, root, opts)
173+ cancel()
174+ m.mu.Lock()
175+ delete(ws.starting, spec.ID)
176+ close(starting)
177+ if err != nil {
178+ if m.closed || m.workspaces[ws.cwd] != ws {
179+ m.mu.Unlock()
180+ return nil, errManagerClosed
181+ }
182+ ws.statuses[spec.ID] = "missing"
183+ ws.errors[spec.ID] = err.Error()
184+ m.mu.Unlock()
185+ return nil, err
186+ }
187+ if m.closed || m.workspaces[ws.cwd] != ws {
188+ m.mu.Unlock()
189+ _ = client.Close()
190+ return nil, errManagerClosed
191+ }
192+ if existing := ws.clients[spec.ID]; existing != nil {
193+ m.mu.Unlock()
194+ _ = client.Close()
195+ return existing, nil
196+ }
197+ ws.clients[spec.ID] = client
198+ ws.statuses[spec.ID] = "ready"
199+ delete(ws.errors, spec.ID)
200+ m.mu.Unlock()
201+ return client, nil
202+ }
203+}
204+
205+func wsSettings(global, local map[string]any) map[string]any {
206+ if len(global) == 0 && len(local) == 0 {
207+ return nil
208+ }
209+ out := make(map[string]any, len(global)+len(local))
210+ for key, value := range global {
211+ out[key] = value
212+ }
213+ for key, value := range local {
214+ out[key] = value
215+ }
216+ return out
217+}
218+
219+func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
220+ byPath := make(map[string][]Diagnostic)
221+ cleared := make(map[string]bool)
222+ for _, diagnostic := range diagnostics {
223+ if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
224+ continue
225+ }
226+ if diagnostic.Clear {
227+ cleared[diagnostic.Path] = true
228+ continue
229+ }
230+ byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
231+ }
232+ m.mu.Lock()
233+ if ws.diagnostics[server] == nil {
234+ ws.diagnostics[server] = make(map[string][]Diagnostic)
235+ }
236+ for path := range cleared {
237+ delete(ws.diagnostics[server], path)
238+ }
239+ for path, values := range byPath {
240+ ws.diagnostics[server][path] = values
241+ }
242+ m.mu.Unlock()
243+}
244+
245+func (m *Manager) log(ws *workspace, server, message string) {
246+ message = strings.TrimSpace(message)
247+ if message == "" {
248+ return
249+ }
250+ if len(message) > 4096 {
251+ message = message[:4095] + "…"
252+ }
253+ m.mu.Lock()
254+ ws.logs = append(ws.logs, server+": "+message)
255+ if len(ws.logs) > 100 {
256+ ws.logs = ws.logs[len(ws.logs)-100:]
257+ }
258+ m.mu.Unlock()
259+ if m.options.OnLog != nil {
260+ m.options.OnLog(server, message)
261+ }
262+}
263+
264+// Open opens a document in every applicable LSP server.
265+func (m *Manager) Open(ctx context.Context, cwd, path string) error {
266+ ws, err := m.workspace(cwd)
267+ if err != nil {
268+ return err
269+ }
270+ path, err = absoluteFile(ws.cwd, path)
271+ if err != nil {
272+ return err
273+ }
274+ text, err := os.ReadFile(path)
275+ if err != nil {
276+ return err
277+ }
278+ for _, spec := range m.applicable(ws, path) {
279+ if spec.Kind != "lsp" {
280+ continue
281+ }
282+ client, clientErr := m.ensureClient(ctx, ws, spec)
283+ if clientErr != nil {
284+ if err == nil {
285+ err = clientErr
286+ }
287+ continue
288+ }
289+ hash := contentHash(text)
290+ m.mu.Lock()
291+ if ws.opened[spec.ID] == nil {
292+ ws.opened[spec.ID] = make(map[string]string)
293+ }
294+ old, open := ws.opened[spec.ID][path]
295+ m.mu.Unlock()
296+ if !open {
297+ languageID := spec.LanguageID
298+ if languageID == "" {
299+ languageID = languageForExtension(filepath.Ext(path))
300+ }
301+ if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil {
302+ m.mu.Lock()
303+ ws.opened[spec.ID][path] = hash
304+ m.mu.Unlock()
305+ }
306+ } else if old != hash {
307+ if clientErr = client.DidChange(path, string(text)); clientErr == nil {
308+ m.mu.Lock()
309+ ws.opened[spec.ID][path] = hash
310+ m.mu.Unlock()
311+ }
312+ }
313+ if clientErr != nil && err == nil {
314+ err = clientErr
315+ }
316+ }
317+ return err
318+}
319+
320+func (m *Manager) Save(ctx context.Context, cwd, path string) error {
321+ openErr := m.Open(ctx, cwd, path)
322+ ws, err := m.workspace(cwd)
323+ if err != nil {
324+ return err
325+ }
326+ path, err = absoluteFile(ws.cwd, path)
327+ if err != nil {
328+ return err
329+ }
330+ text, err := os.ReadFile(path)
331+ if err != nil {
332+ return err
333+ }
334+ for _, spec := range m.applicable(ws, path) {
335+ if spec.Kind != "lsp" {
336+ continue
337+ }
338+ m.mu.Lock()
339+ client := ws.clients[spec.ID]
340+ m.mu.Unlock()
341+ if client != nil {
342+ _ = client.DidSave(path, string(text))
343+ }
344+ }
345+ return openErr
346+}
347+
348+// Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits
349+// only a bounded debounce interval for asynchronous publishDiagnostics.
350+func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) {
351+ return m.DiagnosticsWithOptions(ctx, cwd, path, true)
352+}
353+
354+func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) {
355+ ws, err := m.workspace(cwd)
356+ if err != nil {
357+ return nil, err
358+ }
359+ var firstErr error
360+ if path != "" {
361+ if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) {
362+ // Keep cached diagnostics, but make an unavailable or failed
363+ // server visible to the model-facing lsp action.
364+ firstErr = saveErr
365+ }
366+ }
367+ for _, spec := range m.applicable(ws, path) {
368+ if spec.Kind != "lsp" {
369+ continue
370+ }
371+ m.mu.Lock()
372+ client := ws.clients[spec.ID]
373+ m.mu.Unlock()
374+ if client != nil {
375+ if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) {
376+ firstErr = waitErr
377+ }
378+ }
379+ }
380+ if runCLI {
381+ if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil {
382+ firstErr = err
383+ }
384+ }
385+ return m.snapshot(ws, path), firstErr
386+}
387+
388+// ReduceDiagnostics returns only diagnostics that have not already been
389+// surfaced for this workspace path. The ledger deliberately ignores line
390+// and column changes, matching the behavior users want after a harmless edit:
391+// unchanged problems do not consume another tool-result context window.
392+// Passing an empty snapshot clears the path so a later recurrence is fresh.
393+func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic {
394+ ws, err := m.workspace(cwd)
395+ if err != nil {
396+ return diagnostics
397+ }
398+ path, err = absoluteFile(ws.cwd, path)
399+ if err != nil {
400+ return diagnostics
401+ }
402+ m.mu.Lock()
403+ defer m.mu.Unlock()
404+ if len(diagnostics) == 0 {
405+ delete(ws.surfaced, path)
406+ return nil
407+ }
408+ previous := ws.surfaced[path]
409+ current := make(map[string]bool, len(diagnostics))
410+ fresh := make([]Diagnostic, 0, len(diagnostics))
411+ for _, diagnostic := range diagnostics {
412+ identity := DiagnosticIdentity(diagnostic)
413+ current[identity] = true
414+ if !previous[identity] {
415+ fresh = append(fresh, diagnostic)
416+ }
417+ }
418+ ws.surfaced[path] = current
419+ return fresh
420+}
421+
422+func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic {
423+ m.mu.Lock()
424+ defer m.mu.Unlock()
425+ var all []Diagnostic
426+ for _, files := range ws.diagnostics {
427+ for file, diagnostics := range files {
428+ if path == "" || samePath(file, path) {
429+ all = append(all, diagnostics...)
430+ }
431+ }
432+ }
433+ for _, diagnostics := range ws.cliDiagnostics {
434+ for _, diagnostic := range diagnostics {
435+ if path == "" || samePath(diagnostic.Path, path) {
436+ all = append(all, diagnostic)
437+ }
438+ }
439+ }
440+ return DeduplicateDiagnostics(all)
441+}
442+
443+// Request sends one request to a named LSP server.
444+func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) {
445+ ws, err := m.workspace(cwd)
446+ if err != nil {
447+ return nil, err
448+ }
449+ spec, ok := ws.config.Servers[server]
450+ if !ok || spec.Disabled {
451+ return nil, fmt.Errorf("unknown or disabled LSP server %q", server)
452+ }
453+ if spec.Kind != "lsp" || spec.IsLinter {
454+ return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server)
455+ }
456+ client, err := m.ensureClient(ctx, ws, spec)
457+ if err != nil {
458+ return nil, err
459+ }
460+ return m.requestWithTimeout(ctx, client, method, params)
461+}
462+
463+func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) {
464+ if ctx == nil {
465+ ctx = context.Background()
466+ }
467+ requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
468+ defer cancel()
469+ return client.Request(requestCtx, method, params)
470+}
471+
472+// RequestAll sends a request to every applicable LSP server. If path is set,
473+// the document is opened first so servers have current text and URI context.
474+func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response {
475+ ws, err := m.workspace(cwd)
476+ if err != nil {
477+ return []Response{{Error: err}}
478+ }
479+ if path != "" {
480+ _ = m.Open(ctx, ws.cwd, path)
481+ }
482+ var responses []Response
483+ for _, spec := range m.applicable(ws, path) {
484+ if spec.Kind != "lsp" || spec.IsLinter {
485+ continue
486+ }
487+ client, clientErr := m.ensureClient(ctx, ws, spec)
488+ if clientErr != nil {
489+ responses = append(responses, Response{Server: spec.ID, Error: clientErr})
490+ continue
491+ }
492+ result, requestErr := m.requestWithTimeout(ctx, client, method, params)
493+ responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr})
494+ }
495+ return responses
496+}
497+
498+// ApplyEdit applies a workspace edit under the selected workspace root.
499+func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error {
500+ return m.ApplyEdits(cwd, []WorkspaceEdit{edit})
501+}
502+
503+// ApplyEdits preflights and applies multiple workspace edits as one operation.
504+// This prevents a later unsafe edit from leaving earlier edits applied.
505+func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error {
506+ ws, err := m.workspace(cwd)
507+ if err != nil {
508+ return err
509+ }
510+ merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)}
511+ for _, edit := range edits {
512+ for uri, changes := range edit.Changes {
513+ merged.Changes[uri] = append(merged.Changes[uri], changes...)
514+ }
515+ merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...)
516+ }
517+ return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath)
518+}
519+
520+// Status describes configured and cached servers without starting missing
521+// processes. It is safe to call for a workspace with no installed tools.
522+type ServerStatus struct {
523+ ID string `json:"id"`
524+ Kind string `json:"kind"`
525+ Command string `json:"command"`
526+ Root string `json:"root,omitempty"`
527+ State string `json:"state"`
528+ Error string `json:"error,omitempty"`
529+ Diagnostics int `json:"diagnostics,omitempty"`
530+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
531+}
532+
533+func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
534+ ws, err := m.workspace(cwd)
535+ if err != nil {
536+ return nil, err
537+ }
538+ specs := m.applicable(ws, path)
539+ roots := make(map[string]string, len(specs))
540+ resolveErrors := make(map[string]error, len(specs))
541+ for _, spec := range specs {
542+ roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers))
543+ if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
544+ resolveErrors[spec.ID] = resolveErr
545+ }
546+ }
547+ m.mu.Lock()
548+ defer m.mu.Unlock()
549+ out := make([]ServerStatus, 0, len(specs))
550+ for _, spec := range specs {
551+ status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]}
552+ if status.State == "" {
553+ status.State = "stopped"
554+ }
555+ if status.State == "stopped" {
556+ if resolveErr := resolveErrors[spec.ID]; resolveErr != nil {
557+ status.State = "missing"
558+ status.Error = resolveErr.Error()
559+ }
560+ }
561+ if client := ws.clients[spec.ID]; client != nil {
562+ status.Capabilities = client.Capabilities()
563+ }
564+ for _, values := range ws.diagnostics[spec.ID] {
565+ status.Diagnostics += len(values)
566+ }
567+ status.Diagnostics += len(ws.cliDiagnostics[spec.ID])
568+ if configuredErr := ws.errors[spec.ID]; configuredErr != "" {
569+ status.Error = configuredErr
570+ }
571+ out = append(out, status)
572+ }
573+ return out, nil
574+}
575+
576+func (m *Manager) Capabilities(cwd, path, server string) (map[string]json.RawMessage, error) {
577+ ws, err := m.workspace(cwd)
578+ if err != nil {
579+ return nil, err
580+ }
581+ ensure := func(spec ServerConfig) (*Client, error) {
582+ if spec.Kind != "lsp" || spec.IsLinter {
583+ return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID)
584+ }
585+ m.mu.Lock()
586+ client := ws.clients[spec.ID]
587+ m.mu.Unlock()
588+ if client != nil {
589+ return client, nil
590+ }
591+ return m.ensureClient(context.Background(), ws, spec)
592+ }
593+ if server != "" {
594+ spec, ok := ws.config.Servers[server]
595+ if !ok || spec.Disabled {
596+ return nil, fmt.Errorf("unknown or disabled server %q", server)
597+ }
598+ client, err := ensure(spec)
599+ if err != nil {
600+ return nil, err
601+ }
602+ return map[string]json.RawMessage{server: client.Capabilities()}, nil
603+ }
604+ statuses, err := m.Status(cwd, path)
605+ if err != nil {
606+ return nil, err
607+ }
608+ out := make(map[string]json.RawMessage)
609+ var firstErr error
610+ for _, status := range statuses {
611+ spec := ws.config.Servers[status.ID]
612+ if spec.Kind != "lsp" || spec.IsLinter {
613+ continue
614+ }
615+ client, clientErr := ensure(spec)
616+ if clientErr != nil {
617+ if firstErr == nil {
618+ firstErr = clientErr
619+ }
620+ continue
621+ }
622+ if capabilities := client.Capabilities(); len(capabilities) > 0 {
623+ out[status.ID] = capabilities
624+ }
625+ }
626+ return out, firstErr
627+}
628+
629+// Reload terminates cached processes for cwd and reloads all lsp.json layers.
630+func (m *Manager) Reload(cwd string) error {
631+ cwd, err := absDir(cwd)
632+ if err != nil {
633+ return err
634+ }
635+ m.mu.Lock()
636+ if m.closed {
637+ m.mu.Unlock()
638+ return errManagerClosed
639+ }
640+ ws := m.workspaces[cwd]
641+ delete(m.workspaces, cwd)
642+ m.mu.Unlock()
643+ if ws != nil {
644+ for _, client := range m.clientsSnapshot(ws) {
645+ _ = client.Close()
646+ }
647+ }
648+ return nil
649+}
650+
651+func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
652+ m.mu.Lock()
653+ defer m.mu.Unlock()
654+ clients := make([]*Client, 0, len(ws.clients))
655+ for _, client := range ws.clients {
656+ clients = append(clients, client)
657+ }
658+ return clients
659+}
660+
661+func (m *Manager) Close() error {
662+ m.mu.Lock()
663+ if m.closed {
664+ m.mu.Unlock()
665+ return nil
666+ }
667+ m.closed = true
668+ workspaces := make([]*workspace, 0, len(m.workspaces))
669+ for _, ws := range m.workspaces {
670+ workspaces = append(workspaces, ws)
671+ }
672+ m.workspaces = make(map[string]*workspace)
673+ m.mu.Unlock()
674+ var first error
675+ for _, ws := range workspaces {
676+ for _, client := range m.clientsSnapshot(ws) {
677+ if err := client.Close(); err != nil && first == nil {
678+ first = err
679+ }
680+ }
681+ }
682+ return first
683+}
684+
685+// RunLinters executes configured CLI providers without a shell. A non-zero
686+// linter exit is normal when it emitted parseable findings.
687+func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
688+ if ctx == nil {
689+ ctx = context.Background()
690+ }
691+ ws, err := m.workspace(cwd)
692+ if err != nil {
693+ return err
694+ }
695+ var files []string
696+ if path != "" {
697+ if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil {
698+ files = []string{abs}
699+ }
700+ }
701+ var firstErr error
702+ for _, spec := range m.applicable(ws, path) {
703+ if spec.Kind != "cli" || spec.Disabled {
704+ continue
705+ }
706+ command, resolveErr := ResolveCommand(ws.cwd, spec.Command)
707+ if resolveErr != nil {
708+ m.mu.Lock()
709+ ws.statuses[spec.ID] = "missing"
710+ ws.errors[spec.ID] = resolveErr.Error()
711+ m.mu.Unlock()
712+ if firstErr == nil {
713+ firstErr = resolveErr
714+ }
715+ continue
716+ }
717+ args := make([]string, 0, len(spec.Args)+len(files))
718+ containsFiles := false
719+ relative := relativeFiles(ws.cwd, files)
720+ for _, arg := range spec.Args {
721+ if arg == "{files}" {
722+ containsFiles = true
723+ args = append(args, relative...)
724+ continue
725+ }
726+ if strings.Contains(arg, "{files}") {
727+ containsFiles = true
728+ if len(relative) == 0 {
729+ args = append(args, strings.ReplaceAll(arg, "{files}", ""))
730+ } else {
731+ for _, file := range relative {
732+ args = append(args, strings.ReplaceAll(arg, "{files}", file))
733+ }
734+ }
735+ continue
736+ }
737+ args = append(args, arg)
738+ }
739+ if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) {
740+ if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") {
741+ args = append(args, ".")
742+ }
743+ } else if !containsFiles {
744+ args = append(args, relative...)
745+ }
746+ commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
747+ cmd := exec.CommandContext(commandCtx, command, args...)
748+ cmd.Dir = ws.cwd
749+ cmd.Env = mergedEnvironment(spec.Env)
750+ var stdout, stderr limitedBuffer
751+ stdout.limit = 8 * 1024 * 1024
752+ stderr.limit = 8 * 1024 * 1024
753+ cmd.Stdout = &stdout
754+ cmd.Stderr = &stderr
755+ m.mu.Lock()
756+ ws.statuses[spec.ID] = "running"
757+ m.mu.Unlock()
758+ runErr := cmd.Run()
759+ cancel()
760+ text := strings.TrimSpace(stdout.String())
761+ if text == "" {
762+ text = strings.TrimSpace(stderr.String())
763+ }
764+ diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
765+ filtered := diagnostics[:0]
766+ for _, diagnostic := range diagnostics {
767+ if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
768+ filtered = append(filtered, diagnostic)
769+ }
770+ }
771+ diagnostics = filtered
772+ m.mu.Lock()
773+ ws.cliDiagnostics[spec.ID] = diagnostics
774+ if runErr != nil && len(diagnostics) == 0 {
775+ ws.errors[spec.ID] = runErr.Error()
776+ ws.statuses[spec.ID] = "error"
777+ } else {
778+ delete(ws.errors, spec.ID)
779+ ws.statuses[spec.ID] = "ready"
780+ }
781+ m.mu.Unlock()
782+ if runErr != nil && len(diagnostics) == 0 && firstErr == nil {
783+ firstErr = fmt.Errorf("%s: %w", spec.ID, runErr)
784+ }
785+ }
786+ return firstErr
787+}
788+
789+func contentHash(data []byte) string {
790+ sum := sha256.Sum256(data)
791+ return hex.EncodeToString(sum[:])
792+}
793+
794+func displayWorkspacePath(root, path string) string {
795+ if path == "" {
796+ return ""
797+ }
798+ rel, err := filepath.Rel(root, path)
799+ if err != nil {
800+ return ""
801+ }
802+ if rel == "." {
803+ return "."
804+ }
805+ return filepath.ToSlash(rel)
806+}
807+
808+func relativeFiles(cwd string, files []string) []string {
809+ out := make([]string, len(files))
810+ for i, path := range files {
811+ rel, err := filepath.Rel(cwd, path)
812+ if err != nil {
813+ out[i] = path
814+ } else {
815+ out[i] = filepath.ToSlash(rel)
816+ }
817+ }
818+ return out
819+}
820+func absoluteFile(cwd, path string) (string, error) {
821+ if path == "" {
822+ return "", errors.New("path is required")
823+ }
824+ if !filepath.IsAbs(path) {
825+ path = filepath.Join(cwd, path)
826+ }
827+ return filepath.Abs(path)
828+}
829+func diagnosticInWorkspace(rootReal, path string) bool {
830+ return safeWorkspacePath(rootReal, path) == nil
831+}
832+
833+func samePath(a, b string) bool {
834+ aa, _ := filepath.Abs(a)
835+ bb, _ := filepath.Abs(b)
836+ return filepath.Clean(aa) == filepath.Clean(bb)
837+}
838+
839+type limitedBuffer struct {
840+ buffer bytes.Buffer
841+ limit int
842+}
843+
844+func (b *limitedBuffer) Len() int { return b.buffer.Len() }
845+
846+func (b *limitedBuffer) String() string { return b.buffer.String() }
847+
848+func (b *limitedBuffer) Write(p []byte) (int, error) {
849+ original := len(p)
850+ if b.limit <= b.buffer.Len() {
851+ return original, nil
852+ }
853+ room := b.limit - b.buffer.Len()
854+ if len(p) > room {
855+ p = p[:room]
856+ }
857+ _, _ = b.buffer.Write(p)
858+ return original, nil
859+}
A packages/agent/lsp/parsers.go +287 -0
@@ -0,0 +1,287 @@
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(`(?i)^\s*(.+?):([0-9]+):([0-9]+)(?::\s*(error|err|fatal|failure|warning|warn|info|hint|note)\b)?\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 = line
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 := truncateText(compactWhitespace(d.Message), 220)
263+ 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)
264+}
265+
266+func defaultSeverity(value string) string {
267+ if value == "" {
268+ return "info"
269+ }
270+ return value
271+}
272+
273+func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") }
274+
275+func truncateText(value string, limit int) string {
276+ if limit <= 0 {
277+ return ""
278+ }
279+ runes := []rune(value)
280+ if len(runes) <= limit {
281+ return value
282+ }
283+ if limit == 1 {
284+ return "…"
285+ }
286+ return string(runes[:limit-1]) + "…"
287+}
A packages/agent/lsp/protocol.go +157 -0
@@ -0,0 +1,157 @@
1+package lsp
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "net/url"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+)
11+
12+// Position and Range use the zero-based line and UTF-16 character indexing
13+// defined by LSP.
14+type Position struct {
15+ Line int `json:"line"`
16+ Character int `json:"character"`
17+}
18+
19+type Range struct {
20+ Start Position `json:"start"`
21+ End Position `json:"end"`
22+}
23+
24+type TextEdit struct {
25+ Range Range `json:"range"`
26+ NewText string `json:"newText"`
27+}
28+
29+// WorkspaceEdit is the safe subset of a workspace edit understood by zot.
30+// Resource operations in DocumentChanges are deliberately rejected.
31+type WorkspaceEdit struct {
32+ Changes map[string][]TextEdit `json:"changes,omitempty"`
33+ DocumentChanges []json.RawMessage `json:"documentChanges,omitempty"`
34+ ChangeAnnotations json.RawMessage `json:"changeAnnotations,omitempty"`
35+}
36+
37+// Diagnostic is the normalized diagnostic representation shared by LSP and
38+// CLI linters. Path is absolute when it came from a workspace-backed source.
39+type Diagnostic struct {
40+ Path string `json:"path"`
41+ Range Range `json:"range"`
42+ Severity string `json:"severity"`
43+ Code string `json:"code,omitempty"`
44+ Message string `json:"message"`
45+ Source string `json:"source,omitempty"`
46+ Server string `json:"server,omitempty"`
47+ Clear bool `json:"-"`
48+}
49+
50+func (d *Diagnostic) UnmarshalJSON(data []byte) error {
51+ var raw struct {
52+ Range Range `json:"range"`
53+ Severity json.RawMessage `json:"severity"`
54+ Code json.RawMessage `json:"code"`
55+ Message string `json:"message"`
56+ Source string `json:"source"`
57+ }
58+ if err := json.Unmarshal(data, &raw); err != nil {
59+ return err
60+ }
61+ d.Range, d.Message, d.Source = raw.Range, raw.Message, raw.Source
62+ d.Severity = severityFromJSON(raw.Severity)
63+ d.Code = stringFromJSON(raw.Code)
64+ return nil
65+}
66+
67+func severityFromJSON(raw json.RawMessage) string {
68+ var n int
69+ if json.Unmarshal(raw, &n) == nil {
70+ switch n {
71+ case 1:
72+ return "error"
73+ case 2:
74+ return "warning"
75+ case 3:
76+ return "info"
77+ case 4:
78+ return "hint"
79+ }
80+ }
81+ var s string
82+ if json.Unmarshal(raw, &s) == nil {
83+ switch strings.ToLower(s) {
84+ case "error", "err", "fatal":
85+ return "error"
86+ case "warning", "warn":
87+ return "warning"
88+ case "hint":
89+ return "hint"
90+ default:
91+ return "info"
92+ }
93+ }
94+ return "info"
95+}
96+
97+func stringFromJSON(raw json.RawMessage) string {
98+ if len(raw) == 0 || string(raw) == "null" {
99+ return ""
100+ }
101+ var s string
102+ if json.Unmarshal(raw, &s) == nil {
103+ return s
104+ }
105+ var n json.Number
106+ if json.Unmarshal(raw, &n) == nil {
107+ return n.String()
108+ }
109+ return strings.Trim(string(raw), `"`)
110+}
111+
112+// URIForPath returns the escaped file URI used by LSP document params.
113+func URIForPath(path string) string {
114+ uri, _ := pathToURI(path)
115+ return uri
116+}
117+
118+func pathToURI(path string) (string, error) {
119+ abs, err := filepath.Abs(path)
120+ if err != nil {
121+ return "", err
122+ }
123+ // url.URL escapes spaces and non-ASCII while retaining path separators.
124+ uriPath := filepath.ToSlash(abs)
125+ if runtime.GOOS == "windows" && strings.HasPrefix(uriPath, "//") {
126+ unc := strings.TrimPrefix(uriPath, "//")
127+ if host, rest, ok := strings.Cut(unc, "/"); ok {
128+ return (&url.URL{Scheme: "file", Host: host, Path: "/" + rest}).String(), nil
129+ }
130+ }
131+ if !strings.HasPrefix(uriPath, "/") {
132+ uriPath = "/" + uriPath
133+ }
134+ u := &url.URL{Scheme: "file", Path: uriPath}
135+ return u.String(), nil
136+}
137+
138+func uriToPath(uri string) (string, error) {
139+ u, err := url.Parse(uri)
140+ if err != nil {
141+ return "", err
142+ }
143+ if u.Scheme != "" && !strings.EqualFold(u.Scheme, "file") {
144+ return "", fmt.Errorf("unsupported document URI scheme %q", u.Scheme)
145+ }
146+ path := u.Path
147+ if u.Host != "" && u.Host != "localhost" {
148+ path = "//" + u.Host + path
149+ }
150+ if path == "" {
151+ return "", fmt.Errorf("document URI has no path")
152+ }
153+ if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/...
154+ path = path[1:]
155+ }
156+ return filepath.Clean(filepath.FromSlash(path)), nil
157+}
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+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4232+ if err := store.SetLSPEnabled(value); err != nil {
4233+ i.mu.Lock()
4234+ i.statusErr = "settings: " + err.Error()
4235+ i.mu.Unlock()
4236+ return
4237+ }
4238+ }
4239+ val := value
4240+ i.cfg.LSPEnabled = &val
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+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4250+ if err := store.SetSubagentLSPEnabled(value); err != nil {
4251+ i.mu.Lock()
4252+ i.statusErr = "settings: " + err.Error()
4253+ i.mu.Unlock()
4254+ return
4255+ }
4256+ }
4257+ val := value
4258+ i.cfg.SubagentLSPEnabled = &val
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
A packages/agent/modes/lsp_setting_test.go +37 -0
@@ -0,0 +1,37 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+type failingLSPSettingsStore struct {
9+ SettingsStore
10+}
11+
12+func (failingLSPSettingsStore) SetLSPEnabled(bool) error {
13+ return errors.New("persist lsp setting")
14+}
15+
16+func (failingLSPSettingsStore) SetSubagentLSPEnabled(bool) error {
17+ return errors.New("persist subagent lsp setting")
18+}
19+
20+func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) {
21+ mainEnabled := false
22+ subagentEnabled := false
23+ interactive := &Interactive{cfg: InteractiveConfig{
24+ LSPEnabled: &mainEnabled,
25+ SubagentLSPEnabled: &subagentEnabled,
26+ SettingsStore: failingLSPSettingsStore{},
27+ }}
28+
29+ interactive.applySettingToggle("lsp_enabled", true)
30+ if *interactive.cfg.LSPEnabled {
31+ t.Fatal("main-session LSP changed after persistence failure")
32+ }
33+ interactive.applySettingToggle("subagent_lsp_enabled", true)
34+ if *interactive.cfg.SubagentLSPEnabled {
35+ t.Fatal("sub-agent LSP changed after persistence failure")
36+ }
37+}
M packages/agent/rpc.go +6 -0
@@ -15,6 +15,7 @@ import (
15 "github.com/patriceckhart/zot/packages/agent/extensions"15 "github.com/patriceckhart/zot/packages/agent/extensions"
16 "github.com/patriceckhart/zot/packages/agent/extproto"16 "github.com/patriceckhart/zot/packages/agent/extproto"
17 "github.com/patriceckhart/zot/packages/agent/modes"17 "github.com/patriceckhart/zot/packages/agent/modes"
18+ "github.com/patriceckhart/zot/packages/agent/tools"
18 "github.com/patriceckhart/zot/packages/core"19 "github.com/patriceckhart/zot/packages/core"
19 "github.com/patriceckhart/zot/packages/provider"20 "github.com/patriceckhart/zot/packages/provider"
20 )21 )
@@ -79,6 +80,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
79 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})80 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
80 81
81 ag := r.NewAgent()82 ag := r.NewAgent()
83+ defer closeAgentLSP(ag)
82 server.agent = ag84 server.agent = ag
83 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {85 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
84 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)86 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
@@ -111,7 +113,11 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
111 return113 return
112 }114 }
113 resolved.MergeExtensionTools(adapter)115 resolved.MergeExtensionTools(adapter)
116+ oldTools := ag.Tools
114 ag.SetTools(resolved.ToolRegistry)117 ag.SetTools(resolved.ToolRegistry)
118+ if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil {
119+ fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr)
120+ }
115 })121 })
116 122
117 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})123 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
M packages/agent/sdk/sdk.go +23 -3
@@ -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
@@ -95,8 +96,9 @@ type Runtime struct {
95 model string96 model string
96 cwd string97 cwd string
97 98
98- // activeCancel is set while a Prompt is streaming.99+ // activeCancel is set while a Prompt or Compact is running.
99 activeCancel context.CancelFunc100 activeCancel context.CancelFunc
101+ activeDone chan struct{}
100 102
101 // closed signals that Close has been called.103 // closed signals that Close has been called.
102 closed bool104 closed bool
@@ -217,7 +219,9 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch
217 return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)")219 return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)")
218 }220 }
219 subCtx, cancel := context.WithCancel(ctx)221 subCtx, cancel := context.WithCancel(ctx)
222+ done := make(chan struct{})
220 r.activeCancel = cancel223 r.activeCancel = cancel
224+ r.activeDone = done
221 r.mu.Unlock()225 r.mu.Unlock()
222 226
223 imgBlocks := make([]provider.ImageBlock, 0, len(images))227 imgBlocks := make([]provider.ImageBlock, 0, len(images))
@@ -231,6 +235,8 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch
231 defer func() {235 defer func() {
232 r.mu.Lock()236 r.mu.Lock()
233 r.activeCancel = nil237 r.activeCancel = nil
238+ r.activeDone = nil
239+ close(done)
234 r.mu.Unlock()240 r.mu.Unlock()
235 }()241 }()
236 err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) {242 err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) {
@@ -266,11 +272,15 @@ func (r *Runtime) Compact(ctx context.Context, customInstructions string) (Compa
266 return CompactResult{}, ErrBusy272 return CompactResult{}, ErrBusy
267 }273 }
268 subCtx, cancel := context.WithCancel(ctx)274 subCtx, cancel := context.WithCancel(ctx)
275+ done := make(chan struct{})
269 r.activeCancel = cancel276 r.activeCancel = cancel
277+ r.activeDone = done
270 r.mu.Unlock()278 r.mu.Unlock()
271 defer func() {279 defer func() {
272 r.mu.Lock()280 r.mu.Lock()
273 r.activeCancel = nil281 r.activeCancel = nil
282+ r.activeDone = nil
283+ close(done)
274 r.mu.Unlock()284 r.mu.Unlock()
275 }()285 }()
276 286
@@ -339,10 +349,20 @@ func (r *Runtime) Close() error {
339 }349 }
340 r.closed = true350 r.closed = true
341 cancel := r.activeCancel351 cancel := r.activeCancel
352+ done := r.activeDone
342 r.mu.Unlock()353 r.mu.Unlock()
343 if cancel != nil {354 if cancel != nil {
344 cancel()355 cancel()
345 }356 }
357+ if done != nil {
358+ <-done
359+ }
360+ r.mu.Lock()
361+ ag := r.agent
362+ r.mu.Unlock()
363+ if ag != nil {
364+ return agenttools.CloseLSPManagers(ag.Tools)
365+ }
346 return nil366 return nil
347 }367 }
348 368
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 +55 -0
@@ -106,3 +106,58 @@ 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+ yesSub := true
153+ cfg.SubagentLSPEnabled = &yesSub
154+ write, edit := true, true
155+ cfg.LSPDiagnosticsOnWrite = &write
156+ cfg.LSPDiagnosticsOnEdit = &edit
157+ if cfg.LSPDiagnosticsOnWriteEnabled(false) || cfg.LSPDiagnosticsOnEditEnabled(false) {
158+ t.Fatal("diagnostics remained enabled with main-session LSP disabled")
159+ }
160+ if !cfg.LSPDiagnosticsOnWriteEnabled(true) || !cfg.LSPDiagnosticsOnEditEnabled(true) {
161+ t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings")
162+ }
163+}
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 +39 -9
@@ -59,6 +59,13 @@ 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+ initialAg := ag
63+ defer func() {
64+ closeAgentLSP(ag)
65+ if ag != initialAg {
66+ closeAgentLSP(initialAg)
67+ }
68+ }()
62 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)69 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
63 sess, err := openOrCreateSession(args, r, ag, version)70 sess, err := openOrCreateSession(args, r, ag, version)
64 if err != nil {71 if err != nil {
@@ -114,8 +121,9 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
114 turnCtx context.Context = ctx121 turnCtx context.Context = ctx
115 cancelFn context.CancelFunc122 cancelFn context.CancelFunc
116 busyTurn bool123 busyTurn bool
124+ stopping bool
117 turnNo int125 turnNo int
118- shutdown = make(chan struct{})126+ turns sync.WaitGroup
119 )127 )
120 128
121 runOne := func(prompt string) {129 runOne := func(prompt string) {
@@ -157,11 +165,35 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
157 mu.Unlock()165 mu.Unlock()
158 }166 }
159 167
168+ startTurn := func(prompt string) {
169+ mu.Lock()
170+ if stopping {
171+ mu.Unlock()
172+ return
173+ }
174+ turns.Add(1)
175+ mu.Unlock()
176+ go func() {
177+ defer turns.Done()
178+ runOne(prompt)
179+ }()
180+ }
181+ stopAndWait := func(reason string) {
182+ mu.Lock()
183+ stopping = true
184+ if cancelFn != nil {
185+ cancelFn()
186+ }
187+ mu.Unlock()
188+ turns.Wait()
189+ em.emit("agent_stopped", map[string]any{"reason": reason})
190+ }
191+
160 // Initial task: run before processing the inbox so the agent192 // Initial task: run before processing the inbox so the agent
161 // "starts working" the moment it boots, matching what users193 // "starts working" the moment it boots, matching what users
162 // expect from `/swarm new <task>`.194 // expect from `/swarm new <task>`.
163 if args.Prompt != "" {195 if args.Prompt != "" {
164- go runOne(args.Prompt)196+ startTurn(args.Prompt)
165 }197 }
166 198
167 // Inbox loop: one supervisor message at a time. We don't spawn199 // Inbox loop: one supervisor message at a time. We don't spawn
@@ -171,19 +203,17 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
171 for {203 for {
172 select {204 select {
173 case <-ctx.Done():205 case <-ctx.Done():
174- em.emit("agent_stopped", map[string]any{"reason": "cancelled"})206+ stopAndWait("cancelled")
175 return ctx.Err()207 return ctx.Err()
176- case <-shutdown:
177- em.emit("agent_stopped", map[string]any{"reason": "shutdown"})
178- return nil
179 case msg, ok := <-ln.Lines():208 case msg, ok := <-ln.Lines():
180 if !ok {209 if !ok {
181- em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"})210+ stopAndWait("inbox-closed")
182 return nil211 return nil
183 }212 }
184 switch {213 switch {
185 case msg == "shutdown":214 case msg == "shutdown":
186- close(shutdown)215+ stopAndWait("shutdown")
216+ return nil
187 case msg == "cancel":217 case msg == "cancel":
188 mu.Lock()218 mu.Lock()
189 if cancelFn != nil {219 if cancelFn != nil {
@@ -192,7 +222,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
192 mu.Unlock()222 mu.Unlock()
193 case strings.HasPrefix(msg, "user "):223 case strings.HasPrefix(msg, "user "):
194 prompt := strings.TrimPrefix(msg, "user ")224 prompt := strings.TrimPrefix(msg, "user ")
195- go runOne(prompt)225+ startTurn(prompt)
196 default:226 default:
197 em.emit("error", map[string]any{227 em.emit("error", map[string]any{
198 "message": "unknown supervisor message: " + truncateForLog(msg, 200),228 "message": "unknown supervisor message: " + truncateForLog(msg, 200),
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 +351 -0
@@ -0,0 +1,351 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+
13+ "github.com/patriceckhart/zot/packages/agent/lsp"
14+ "github.com/patriceckhart/zot/packages/core"
15+ "github.com/patriceckhart/zot/packages/provider"
16+)
17+
18+const maxLSPToolOutput = 60 * 1024
19+
20+// LSPTool exposes diagnostics, language navigation, and server management to
21+// the model. Manager owns processes and is normally shared by all tool calls
22+// in one agent session.
23+type LSPTool struct {
24+ CWD string
25+ Manager *lsp.Manager
26+ Sandbox *Sandbox
27+}
28+
29+// NewLSPTool constructs the model-facing LSP tool.
30+func NewLSPTool(cwd string, manager *lsp.Manager) *LSPTool {
31+ if manager == nil {
32+ manager = lsp.NewManager()
33+ }
34+ return &LSPTool{CWD: cwd, Manager: manager}
35+}
36+
37+type lspArgs struct {
38+ Action string `json:"action"`
39+ Path string `json:"path,omitempty"`
40+ Line int `json:"line,omitempty"`
41+ Column int `json:"column,omitempty"`
42+ Query string `json:"query,omitempty"`
43+ NewName string `json:"new_name,omitempty"`
44+ Server string `json:"server,omitempty"`
45+ Method string `json:"method,omitempty"`
46+ Params json.RawMessage `json:"params,omitempty"`
47+ Apply *bool `json:"apply,omitempty"`
48+ Timeout int `json:"timeout_ms,omitempty"`
49+ Max int `json:"max,omitempty"`
50+ RunCLI *bool `json:"run_cli,omitempty"`
51+}
52+
53+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"]}`
54+
55+func (t *LSPTool) Name() string { return "lsp" }
56+func (t *LSPTool) Description() string {
57+ return "Query configured language servers and linters for diagnostics, navigation, refactors, symbols, capabilities, and raw LSP requests."
58+}
59+func (t *LSPTool) Schema() json.RawMessage { return json.RawMessage(lspSchema) }
60+
61+func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
62+ if ctx == nil {
63+ ctx = context.Background()
64+ }
65+ var args lspArgs
66+ if err := json.Unmarshal(raw, &args); err != nil {
67+ return core.ToolResult{}, fmt.Errorf("invalid lsp args: %w", err)
68+ }
69+ args.Action = strings.ToLower(strings.TrimSpace(args.Action))
70+ if args.Action == "" {
71+ return core.ToolResult{}, fmt.Errorf("action is required")
72+ }
73+ if t.Manager == nil {
74+ return core.ToolResult{}, fmt.Errorf("lsp manager is unavailable")
75+ }
76+ cwd := t.CWD
77+ if cwd == "" {
78+ cwd, _ = os.Getwd()
79+ }
80+ absCWD, err := filepath.Abs(cwd)
81+ if err != nil {
82+ return core.ToolResult{}, err
83+ }
84+ path := ""
85+ if args.Path != "" && args.Path != "*" {
86+ path = resolvePath(absCWD, args.Path)
87+ if t.Sandbox != nil {
88+ if err := t.Sandbox.CheckPath(path); err != nil {
89+ return core.ToolResult{}, err
90+ }
91+ }
92+ }
93+ runCtx := ctx
94+ if args.Timeout > 0 {
95+ var cancel context.CancelFunc
96+ runCtx, cancel = context.WithTimeout(ctx, time.Duration(args.Timeout)*time.Millisecond)
97+ defer cancel()
98+ }
99+ var text string
100+ var details any
101+ var actionErr error
102+ switch args.Action {
103+ case "diagnostics":
104+ runCLI := true
105+ if args.RunCLI != nil {
106+ runCLI = *args.RunCLI
107+ }
108+ var diagnostics []lsp.Diagnostic
109+ if path != "" && hasGlobMeta(args.Path) {
110+ matches, globErr := filepath.Glob(path)
111+ if globErr != nil {
112+ actionErr = globErr
113+ break
114+ }
115+ for _, match := range matches {
116+ if t.Sandbox != nil {
117+ if checkErr := t.Sandbox.CheckPath(match); checkErr != nil {
118+ if actionErr == nil {
119+ actionErr = checkErr
120+ }
121+ continue
122+ }
123+ }
124+ values, diagErr := t.Manager.DiagnosticsWithOptions(runCtx, absCWD, match, runCLI)
125+ if actionErr == nil {
126+ actionErr = diagErr
127+ }
128+ diagnostics = append(diagnostics, values...)
129+ }
130+ } else {
131+ diagnostics, actionErr = t.Manager.DiagnosticsWithOptions(runCtx, absCWD, path, runCLI)
132+ }
133+ max := boundedMax(args.Max)
134+ text = lsp.SummarizeDiagnostics(diagnostics, absCWD, max)
135+ visible := diagnostics
136+ if len(visible) > max {
137+ visible = visible[:max]
138+ }
139+ details = map[string]any{"diagnostics": visible, "count": len(diagnostics), "truncated": len(visible) < len(diagnostics), "run_cli": runCLI}
140+ case "status":
141+ var statuses []lsp.ServerStatus
142+ statuses, actionErr = t.Manager.Status(absCWD, path)
143+ text = prettyJSON(statuses)
144+ details = statuses
145+ case "reload":
146+ actionErr = t.Manager.Reload(absCWD)
147+ text = "Reloaded LSP and linter processes and configuration."
148+ case "capabilities":
149+ var capabilities map[string]json.RawMessage
150+ capabilities, actionErr = t.Manager.Capabilities(absCWD, path, args.Server)
151+ text = prettyJSON(capabilities)
152+ details = capabilities
153+ case "request":
154+ text, details, actionErr = t.executeRawRequest(runCtx, absCWD, path, args)
155+ case "definition", "references", "hover", "symbols", "rename", "code_actions", "type_definition", "implementation":
156+ text, details, actionErr = t.executeLanguageAction(runCtx, absCWD, path, args)
157+ default:
158+ return core.ToolResult{}, fmt.Errorf("unknown lsp action %q", args.Action)
159+ }
160+ if actionErr != nil && text == "" {
161+ return core.ToolResult{}, actionErr
162+ }
163+ text = boundLSPText(text)
164+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}, nil
165+}
166+
167+func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
168+ if path == "" && (args.Action != "symbols" || args.Query == "") {
169+ return "", nil, fmt.Errorf("path is required for %s", args.Action)
170+ }
171+ if args.Action != "symbols" && (args.Line < 1 || args.Column < 1) {
172+ return "", nil, fmt.Errorf("line and column (1-based) are required for %s", args.Action)
173+ }
174+ position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1}
175+ 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]
176+ var params map[string]any
177+ if args.Action == "symbols" {
178+ if args.Query != "" {
179+ method, params = "workspace/symbol", map[string]any{"query": args.Query}
180+ } else {
181+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}}
182+ }
183+ } else {
184+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position}
185+ if args.Action == "references" {
186+ params["context"] = map[string]any{"includeDeclaration": true}
187+ }
188+ if args.Action == "code_actions" {
189+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}}
190+ }
191+ if args.Action == "rename" {
192+ if strings.TrimSpace(args.NewName) == "" {
193+ return "", nil, fmt.Errorf("new_name is required for rename")
194+ }
195+ params["newName"] = args.NewName
196+ }
197+ }
198+ responses := t.Manager.RequestAll(ctx, cwd, path, method, params)
199+ apply := args.Apply != nil && *args.Apply
200+ if args.Action == "rename" && args.Apply == nil {
201+ apply = true
202+ }
203+ text, applyCount, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
204+ if applyCount > 0 {
205+ text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)
206+ }
207+ return text, map[string]any{"action": args.Action, "responses": responses, "applied": applyCount}, applyErr
208+}
209+
210+func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
211+ if strings.TrimSpace(args.Method) == "" {
212+ return "", nil, fmt.Errorf("method is required for request")
213+ }
214+ var params any = map[string]any{}
215+ if len(args.Params) > 0 && string(args.Params) != "null" {
216+ if err := json.Unmarshal(args.Params, &params); err != nil {
217+ return "", nil, fmt.Errorf("params must be JSON: %w", err)
218+ }
219+ }
220+ var responses []lsp.Response
221+ if path != "" {
222+ _ = t.Manager.Open(ctx, cwd, path)
223+ }
224+ if args.Server != "" {
225+ result, err := t.Manager.Request(ctx, cwd, args.Server, args.Method, params)
226+ responses = []lsp.Response{{Server: args.Server, Result: result, Error: err}}
227+ } else {
228+ responses = t.Manager.RequestAll(ctx, cwd, path, args.Method, params)
229+ }
230+ return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)
231+}
232+
233+func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, error) {
234+ var b strings.Builder
235+ applied := 0
236+ var firstErr error
237+ pendingEdits := make([]lsp.WorkspaceEdit, 0)
238+ for _, response := range responses {
239+ if response.Error != nil {
240+ fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
241+ if firstErr == nil {
242+ firstErr = response.Error
243+ }
244+ continue
245+ }
246+ fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
247+ if apply && (action == "rename" || action == "code_actions") {
248+ pendingEdits = append(pendingEdits, workspaceEdits(response.Result, action)...)
249+ }
250+ }
251+ if len(pendingEdits) > 0 {
252+ if err := manager.ApplyEdits(cwd, pendingEdits); err != nil {
253+ if firstErr == nil {
254+ firstErr = err
255+ }
256+ fmt.Fprintf(&b, "apply: error: %s\n", err)
257+ } else {
258+ applied = len(pendingEdits)
259+ }
260+ }
261+ if b.Len() == 0 {
262+ return "No results.", applied, firstErr
263+ }
264+ return b.String(), applied, firstErr
265+}
266+
267+func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {
268+ if action == "rename" {
269+ var edit lsp.WorkspaceEdit
270+ if json.Unmarshal(raw, &edit) == nil && (len(edit.Changes) > 0 || len(edit.DocumentChanges) > 0) {
271+ return []lsp.WorkspaceEdit{edit}
272+ }
273+ return nil
274+ }
275+ var actions []struct {
276+ Edit lsp.WorkspaceEdit `json:"edit"`
277+ }
278+ if json.Unmarshal(raw, &actions) != nil {
279+ return nil
280+ }
281+ out := make([]lsp.WorkspaceEdit, 0, len(actions))
282+ for _, action := range actions {
283+ if len(action.Edit.Changes) > 0 || len(action.Edit.DocumentChanges) > 0 {
284+ out = append(out, action.Edit)
285+ }
286+ }
287+ return out
288+}
289+
290+func formatRawResponses(responses []lsp.Response) string {
291+ var b strings.Builder
292+ for _, response := range responses {
293+ if response.Error != nil {
294+ fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
295+ continue
296+ }
297+ fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
298+ }
299+ if b.Len() == 0 {
300+ return "No results."
301+ }
302+ return b.String()
303+}
304+
305+func firstResponseError(responses []lsp.Response) error {
306+ for _, response := range responses {
307+ if response.Error != nil {
308+ return response.Error
309+ }
310+ }
311+ return nil
312+}
313+func fileURI(path string) string { return lsp.URIForPath(path) }
314+func prettyJSON(raw any) string {
315+ var data []byte
316+ switch value := raw.(type) {
317+ case json.RawMessage:
318+ data = value
319+ default:
320+ data, _ = json.Marshal(value)
321+ }
322+ var value any
323+ if json.Unmarshal(data, &value) == nil {
324+ if formatted, err := json.MarshalIndent(value, "", " "); err == nil {
325+ return string(formatted)
326+ }
327+ }
328+ return strings.TrimSpace(string(data))
329+}
330+func hasGlobMeta(value string) bool {
331+ return strings.ContainsAny(value, "*?[")
332+}
333+
334+func boundedMax(value int) int {
335+ if value <= 0 || value > lsp.DefaultDiagnosticCap {
336+ return lsp.DefaultDiagnosticCap
337+ }
338+ return value
339+}
340+func boundLSPText(value string) string {
341+ if len(value) <= maxLSPToolOutput {
342+ return value
343+ }
344+ suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput)
345+ available := maxLSPToolOutput - len(suffix)
346+ cut := value[:available]
347+ for len(cut) > 0 && !utf8.ValidString(cut) {
348+ cut = cut[:len(cut)-1]
349+ }
350+ return cut + suffix
351+}
A packages/agent/tools/lsp_diagnostics.go +96 -0
@@ -0,0 +1,96 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/agent/lsp"
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+const writeDiagnosticsTimeout = 2 * time.Second
15+
16+// CloseLSPManagers closes every manager referenced by the registry. Write,
17+// edit, and lsp share one manager in normal runs, so de-duplicate pointers
18+// before closing them. This is used by mode owners when an agent or registry
19+// is replaced; LSP processes should not outlive the session that created them.
20+func CloseLSPManagers(reg core.Registry) error {
21+ managers := make(map[*lsp.Manager]struct{})
22+ for _, tool := range reg {
23+ switch value := tool.(type) {
24+ case *LSPTool:
25+ if value != nil && value.Manager != nil {
26+ managers[value.Manager] = struct{}{}
27+ }
28+ case *WriteTool:
29+ if value != nil && value.LSP != nil {
30+ managers[value.LSP] = struct{}{}
31+ }
32+ case *EditTool:
33+ if value != nil && value.LSP != nil {
34+ managers[value.LSP] = struct{}{}
35+ }
36+ }
37+ }
38+ var firstErr error
39+ for manager := range managers {
40+ if err := manager.Close(); err != nil && firstErr == nil {
41+ firstErr = err
42+ }
43+ }
44+ return firstErr
45+}
46+
47+// attachWriteDiagnostics performs a bounded post-write diagnostics pass. A
48+// failed or slow language server must never turn a successful filesystem
49+// mutation into a failed tool call, so callers only attach diagnostics that
50+// were actually returned before the short budget expired.
51+func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) {
52+ if manager == nil || result == nil {
53+ return
54+ }
55+ if ctx == nil {
56+ ctx = context.Background()
57+ }
58+ diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout)
59+ defer cancel()
60+ diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
61+ if err != nil {
62+ if details, ok := result.Details.(map[string]any); ok {
63+ status := "unavailable"
64+ switch {
65+ case errors.Is(err, context.DeadlineExceeded):
66+ status = "timeout"
67+ case errors.Is(err, context.Canceled):
68+ status = "canceled"
69+ }
70+ details["diagnostics_status"] = status
71+ details["diagnostics_error"] = err.Error()
72+ }
73+ return
74+ }
75+ diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics)
76+ if len(diagnostics) == 0 {
77+ return
78+ }
79+ summary := lsp.SummarizeDiagnostics(diagnostics, cwd, 8)
80+ if strings.TrimSpace(summary) == "" || summary == "No diagnostics." {
81+ return
82+ }
83+ for index, block := range result.Content {
84+ text, ok := block.(provider.TextBlock)
85+ if !ok {
86+ continue
87+ }
88+ text.Text = strings.TrimRight(text.Text, "\n") + "\n\n" + summary + "\n"
89+ result.Content[index] = text
90+ break
91+ }
92+ if details, ok := result.Details.(map[string]any); ok {
93+ details["diagnostics"] = diagnostics
94+ details["diagnostics_summary"] = summary
95+ }
96+}
A packages/agent/tools/lsp_test.go +74 -0
@@ -0,0 +1,74 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "testing"
11+ "unicode/utf8"
12+
13+ "github.com/patriceckhart/zot/packages/agent/lsp"
14+ "github.com/patriceckhart/zot/packages/provider"
15+)
16+
17+func TestLSPToolSchemaAndFormatting(t *testing.T) {
18+ tool := NewLSPTool(t.TempDir(), lsp.NewManager())
19+ var schema map[string]any
20+ if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
21+ t.Fatal(err)
22+ }
23+ if tool.Name() != "lsp" || !strings.Contains(tool.Description(), "diagnostics") {
24+ t.Fatalf("tool metadata missing")
25+ }
26+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"status"}`), nil)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ text, ok := result.Content[0].(provider.TextBlock)
31+ if !ok || text.Text == "" {
32+ t.Fatalf("status result = %#v", result.Content)
33+ }
34+ if !strings.Contains(text.Text, "[") && !strings.Contains(text.Text, "{") {
35+ t.Fatalf("status was not formatted JSON: %q", text.Text)
36+ }
37+}
38+
39+func TestLSPToolGlobRespectsJailForSymlinkMatches(t *testing.T) {
40+ if runtime.GOOS == "windows" {
41+ t.Skip("symlink permissions vary on Windows")
42+ }
43+ root := t.TempDir()
44+ outside := t.TempDir()
45+ outsidePath := filepath.Join(outside, "outside.go")
46+ if err := os.WriteFile(outsidePath, []byte("package outside\\n"), 0o644); err != nil {
47+ t.Fatal(err)
48+ }
49+ if err := os.Symlink(outsidePath, filepath.Join(root, "link.go")); err != nil {
50+ t.Fatal(err)
51+ }
52+ sandbox := NewSandbox(root)
53+ sandbox.Lock()
54+ tool := NewLSPTool(root, lsp.NewManager())
55+ tool.Sandbox = sandbox
56+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"diagnostics","path":"*.go","run_cli":false}`), nil)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if !result.IsError {
61+ t.Fatalf("jailed symlink glob was accepted: %#v", result)
62+ }
63+}
64+
65+func TestBoundLSPText(t *testing.T) {
66+ value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10))
67+ if len(value) > maxLSPToolOutput || !strings.Contains(value, "truncated") {
68+ t.Fatalf("bound output = %d bytes", len(value))
69+ }
70+ unicodeValue := boundLSPText(strings.Repeat("界", maxLSPToolOutput/3+10))
71+ if !utf8.ValidString(unicodeValue) {
72+ t.Fatal("bound output split a UTF-8 sequence")
73+ }
74+}
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 child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it shares this working directory and 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