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.
1919
2020 - one static binary.
2121 - 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.
2323 - three run modes (interactive tui, print, json).
2424 - built-in telegram bot.
2525 - 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
219219 | `--no-session` | Don't read or write session files. |
220220 | `--cwd <path>` | Use `<path>` as the working directory. |
221221 | `--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. |
223224 | `--max-steps <n>` | Cap agent loop iterations (default 50). |
224225 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
225226 | `--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
232233 - `write`: create or overwrite files, making parent directories as needed.
233234 - `edit`: one or more exact-match replacements in an existing file.
234235 - `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).
235237
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.
237239
238240 ## Modes
239241
@@ -290,7 +292,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
290292 | `/unjail` | Allow tools to touch paths outside again. |
291293 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
292294 | `/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. |
294296 | `/clear` | Clear the chat transcript. |
295297 | `/exit` | Exit zot. |
296298
@@ -399,6 +401,8 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
399401 - **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`.
400402 - **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.
401403 - **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.
402406 - **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.
403407 - **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.
404408 - **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`,
876880 | `ctrl+d` | Exit on empty input. |
877881 | `ctrl+l` | Redraw the screen. |
878882 | `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). |
880884 | `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. |
881885 | `@` | Open the file picker. Browse files and directories in the working directory. |
882886
@@ -1038,12 +1042,13 @@ packages/provider/auth/ credential store, api-key probe, oauth, lo
10381042 packages/core/ agent loop, sessions, cost tracking, compaction
10391043 packages/tui/ terminal raw-mode, input parser, editor, renderer, markdown, view
10401044 packages/agent/ cli wiring, arg parsing, system prompt, config
1045+packages/agent/lsp/ LSP clients, server discovery, linters, diagnostics
10411046 packages/agent/extensions/ extension subprocess manager
10421047 packages/agent/extproto/ extension wire-format types
10431048 packages/agent/modes/ interactive tui, print, json, dialogs
10441049 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10451050 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1046-packages/agent/tools/ read, write, edit, bash, sandbox
1051+packages/agent/tools/ read, write, edit, bash, lsp, sandbox
10471052 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10481053 packages/agent/subagents/ named markdown profile discovery and dispatch
10491054 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
2323 ---
2424 name: reviewer
2525 description: Read-only code reviewer
26-tools: read, write, edit, bash
26+tools: read
2727 model: openai-codex/gpt-5.6-luna
2828 thinking: max
2929 systemPromptMode: replace
@@ -40,13 +40,13 @@ Supported metadata:
4040 |---|---|
4141 | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. |
4242 | `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. |
4444 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
4545 | `provider` | Optional separate provider ID for a model without a provider prefix. |
4646 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
4747 | `systemPromptMode` | `append` (default) or `replace`. |
4848 | `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. |
5050
5151 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.
5252
M examples/extensions/mcp-bridge/bridge.go +1 -1
@@ -5,7 +5,7 @@
55 // mcp__<server>__<tool>
66 //
77 // 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).
99 package main
1010
1111 import (
M packages/agent/args.go +5 -1
@@ -63,6 +63,7 @@ type Args struct {
6363
6464 CWD string
6565 NoTools bool
66+ NoLSP bool
6667 Tools []string
6768 MaxSteps int
6869
@@ -177,6 +178,8 @@ func ParseArgs(in []string) (Args, error) {
177178 a.NoSess = true
178179 case "--no-tools":
179180 a.NoTools = true
181+ case "--no-lsp":
182+ a.NoLSP = true
180183 case "--list-models":
181184 a.ListModels = true
182185 case "--experimental-oauth":
@@ -459,7 +462,8 @@ func PrintHelp(version string) {
459462 section("workspace, tools, skills",
460463 row{"--cwd PATH", "treat PATH as the working directory"},
461464 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)"},
463467 row{"--no-yolo", "ask before running every tool call"},
464468 row{"-y, --yes", "accept zot run consent without prompting"},
465469 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) {
1212 }
1313 }
1414
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+
1525 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
1626 args, err := ParseArgs([]string{"--temperature", "0"})
1727 if err != nil {
M packages/agent/botcmd.go +2 -0
@@ -218,6 +218,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
218218 if err != nil {
219219 return err
220220 }
221+ defer closeResolvedLSP(resolved)
221222
222223 if ok, cerr := spec.configured(ZotHome()); cerr != nil {
223224 return cerr
@@ -269,6 +270,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
269270 if err != nil {
270271 return err
271272 }
273+ defer closeResolvedLSP(next)
272274 agent.Client = next.NewClient()
273275 agent.Model = next.Model
274276 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
M packages/agent/build.go +44 -5
@@ -9,6 +9,7 @@ import (
99 "strings"
1010
1111 zotdocs "github.com/patriceckhart/zot"
12+ "github.com/patriceckhart/zot/packages/agent/lsp"
1213 "github.com/patriceckhart/zot/packages/agent/skills"
1314 "github.com/patriceckhart/zot/packages/agent/subagents"
1415 "github.com/patriceckhart/zot/packages/agent/tools"
@@ -621,7 +622,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
621622 if args.PermissionSet != nil {
622623 sandbox.SetPermissions(args.PermissionSet)
623624 }
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))
625628
626629 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
627630
@@ -1028,6 +1031,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
10281031 v.Sandbox = s
10291032 case *tools.BashTool:
10301033 v.Sandbox = s
1034+ case *tools.LSPTool:
1035+ v.Sandbox = s
10311036 }
10321037 _ = name
10331038 }
@@ -1044,16 +1049,29 @@ func (r Resolved) NewAgent() *core.Agent {
10441049 return a
10451050 }
10461051
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 {
10481053 if args.NoTools {
10491054 return core.Registry{}
10501055 }
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+ }
10511064 all := map[string]core.Tool{
10521065 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1053- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1054- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1066+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1067+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
10551068 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
10561069 }
1070+ if manager != nil {
1071+ lspTool := tools.NewLSPTool(cwd, manager)
1072+ lspTool.Sandbox = sandbox
1073+ all["lsp"] = lspTool
1074+ }
10571075 reg := core.Registry{}
10581076 if len(args.Tools) == 0 {
10591077 for _, t := range all {
@@ -1069,8 +1087,29 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
10691087 return reg
10701088 }
10711089
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+
10721111 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1073- order := []string{"read", "write", "edit", "bash"}
1112+ order := []string{"read", "write", "edit", "bash", "lsp"}
10741113 var out []ToolSummary
10751114 for _, name := range order {
10761115 if t, ok := reg[name]; ok {
M packages/agent/build_test.go +29 -0
@@ -9,9 +9,38 @@ import (
99 "strings"
1010 "testing"
1111
12+ "github.com/patriceckhart/zot/packages/agent/tools"
1213 "github.com/patriceckhart/zot/packages/provider"
1314 )
1415
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+
1544 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
1645 root := t.TempDir()
1746 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
179179 return fallback
180180 }
181181
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+
182192 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
183193 if keepTail <= 0 || len(msgs) <= keepTail {
184194 return provider.RepairOrphanedToolResults(msgs)
@@ -648,6 +658,13 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
648658 defer stopExt()
649659
650660 ag := r.NewAgent()
661+ initialAg := ag
662+ defer func() {
663+ closeAgentLSP(ag)
664+ if ag != initialAg {
665+ closeAgentLSP(initialAg)
666+ }
667+ }()
651668 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
652669 sess, err := openOrCreateSession(args, r, ag, version)
653670 if err != nil {
@@ -702,6 +719,13 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
702719 defer stopExt()
703720
704721 ag := r.NewAgent()
722+ initialAg := ag
723+ defer func() {
724+ closeAgentLSP(ag)
725+ if ag != initialAg {
726+ closeAgentLSP(initialAg)
727+ }
728+ }()
705729 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
706730 sess, err := openOrCreateSession(args, r, ag, version)
707731 if err != nil {
@@ -795,6 +819,13 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
795819 defer stopExt()
796820
797821 ag := r.NewAgent()
822+ initialAg := ag
823+ defer func() {
824+ closeAgentLSP(ag)
825+ if ag != initialAg {
826+ closeAgentLSP(initialAg)
827+ }
828+ }()
798829 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
799830 sess, err := openOrCreateSession(args, r, ag, version)
800831 if err != nil {
@@ -915,8 +946,10 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
915946 if mutateRegistry != nil {
916947 reg = mutateRegistry(reg)
917948 }
949+ oldTools := ag.Tools
918950 ag.SetTools(reg)
919951 ag.System = resolved.SystemPrompt
952+ _ = tools.CloseLSPManagers(oldTools)
920953 }
921954
922955 // reloadResourcesAfterStartupPre reloads extensions (if any) and
@@ -1152,6 +1185,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11521185 return a
11531186 }
11541187
1188+ var ag *core.Agent
11551189 buildAgent := func() (*core.Agent, string, string, error) {
11561190 resolved, err := Resolve(args, true)
11571191 if err != nil {
@@ -1209,7 +1243,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12091243 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
12101244 }
12111245
1212- var ag *core.Agent
12131246 if r.HasCredential() {
12141247 ag = wireAgentExt(r.NewAgent())
12151248 }
@@ -1321,6 +1354,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13211354 return a
13221355 }
13231356 wireAgentPersist(ag)
1357+ defer func() {
1358+ closeAgentLSP(liveInteractiveAgent(iv, ag))
1359+ closeResolvedLSP(r)
1360+ }()
13241361
13251362 // Re-wrap the build closures so any agent constructed by the TUI
13261363 // (login, /model swap to a different provider) also gets the
@@ -1601,6 +1638,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16011638 activeModel = newModel
16021639 persistMu.Unlock()
16031640 ag = newAg
1641+ closeAgentLSP(currentAg)
16041642
16051643 // Push the new state into the running Interactive.
16061644 if iv != nil {
@@ -1709,6 +1747,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17091747 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
17101748 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
17111749 FastMode: &fastMode,
1750+ LSPEnabled: initialCfg.LSPEnabled,
1751+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
17121752 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
17131753 JailByDefault: initialCfg.JailByDefault,
17141754 QuickModelShortcuts: quickModelShortcuts,
@@ -1766,6 +1806,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17661806 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
17671807 }
17681808 },
1809+ RefreshTools: func() {
1810+ current := liveInteractiveAgent(iv, ag)
1811+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1812+ },
17691813 AuthManager: mgr,
17701814 LlamaCPPConfig: ResolveLlamaCPPConfig,
17711815 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
@@ -1977,7 +2021,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19772021 if !ok {
19782022 return
19792023 }
1980- if finalAg := iv.Agent(); finalAg != nil {
2024+ finalAg := iv.Agent()
2025+ if finalAg != nil {
19812026 persistMu.Lock()
19822027 if sess != nil {
19832028 writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
@@ -1987,6 +2032,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19872032 }
19882033 persistMu.Unlock()
19892034 }
2035+ closeAgentLSP(finalAg)
2036+ closeResolvedLSP(r)
19902037 // Exit cleanly. Re-raising the signal would skip os.Exit's
19912038 // at-exit hooks; explicit exit is fine because we've already
19922039 // flushed the only at-risk state (the session file).
M packages/agent/config.go +40 -0
@@ -73,6 +73,23 @@ type Config struct {
7373 // default; nil/missing means disabled. Toggle from /settings.
7474 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
7575
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+
7693 // AutoCompactThreshold is the percentage of the model context window
7794 // that triggers automatic transcript compaction in interactive mode.
7895 // nil/missing means 85; zero disables percentage-based triggers.
@@ -196,6 +213,29 @@ func (c Config) CompactUserInput() bool {
196213 return c.CompactInput != nil && *c.CompactInput
197214 }
198215
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+
199239 // AuthPath returns the path to auth.json.
200240 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
201241
M packages/agent/ext/ext.go +1 -1
@@ -395,7 +395,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
395395 // OpenAI accept). Call this BEFORE Run(); zot folds extension tools
396396 // into the agent's registry once the extension's ready frame fires.
397397 //
398-// Naming conflicts with built-in tools (read, write, edit, bash,
398+// Naming conflicts with built-in tools (read, write, edit, bash, lsp,
399399 // skill) are silently shadowed by the built-in.
400400 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
401401 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 {
7777 // to enable it and reject requests when it remains enabled.
7878 FastMode *bool
7979
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+
8088 // AutoCompactThreshold is the context-window percentage that triggers
8189 // automatic compaction. nil means 85; zero disables percentage-based
8290 // triggers while preserving payload-too-large recovery.
@@ -159,6 +167,10 @@ type InteractiveConfig struct {
159167 // it to rediscover skills / reload extensions installed by pre.
160168 OnStartupPreDone func()
161169
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+
162174 // AutoSubmitInitial, when true, auto-submits InitialInput after
163175 // StartupPre completes (or immediately when StartupPre is empty).
164176 // When false, InitialInput only pre-fills the editor.
@@ -404,6 +416,11 @@ type terminalTitleSettingsStore interface {
404416 SetTerminalTitleEnabled(enabled bool) error
405417 }
406418
419+type lspSettingsStore interface {
420+ SetLSPEnabled(enabled bool) error
421+ SetSubagentLSPEnabled(enabled bool) error
422+}
423+
407424 type showInstructionsSettingsStore interface {
408425 SetShowInstructionsAtStartup(enabled bool) error
409426 }
@@ -3631,6 +3648,8 @@ func (i *Interactive) openSettingsDialog() {
36313648 if !provider.SupportsFastMode(i.cfg.Provider) {
36323649 fastModeHint = "only supported for OpenAI providers"
36333650 }
3651+ lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
3652+ subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
36343653
36353654 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
36363655 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
@@ -3757,6 +3776,18 @@ func (i *Interactive) openSettingsDialog() {
37573776 value: fastMode,
37583777 hint: fastModeHint,
37593778 },
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+ },
37603791 {
37613792 key: "auto_compact_threshold",
37623793 label: "auto-compact threshold",
@@ -4196,6 +4227,39 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
41964227 i.statusOK = "fast mode " + onOff(value)
41974228 i.statusErr = ""
41984229 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()
41994263 case "jail_by_default":
42004264 val := value
42014265 i.cfg.JailByDefault = &val
@@ -5130,7 +5194,10 @@ func (i *Interactive) doLogout(target string) {
51305194 if clearedCurrent {
51315195 // The running agent was using a credential we just wiped. Drop
51325196 // 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+ }
51345201 i.agent = nil
51355202 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
51365203 } else {
@@ -5722,6 +5789,9 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
57225789 }
57235790 ag.SeedCost(carryCost)
57245791
5792+ if i.agent != nil && i.agent != ag {
5793+ _ = tools.CloseLSPManagers(i.agent.Tools)
5794+ }
57255795 i.mu.Lock()
57265796 i.agent = ag
57275797 i.cfg.Provider = p
@@ -5764,12 +5834,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
57645834 }
57655835 commitLogin := func() {
57665836 i.mu.Lock()
5837+ oldAgent := i.agent
57675838 i.agent = ag
57685839 i.cfg.Provider = prov
57695840 i.cfg.Model = model
57705841 i.statusErr = ""
57715842 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
57725843 i.mu.Unlock()
5844+ if oldAgent != nil {
5845+ _ = tools.CloseLSPManagers(oldAgent.Tools)
5846+ }
57735847 i.applyAutoSwarmTool(i.autoSwarmEnabled())
57745848 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
57755849 // 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 (
1515 "github.com/patriceckhart/zot/packages/agent/extensions"
1616 "github.com/patriceckhart/zot/packages/agent/extproto"
1717 "github.com/patriceckhart/zot/packages/agent/modes"
18+ "github.com/patriceckhart/zot/packages/agent/tools"
1819 "github.com/patriceckhart/zot/packages/core"
1920 "github.com/patriceckhart/zot/packages/provider"
2021 )
@@ -79,6 +80,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
7980 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
8081
8182 ag := r.NewAgent()
83+ defer closeAgentLSP(ag)
8284 server.agent = ag
8385 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
8486 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
@@ -111,7 +113,11 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
111113 return
112114 }
113115 resolved.MergeExtensionTools(adapter)
116+ oldTools := ag.Tools
114117 ag.SetTools(resolved.ToolRegistry)
118+ if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil {
119+ fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr)
120+ }
115121 })
116122
117123 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
M packages/agent/sdk/sdk.go +23 -3
@@ -30,6 +30,7 @@ import (
3030 "sync"
3131
3232 "github.com/patriceckhart/zot/packages/agent"
33+ agenttools "github.com/patriceckhart/zot/packages/agent/tools"
3334 "github.com/patriceckhart/zot/packages/core"
3435 "github.com/patriceckhart/zot/packages/provider"
3536 )
@@ -74,8 +75,8 @@ type Config struct {
7475 // BaseURL overrides the provider base url (for tests / proxies).
7576 BaseURL string
7677
77- // Tools is the list of tools to enable. Nil/empty = all four
78- // (read, write, edit, bash). Pass an empty-but-non-nil slice
78+ // Tools is the list of tools to enable. Nil/empty = all built-in
79+ // tools (read, write, edit, bash, lsp). Pass an empty-but-non-nil slice
7980 // (e.g. []string{}) plus NoTools=true to disable everything.
8081 Tools []string
8182
@@ -95,8 +96,9 @@ type Runtime struct {
9596 model string
9697 cwd string
9798
98- // activeCancel is set while a Prompt is streaming.
99+ // activeCancel is set while a Prompt or Compact is running.
99100 activeCancel context.CancelFunc
101+ activeDone chan struct{}
100102
101103 // closed signals that Close has been called.
102104 closed bool
@@ -217,7 +219,9 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch
217219 return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)")
218220 }
219221 subCtx, cancel := context.WithCancel(ctx)
222+ done := make(chan struct{})
220223 r.activeCancel = cancel
224+ r.activeDone = done
221225 r.mu.Unlock()
222226
223227 imgBlocks := make([]provider.ImageBlock, 0, len(images))
@@ -231,6 +235,8 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch
231235 defer func() {
232236 r.mu.Lock()
233237 r.activeCancel = nil
238+ r.activeDone = nil
239+ close(done)
234240 r.mu.Unlock()
235241 }()
236242 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
266272 return CompactResult{}, ErrBusy
267273 }
268274 subCtx, cancel := context.WithCancel(ctx)
275+ done := make(chan struct{})
269276 r.activeCancel = cancel
277+ r.activeDone = done
270278 r.mu.Unlock()
271279 defer func() {
272280 r.mu.Lock()
273281 r.activeCancel = nil
282+ r.activeDone = nil
283+ close(done)
274284 r.mu.Unlock()
275285 }()
276286
@@ -339,10 +349,20 @@ func (r *Runtime) Close() error {
339349 }
340350 r.closed = true
341351 cancel := r.activeCancel
352+ done := r.activeDone
342353 r.mu.Unlock()
343354 if cancel != nil {
344355 cancel()
345356 }
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+ }
346366 return nil
347367 }
348368
M packages/agent/settings_store.go +18 -0
@@ -77,6 +77,24 @@ func (configSettingsStore) SetFastMode(enabled bool) error {
7777 return SaveConfig(cfg)
7878 }
7979
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+
8098 func (configSettingsStore) SetAutoCompactThreshold(percent int) error {
8199 cfg, err := LoadConfig()
82100 if err != nil {
M packages/agent/settings_store_test.go +55 -0
@@ -106,3 +106,58 @@ func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) {
106106 t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
107107 }
108108 }
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):
2929 2. **Tools** — register tools the LLM itself calls. Schema is
3030 JSON Schema; zot routes the model's `tool_call` to the
3131 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/skill).
32+ (read/write/edit/bash/lsp/skill).
3333
3434 3. **Lifecycle hooks** — subscribe to events
3535 (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 {
5959 defer stopExt()
6060
6161 ag := r.NewAgent()
62+ initialAg := ag
63+ defer func() {
64+ closeAgentLSP(ag)
65+ if ag != initialAg {
66+ closeAgentLSP(initialAg)
67+ }
68+ }()
6269 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
6370 sess, err := openOrCreateSession(args, r, ag, version)
6471 if err != nil {
@@ -114,8 +121,9 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
114121 turnCtx context.Context = ctx
115122 cancelFn context.CancelFunc
116123 busyTurn bool
124+ stopping bool
117125 turnNo int
118- shutdown = make(chan struct{})
126+ turns sync.WaitGroup
119127 )
120128
121129 runOne := func(prompt string) {
@@ -157,11 +165,35 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
157165 mu.Unlock()
158166 }
159167
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+
160192 // Initial task: run before processing the inbox so the agent
161193 // "starts working" the moment it boots, matching what users
162194 // expect from `/swarm new <task>`.
163195 if args.Prompt != "" {
164- go runOne(args.Prompt)
196+ startTurn(args.Prompt)
165197 }
166198
167199 // 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 {
171203 for {
172204 select {
173205 case <-ctx.Done():
174- em.emit("agent_stopped", map[string]any{"reason": "cancelled"})
206+ stopAndWait("cancelled")
175207 return ctx.Err()
176- case <-shutdown:
177- em.emit("agent_stopped", map[string]any{"reason": "shutdown"})
178- return nil
179208 case msg, ok := <-ln.Lines():
180209 if !ok {
181- em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"})
210+ stopAndWait("inbox-closed")
182211 return nil
183212 }
184213 switch {
185214 case msg == "shutdown":
186- close(shutdown)
215+ stopAndWait("shutdown")
216+ return nil
187217 case msg == "cancel":
188218 mu.Lock()
189219 if cancelFn != nil {
@@ -192,7 +222,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
192222 mu.Unlock()
193223 case strings.HasPrefix(msg, "user "):
194224 prompt := strings.TrimPrefix(msg, "user ")
195- go runOne(prompt)
225+ startTurn(prompt)
196226 default:
197227 em.emit("error", map[string]any{
198228 "message": "unknown supervisor message: " + truncateForLog(msg, 200),
M packages/agent/tools/edit.go +8 -2
@@ -8,14 +8,17 @@ import (
88 "os"
99 "strings"
1010
11+ "github.com/patriceckhart/zot/packages/agent/lsp"
1112 "github.com/patriceckhart/zot/packages/core"
1213 "github.com/patriceckhart/zot/packages/provider"
1314 )
1415
1516 // EditTool applies one or more exact-match replacements to a file.
1617 type EditTool struct {
17- CWD string
18- Sandbox *Sandbox
18+ CWD string
19+ Sandbox *Sandbox
20+ LSP *lsp.Manager
21+ LSPDiagnostics bool
1922 }
2023
2124 type editOp struct {
@@ -61,6 +64,9 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
6164 if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
6265 return core.ToolResult{}, err
6366 }
67+ if t.LSPDiagnostics {
68+ attachWriteDiagnostics(ctx, t.CWD, plan.path, t.LSP, &plan.result)
69+ }
6470 return plan.result, nil
6571 }
6672
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.
22 package tools
33
44 import (
M packages/agent/tools/swarm_spawn.go +1 -1
@@ -65,7 +65,7 @@ const swarmSpawnSchema = `{
6565 "properties": {
6666 "task": {
6767 "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."
6969 },
7070 "agent": {
7171 "type": "string",
M packages/agent/tools/write.go +11 -4
@@ -8,14 +8,17 @@ import (
88 "path/filepath"
99 "strings"
1010
11+ "github.com/patriceckhart/zot/packages/agent/lsp"
1112 "github.com/patriceckhart/zot/packages/core"
1213 "github.com/patriceckhart/zot/packages/provider"
1314 )
1415
1516 // WriteTool writes content to a file, creating parent directories.
1617 type WriteTool struct {
17- CWD string
18- Sandbox *Sandbox
18+ CWD string
19+ Sandbox *Sandbox
20+ LSP *lsp.Manager
21+ LSPDiagnostics bool
1922 }
2023
2124 type writeArgs struct {
@@ -62,7 +65,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
6265 if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {
6366 totalLines++ // count the last unterminated line
6467 }
65- return core.ToolResult{
68+ result := core.ToolResult{
6669 Content: []provider.Content{provider.TextBlock{Text: a.Content}},
6770 Details: map[string]any{
6871 "path": path,
@@ -70,5 +73,9 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
7073 "total_lines": totalLines,
7174 "start_line": 1,
7275 },
73- }, nil
76+ }
77+ if t.LSPDiagnostics {
78+ attachWriteDiagnostics(ctx, t.CWD, path, t.LSP, &result)
79+ }
80+ return result, nil
7481 }

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