detached 408d4f4

feat(agent): add lsp and linter support

commit
408d4f4d5e34dc3d474eb26757d54b41f5543bb8
author
brice · · 2026-08-04 10:54 UTC
committer
brice · · 2026-08-04 12:56 UTC
parent
5b4cea4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +4380 -30
M README.md +11 -6
@@ -19,7 +19,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
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 built-in tools (read, write, edit, bash, and lsp).
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 +142 -0
@@ -0,0 +1,142 @@
1+# LSP and linter support
2+
3+zot exposes a built-in `lsp` tool for code intelligence and diagnostics. It
4+communicates with stdio language servers through JSON-RPC and can also run
5+configured command-line linters without a shell.
6+
7+## Configuration
8+
9+zot loads JSON configuration from the following layers, in increasing
10+precedence:
11+
12+- `$ZOT_HOME/lsp.json`
13+- `<cwd>/.pi/lsp-bridge.json`, `<cwd>/.pi/lsp.json`
14+- `<cwd>/.omp/lsp.json`
15+- `<cwd>/pi-lsp-bridge.json`
16+- `<cwd>/lsp.json`, `<cwd>/.lsp.json`, and `<cwd>/.zot/lsp.json`
17+
18+A file may put providers below `servers`, `lspServers`, or `linters`, use a
19+flat provider map, or use the `providers` array from pi-lsp-bridge:
20+
21+```json
22+{
23+ "servers": {
24+ "gopls": {
25+ "command": "gopls",
26+ "fileTypes": [".go"],
27+ "rootMarkers": ["go.mod", "go.work"]
28+ },
29+ "eslint": {
30+ "kind": "cli",
31+ "command": "eslint",
32+ "args": ["--format", "json"],
33+ "parser": "eslint-json",
34+ "mode": "files",
35+ "fileTypes": [".js", ".jsx", ".ts", ".tsx"],
36+ "rootMarkers": ["eslint.config.js", ".eslintrc"]
37+ }
38+ }
39+}
40+```
41+
42+The pi-lsp-bridge array accepts built-in names and objects with `id`, `kind`,
43+`command`, `args`, selectors, root markers, and nested `lsp`/`cli` options.
44+Unknown bridge-specific metadata is ignored. Set `autoDetect` to `false` to
45+keep only providers named or defined by the configuration file.
46+
47+Server overrides merge onto zot's built-in definitions. Built-ins cover common
48+Go, Rust, TypeScript/JavaScript, Python, C/C++, YAML, JSON, and shell servers,
49+plus ESLint, golangci-lint, and Ruff CLI providers. A provider is selected
50+when it is enabled and its file type/root markers match the workspace. Command
51+lookup prefers project-local bin directories and then `PATH`; missing commands
52+are reported by `status` or the request result. Set `disabled` to `true` to
53+turn off a provider.
54+
55+Useful server fields are:
56+
57+| Field | Meaning |
58+| --- | --- |
59+| `kind` | `lsp` (default) or `cli` |
60+| `command`, `args` | Executable and arguments |
61+| `fileTypes` | Extensions, basenames, or language IDs handled by the provider |
62+| `languageId` | Optional LSP language ID sent on `textDocument/didOpen`; inferred when omitted |
63+| `rootMarkers` | Files identifying a workspace root |
64+| `isLinter` | LSP provider used for diagnostics, not navigation/refactors |
65+| `parser` | CLI output parser: `eslint-json`, `golangci-lint-json`, `ruff-json`, `sarif`, or generic |
66+| `mode` | CLI provider scope: `files` or `workspace` |
67+| `settings` | Values returned for `workspace/configuration` requests |
68+| `initializationOptions` | Values sent during LSP initialization |
69+| `env` | Extra environment variables for the child process |
70+
71+CLI providers may use `{files}` in an argument. It is replaced with the
72+project-relative paths touched by the diagnostics request. CLI output is capped
73+before parsing.
74+
75+## Tool actions
76+
77+The model calls `lsp` with an `action` and an optional `path`, `line`, `column`,
78+`server`, or `query`:
79+
80+- `diagnostics` — merge LSP and CLI diagnostics for a file, glob, or the whole workspace. `run_cli:false` skips command-line linters.
81+- `definition`, `type_definition`, `implementation`, `references`, `hover` — query a language server at a 1-based line and column.
82+- `symbols` — return document symbols, or workspace symbols when `query` is supplied and `path` is omitted or `*`.
83+- `rename` — request a `WorkspaceEdit`; applies it by default, or previews it with `apply:false`.
84+- `code_actions` — list actions, or apply returned edits with `apply:true`.
85+- `status` — show configured providers, running state, and diagnostic counts without starting servers.
86+- `reload` — stop cached LSP processes and reload configuration.
87+- `capabilities` — inspect initialize capabilities for one or all servers.
88+- `request` — send a raw JSON-RPC method with JSON `params`.
89+
90+`--no-lsp` disables the tool for one run. `--tools lsp` selects it when an
91+explicit tool list is used.
92+
93+## Diagnostics and context limits
94+
95+Diagnostics are deduplicated across providers using path, severity, code,
96+start position, and normalized message. Results are ordered by severity and
97+location. Repeated
98+identical messages are represented as one occurrence group with example paths
99+or lines. Cascades beneath missing-module errors are grouped as `SECONDARY`
100+items so the root import problem remains visible without repeating a large
101+error fan-out.
102+
103+The model-facing summary has a hard diagnostic cap (50 by default, with a
104+smaller eight-item summary used after writes and edits). Messages are
105+whitespace-compacted and clipped before formatting. Slow write-time checks are
106+bounded to a short two-second budget; a successful file write or edit is never
107+turned into a failed mutation because an LSP or linter is unavailable.
108+
109+Write diagnostics are enabled by default when LSP is enabled. Edit diagnostics
110+are disabled by default to keep multi-edit sequences compact. The persisted
111+configuration keys are `lsp_diagnostics_on_write` and
112+`lsp_diagnostics_on_edit` when an embedding needs to override those defaults.
113+Repeated post-write diagnostics are tracked per file by severity/code/message
114+identity, so a line shift does not replay the same error. Clearing a file's
115+diagnostics allows a later recurrence to be surfaced again.
116+
117+## Settings
118+
119+`/settings` provides two independent switches, both enabled by default:
120+
121+- **lsp in main session** (`lsp_enabled`) controls the main agent and applies
122+ immediately to the live tool registry.
123+- **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned swarm
124+ child processes.
125+
126+The sub-agent setting does not grant access to a child whose profile or
127+explicit tool list omits `lsp`. Both settings can also be controlled through
128+`$ZOT_HOME/config.json`.
129+
130+## Safety and lifecycle
131+
132+Language servers and linters run as local child processes. The `lsp` tool never
133+executes configured commands through a shell. Workspace edits returned by
134+rename and code actions are restricted to the session workspace, reject
135+resource operations, validate UTF-16 LSP positions, and write through a
136+temporary file before replacement. Server-initiated `workspace/applyEdit`
137+requests use the same checks.
138+
139+LSP clients are cached per workspace and provider for the lifetime of the zot
140+process. `reload` and process shutdown terminate cached children. Server
141+stderr is kept out of model-facing tool results; diagnostics and protocol
142+errors are returned through the established tool result channel.
M docs/subagents.md +2 -2
@@ -23,7 +23,7 @@ Profiles are read-only inputs. zot does not execute files from these directories
2323 ---
2424 name: reviewer
2525 description: Read-only code reviewer
26-tools: read, write, edit, bash
26+tools: read, write, edit, bash, lsp
2727 model: openai-codex/gpt-5.6-luna
2828 thinking: max
2929 systemPromptMode: replace
@@ -40,7 +40,7 @@ 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; 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`. |
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 +19 -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(), cfg.LSPDiagnosticsOnEditEnabled())
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,25 @@ 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 {
1058+ manager = lsp.NewManager()
1059+ }
10511060 all := map[string]core.Tool{
10521061 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1053- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1054- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1062+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1063+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
10551064 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
10561065 }
1066+ if manager != nil {
1067+ lspTool := tools.NewLSPTool(cwd, manager)
1068+ lspTool.Sandbox = sandbox
1069+ all["lsp"] = lspTool
1070+ }
10571071 reg := core.Registry{}
10581072 if len(args.Tools) == 0 {
10591073 for _, t := range all {
@@ -1070,7 +1084,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
10701084 }
10711085
10721086 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1073- order := []string{"read", "write", "edit", "bash"}
1087+ order := []string{"read", "write", "edit", "bash", "lsp"}
10741088 var out []ToolSummary
10751089 for _, name := range order {
10761090 if t, ok := reg[name]; ok {
M packages/agent/build_test.go +20 -0
@@ -9,9 +9,29 @@ 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+ if _, ok := registry["lsp"]; !ok {
20+ t.Fatal("default registry does not include lsp")
21+ }
22+ write, ok := registry["write"].(*tools.WriteTool)
23+ if !ok || write.LSP == nil || !write.LSPDiagnostics {
24+ t.Fatalf("write tool LSP wiring = %#v", write)
25+ }
26+ edit, ok := registry["edit"].(*tools.EditTool)
27+ if !ok || edit.LSP == nil || edit.LSPDiagnostics {
28+ t.Fatalf("edit tool LSP wiring = %#v", edit)
29+ }
30+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {
31+ t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))
32+ }
33+}
34+
1535 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
1636 root := t.TempDir()
1737 zotHome := filepath.Join(root, "zot-home")
M packages/agent/cli.go +31 -2
@@ -179,6 +179,16 @@ func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Age
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,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
648658 defer stopExt()
649659
650660 ag := r.NewAgent()
661+ defer closeAgentLSP(ag)
651662 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
652663 sess, err := openOrCreateSession(args, r, ag, version)
653664 if err != nil {
@@ -702,6 +713,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
702713 defer stopExt()
703714
704715 ag := r.NewAgent()
716+ defer closeAgentLSP(ag)
705717 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
706718 sess, err := openOrCreateSession(args, r, ag, version)
707719 if err != nil {
@@ -795,6 +807,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
795807 defer stopExt()
796808
797809 ag := r.NewAgent()
810+ defer closeAgentLSP(ag)
798811 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
799812 sess, err := openOrCreateSession(args, r, ag, version)
800813 if err != nil {
@@ -915,8 +928,10 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
915928 if mutateRegistry != nil {
916929 reg = mutateRegistry(reg)
917930 }
931+ oldTools := ag.Tools
918932 ag.SetTools(reg)
919933 ag.System = resolved.SystemPrompt
934+ _ = tools.CloseLSPManagers(oldTools)
920935 }
921936
922937 // reloadResourcesAfterStartupPre reloads extensions (if any) and
@@ -1152,6 +1167,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11521167 return a
11531168 }
11541169
1170+ var ag *core.Agent
11551171 buildAgent := func() (*core.Agent, string, string, error) {
11561172 resolved, err := Resolve(args, true)
11571173 if err != nil {
@@ -1209,7 +1225,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12091225 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
12101226 }
12111227
1212- var ag *core.Agent
12131228 if r.HasCredential() {
12141229 ag = wireAgentExt(r.NewAgent())
12151230 }
@@ -1321,6 +1336,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13211336 return a
13221337 }
13231338 wireAgentPersist(ag)
1339+ defer func() {
1340+ closeAgentLSP(liveInteractiveAgent(iv, ag))
1341+ closeResolvedLSP(r)
1342+ }()
13241343
13251344 // Re-wrap the build closures so any agent constructed by the TUI
13261345 // (login, /model swap to a different provider) also gets the
@@ -1601,6 +1620,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16011620 activeModel = newModel
16021621 persistMu.Unlock()
16031622 ag = newAg
1623+ closeAgentLSP(currentAg)
16041624
16051625 // Push the new state into the running Interactive.
16061626 if iv != nil {
@@ -1709,6 +1729,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17091729 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
17101730 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
17111731 FastMode: &fastMode,
1732+ LSPEnabled: initialCfg.LSPEnabled,
1733+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
17121734 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
17131735 JailByDefault: initialCfg.JailByDefault,
17141736 QuickModelShortcuts: quickModelShortcuts,
@@ -1766,6 +1788,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17661788 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
17671789 }
17681790 },
1791+ RefreshTools: func() {
1792+ current := liveInteractiveAgent(iv, ag)
1793+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1794+ },
17691795 AuthManager: mgr,
17701796 LlamaCPPConfig: ResolveLlamaCPPConfig,
17711797 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
@@ -1977,7 +2003,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19772003 if !ok {
19782004 return
19792005 }
1980- if finalAg := iv.Agent(); finalAg != nil {
2006+ finalAg := iv.Agent()
2007+ if finalAg != nil {
19812008 persistMu.Lock()
19822009 if sess != nil {
19832010 writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
@@ -1987,6 +2014,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19872014 }
19882015 persistMu.Unlock()
19892016 }
2017+ closeAgentLSP(finalAg)
2018+ closeResolvedLSP(r)
19902019 // Exit cleanly. Re-raising the signal would skip os.Exit's
19912020 // at-exit hooks; explicit exit is fine because we've already
19922021 // 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() bool {
230+ return c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite
231+}
232+
233+// LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics
234+// preference without requiring a generated config entry.
235+func (c Config) LSPDiagnosticsOnEditEnabled() bool {
236+ return c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit
237+}
238+
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 +659 -0
@@ -0,0 +1,659 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "sync/atomic"
16+ "time"
17+)
18+
19+type rpcError struct {
20+ Code int `json:"code"`
21+ Message string `json:"message"`
22+ Data any `json:"data,omitempty"`
23+}
24+
25+type rpcResponse struct {
26+ Result json.RawMessage
27+ Error *rpcError
28+}
29+
30+type rpcEnvelope struct {
31+ JSONRPC string `json:"jsonrpc"`
32+ ID json.RawMessage `json:"id"`
33+ Method string `json:"method"`
34+ Params json.RawMessage `json:"params"`
35+ Result json.RawMessage `json:"result"`
36+ Error *rpcError `json:"error"`
37+}
38+
39+// ClientOptions configures notifications and the small set of client-side
40+// handlers required by real language servers.
41+type ClientOptions struct {
42+ Settings map[string]any
43+ InitializationOptions any
44+ OnDiagnostics func([]Diagnostic)
45+ OnLog func(string)
46+ OnProgress func(string, json.RawMessage)
47+ ApplyEdit func(WorkspaceEdit) error
48+}
49+
50+// Client is one stdio JSON-RPC LSP process. A Client is safe for concurrent
51+// requests; the process itself is shared by all open documents in its root.
52+type Client struct {
53+ Spec ServerConfig
54+ Root string
55+ Command string
56+ cmd *exec.Cmd
57+ stdin io.WriteCloser
58+ stdout io.ReadCloser
59+ stderr io.ReadCloser
60+
61+ writeMu sync.Mutex
62+ mu sync.Mutex
63+ pending map[int64]chan rpcResponse
64+ nextID int64
65+ closed bool
66+ closeOnce sync.Once
67+ done chan struct{}
68+ doneOnce sync.Once
69+ readErr atomic.Value // string; one concrete type keeps atomic.Value safe
70+
71+ options ClientOptions
72+ capMu sync.RWMutex
73+ caps json.RawMessage
74+ docsMu sync.Mutex
75+ docs map[string]int
76+}
77+
78+// NewClient starts a stdio server but does not initialize it. This separation
79+// is convenient for tests and lets callers choose an initialization timeout.
80+func NewClient(spec ServerConfig, root string) (*Client, error) {
81+ return NewClientWithOptions(spec, root, ClientOptions{})
82+}
83+
84+// NewClientWithOptions starts a client with notification callbacks installed.
85+func NewClientWithOptions(spec ServerConfig, root string, options ClientOptions) (*Client, error) {
86+ if strings.EqualFold(spec.Kind, "cli") {
87+ return nil, fmt.Errorf("%s is a CLI provider, not an LSP server", spec.ID)
88+ }
89+ root, err := filepath.Abs(root)
90+ if err != nil {
91+ return nil, err
92+ }
93+ command, err := ResolveCommand(root, spec.Command)
94+ if err != nil {
95+ return nil, fmt.Errorf("%s: %w", spec.ID, err)
96+ }
97+ cmd := exec.Command(command, spec.Args...)
98+ cmd.Dir = root
99+ cmd.Env = mergedEnvironment(spec.Env)
100+ stdin, err := cmd.StdinPipe()
101+ if err != nil {
102+ return nil, fmt.Errorf("LSP stdin: %w", err)
103+ }
104+ stdout, err := cmd.StdoutPipe()
105+ if err != nil {
106+ _ = stdin.Close()
107+ return nil, fmt.Errorf("LSP stdout: %w", err)
108+ }
109+ stderr, err := cmd.StderrPipe()
110+ if err != nil {
111+ _ = stdin.Close()
112+ _ = stdout.Close()
113+ return nil, fmt.Errorf("LSP stderr: %w", err)
114+ }
115+ if err := cmd.Start(); err != nil {
116+ _ = stdin.Close()
117+ _ = stdout.Close()
118+ _ = stderr.Close()
119+ return nil, fmt.Errorf("start %s: %w", spec.ID, err)
120+ }
121+ c := &Client{
122+ Spec: spec, Root: root, Command: command, cmd: cmd,
123+ stdin: stdin, stdout: stdout, stderr: stderr,
124+ pending: make(map[int64]chan rpcResponse), done: make(chan struct{}),
125+ options: options, docs: make(map[string]int),
126+ }
127+ go c.readLoop()
128+ go c.stderrLoop()
129+ return c, nil
130+}
131+
132+// NewClientWithContext starts and initializes a client.
133+func NewClientWithContext(ctx context.Context, spec ServerConfig, root string, options ClientOptions) (*Client, error) {
134+ client, err := NewClientWithOptions(spec, root, options)
135+ if err != nil {
136+ return nil, err
137+ }
138+ if err := client.Initialize(ctx); err != nil {
139+ _ = client.Close()
140+ return nil, err
141+ }
142+ return client, nil
143+}
144+
145+func (c *Client) Initialize(ctx context.Context) error {
146+ if ctx == nil {
147+ ctx = context.Background()
148+ }
149+ folders := []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}
150+ params := map[string]any{
151+ "processId": os.Getpid(),
152+ "rootUri": mustURI(c.Root),
153+ "workspaceFolders": folders,
154+ "capabilities": map[string]any{
155+ "workspace": map[string]any{
156+ "applyEdit": true,
157+ "workspaceFolders": true,
158+ "configuration": true,
159+ },
160+ "textDocument": map[string]any{
161+ "publishDiagnostics": map[string]any{"relatedInformation": true},
162+ },
163+ },
164+ "initializationOptions": c.options.InitializationOptions,
165+ "clientInfo": map[string]any{"name": "zot", "version": "lsp"},
166+ }
167+ result, err := c.Request(ctx, "initialize", params)
168+ if err != nil {
169+ return fmt.Errorf("initialize %s: %w", c.Spec.ID, err)
170+ }
171+ var response struct {
172+ Capabilities json.RawMessage `json:"capabilities"`
173+ }
174+ if err := json.Unmarshal(result, &response); err == nil {
175+ c.capMu.Lock()
176+ c.caps = append(c.caps[:0], response.Capabilities...)
177+ c.capMu.Unlock()
178+ }
179+ if err := c.Notify("initialized", map[string]any{}); err != nil {
180+ return err
181+ }
182+ return nil
183+}
184+
185+// Capabilities returns the initialize result's capabilities.
186+func (c *Client) Capabilities() json.RawMessage {
187+ c.capMu.RLock()
188+ defer c.capMu.RUnlock()
189+ return append(json.RawMessage(nil), c.caps...)
190+}
191+
192+// Request sends a raw JSON-RPC request and waits for its response.
193+func (c *Client) Request(ctx context.Context, method string, params any) (json.RawMessage, error) {
194+ if ctx == nil {
195+ ctx = context.Background()
196+ }
197+ id := atomic.AddInt64(&c.nextID, 1)
198+ responseCh := make(chan rpcResponse, 1)
199+ c.mu.Lock()
200+ if c.closed {
201+ c.mu.Unlock()
202+ return nil, errors.New("LSP client is closed")
203+ }
204+ c.pending[id] = responseCh
205+ c.mu.Unlock()
206+ request := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}
207+ if err := c.send(request); err != nil {
208+ c.mu.Lock()
209+ delete(c.pending, id)
210+ c.mu.Unlock()
211+ return nil, err
212+ }
213+ select {
214+ case response := <-responseCh:
215+ if response.Error != nil {
216+ return nil, fmt.Errorf("LSP %s (%d): %s", method, response.Error.Code, response.Error.Message)
217+ }
218+ return response.Result, nil
219+ case <-ctx.Done():
220+ c.mu.Lock()
221+ delete(c.pending, id)
222+ c.mu.Unlock()
223+ _ = c.Notify("$/cancelRequest", map[string]any{"id": id})
224+ return nil, ctx.Err()
225+ case <-c.done:
226+ return nil, c.processError()
227+ }
228+}
229+
230+// Notify sends a JSON-RPC notification.
231+func (c *Client) Notify(method string, params any) error {
232+ return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
233+}
234+
235+func (c *Client) send(message any) error {
236+ payload, err := json.Marshal(message)
237+ if err != nil {
238+ return err
239+ }
240+ c.writeMu.Lock()
241+ defer c.writeMu.Unlock()
242+ c.mu.Lock()
243+ closed := c.closed
244+ c.mu.Unlock()
245+ if closed {
246+ return errors.New("LSP client is closed")
247+ }
248+ if err := WriteMessage(c.stdin, payload); err != nil {
249+ return fmt.Errorf("write LSP message: %w", err)
250+ }
251+ return nil
252+}
253+
254+// DidOpen publishes a complete document. Callers which already opened the URI
255+// should use DidChange for subsequent full-content updates.
256+func (c *Client) DidOpen(path, languageID, text string) error {
257+ uri, err := pathToURI(path)
258+ if err != nil {
259+ return err
260+ }
261+ c.docsMu.Lock()
262+ version := c.docs[uri]
263+ if version == 0 {
264+ version = 1
265+ } else {
266+ version++
267+ }
268+ c.docs[uri] = version
269+ c.docsMu.Unlock()
270+ return c.Notify("textDocument/didOpen", map[string]any{
271+ "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text},
272+ })
273+}
274+
275+// DidChange sends a full-content change, which avoids version/range drift
276+// between zot and servers that use different incremental-sync policies.
277+func (c *Client) DidChange(path, text string) error {
278+ uri, err := pathToURI(path)
279+ if err != nil {
280+ return err
281+ }
282+ c.docsMu.Lock()
283+ version := c.docs[uri]
284+ if version == 0 {
285+ version = 1
286+ } else {
287+ version++
288+ }
289+ c.docs[uri] = version
290+ c.docsMu.Unlock()
291+ return c.Notify("textDocument/didChange", map[string]any{
292+ "textDocument": map[string]any{"uri": uri, "version": version},
293+ "contentChanges": []map[string]any{{"text": text}},
294+ })
295+}
296+
297+func (c *Client) DidSave(path, text string) error {
298+ uri, err := pathToURI(path)
299+ if err != nil {
300+ return err
301+ }
302+ params := map[string]any{"textDocument": map[string]any{"uri": uri}}
303+ if text != "" {
304+ params["text"] = text
305+ }
306+ return c.Notify("textDocument/didSave", params)
307+}
308+
309+func (c *Client) readLoop() {
310+ reader := bufio.NewReaderSize(c.stdout, 64*1024)
311+ for {
312+ payload, err := ReadMessage(reader)
313+ if err != nil {
314+ if !errors.Is(err, io.EOF) {
315+ c.readErr.Store(err.Error())
316+ }
317+ c.failPending(err)
318+ c.signalDone()
319+ return
320+ }
321+ var envelope rpcEnvelope
322+ if err := json.Unmarshal(payload, &envelope); err != nil {
323+ c.readErr.Store(fmt.Sprintf("decode LSP message: %v", err))
324+ continue
325+ }
326+ if envelope.Method == "" && len(envelope.ID) > 0 {
327+ var id int64
328+ if json.Unmarshal(envelope.ID, &id) == nil {
329+ c.mu.Lock()
330+ channel := c.pending[id]
331+ delete(c.pending, id)
332+ c.mu.Unlock()
333+ if channel != nil {
334+ channel <- rpcResponse{Result: envelope.Result, Error: envelope.Error}
335+ }
336+ }
337+ continue
338+ }
339+ if envelope.Method != "" {
340+ // Diagnostics are ordered state: process publications on the
341+ // reader goroutine so a rapid clear/update sequence cannot be
342+ // reordered by notification handlers. Server requests may
343+ // block while they wait for an edit response, so those remain
344+ // asynchronous.
345+ if envelope.Method == "textDocument/publishDiagnostics" {
346+ c.handleServerMessage(envelope)
347+ } else {
348+ go c.handleServerMessage(envelope)
349+ }
350+ }
351+ }
352+}
353+
354+func (c *Client) handleServerMessage(message rpcEnvelope) {
355+ switch message.Method {
356+ case "textDocument/publishDiagnostics":
357+ c.handleDiagnostics(message.Params)
358+ return
359+ case "$/progress":
360+ if c.options.OnProgress != nil {
361+ c.options.OnProgress(message.Method, message.Params)
362+ }
363+ return
364+ case "window/logMessage", "window/showMessage", "telemetry/event":
365+ if c.options.OnLog != nil {
366+ var value struct {
367+ Message string `json:"message"`
368+ }
369+ _ = json.Unmarshal(message.Params, &value)
370+ c.options.OnLog(value.Message)
371+ }
372+ if len(message.ID) == 0 || string(message.ID) == "null" {
373+ return
374+ }
375+ _ = c.respond(message.ID, nil, nil)
376+ return
377+ case "workspace/configuration":
378+ _ = c.respond(message.ID, c.configuration(message.Params), nil)
379+ case "workspace/workspaceFolders":
380+ _ = c.respond(message.ID, []map[string]any{{"uri": mustURI(c.Root), "name": filepath.Base(c.Root)}}, nil)
381+ case "workspace/applyEdit":
382+ var params struct {
383+ Edit WorkspaceEdit `json:"edit"`
384+ }
385+ if err := json.Unmarshal(message.Params, &params); err != nil {
386+ _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil)
387+ return
388+ }
389+ err := c.applyEdit(params.Edit)
390+ result := map[string]any{"applied": err == nil}
391+ if err != nil {
392+ result["failureReason"] = err.Error()
393+ }
394+ _ = c.respond(message.ID, result, nil)
395+ case "client/registerCapability", "window/workDoneProgress/create":
396+ _ = c.respond(message.ID, map[string]any{}, nil)
397+ default:
398+ if len(message.ID) > 0 && string(message.ID) != "null" {
399+ _ = c.respond(message.ID, nil, &rpcError{Code: -32601, Message: "method not supported"})
400+ }
401+ }
402+}
403+
404+func (c *Client) configuration(params json.RawMessage) []any {
405+ var request struct {
406+ Items []struct {
407+ Section string `json:"section"`
408+ } `json:"items"`
409+ }
410+ _ = json.Unmarshal(params, &request)
411+ out := make([]any, len(request.Items))
412+ for i, item := range request.Items {
413+ if item.Section == "" {
414+ out[i] = c.options.Settings
415+ continue
416+ }
417+ out[i] = lookupSetting(c.options.Settings, item.Section)
418+ }
419+ return out
420+}
421+
422+func lookupSetting(settings map[string]any, section string) any {
423+ if settings == nil {
424+ return nil
425+ }
426+ if value, ok := settings[section]; ok {
427+ return value
428+ }
429+ var value any = settings
430+ for _, part := range strings.Split(section, ".") {
431+ object, ok := value.(map[string]any)
432+ if !ok {
433+ return nil
434+ }
435+ value, ok = object[part]
436+ if !ok {
437+ return nil
438+ }
439+ }
440+ return value
441+}
442+
443+func (c *Client) applyEdit(edit WorkspaceEdit) error {
444+ if c.options.ApplyEdit != nil {
445+ return c.options.ApplyEdit(edit)
446+ }
447+ return ApplyWorkspaceEdit(c.Root, edit)
448+}
449+
450+func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error {
451+ message := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(id)}
452+ if rpcErr != nil {
453+ message["error"] = rpcErr
454+ } else {
455+ message["result"] = result
456+ }
457+ return c.send(message)
458+}
459+
460+func (c *Client) handleDiagnostics(params json.RawMessage) {
461+ var value struct {
462+ URI string `json:"uri"`
463+ Diagnostics []Diagnostic `json:"diagnostics"`
464+ }
465+ if err := json.Unmarshal(params, &value); err != nil {
466+ return
467+ }
468+ path, err := uriToPath(value.URI)
469+ if err != nil {
470+ return
471+ }
472+ for i := range value.Diagnostics {
473+ value.Diagnostics[i].Path = path
474+ value.Diagnostics[i].Server = c.Spec.ID
475+ }
476+ if len(value.Diagnostics) == 0 {
477+ // Preserve the URI on a clear so Manager can remove only this file's
478+ // snapshot rather than accidentally retaining stale diagnostics.
479+ value.Diagnostics = []Diagnostic{{Path: path, Server: c.Spec.ID}}
480+ }
481+ if c.options.OnDiagnostics != nil {
482+ c.options.OnDiagnostics(value.Diagnostics)
483+ }
484+}
485+
486+func (c *Client) stderrLoop() {
487+ if c.stderr == nil {
488+ return
489+ }
490+ var output limitedBuffer
491+ output.limit = 1 << 20
492+ _, _ = io.Copy(&output, c.stderr)
493+ if output.Len() > 0 && c.options.OnLog != nil {
494+ c.options.OnLog(strings.TrimSpace(output.String()))
495+ }
496+}
497+
498+func (c *Client) failPending(err error) {
499+ c.mu.Lock()
500+ defer c.mu.Unlock()
501+ for id, channel := range c.pending {
502+ delete(c.pending, id)
503+ channel <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}}
504+ }
505+}
506+
507+func (c *Client) processError() error {
508+ if err, ok := c.readErr.Load().(string); ok && err != "" {
509+ return fmt.Errorf("LSP process stopped: %s", err)
510+ }
511+ return errors.New("LSP process stopped")
512+}
513+
514+func (c *Client) signalDone() { c.doneOnce.Do(func() { close(c.done) }) }
515+
516+// Close stops the process and releases all pipes. It is safe to call more
517+// than once and is also used by Manager.Reload.
518+func (c *Client) Close() error {
519+ var waitErr error
520+ c.closeOnce.Do(func() {
521+ // Give a cooperative server a short shutdown window before killing
522+ // the process. Close remains bounded for servers that do not support
523+ // shutdown or are already wedged.
524+ if c.cmd.ProcessState == nil {
525+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
526+ _, _ = c.Request(shutdownCtx, "shutdown", nil)
527+ cancel()
528+ _ = c.Notify("exit", nil)
529+ }
530+ // Serialize the closed transition with send so a writer that has
531+ // passed its closed check cannot race stdin.Close.
532+ c.writeMu.Lock()
533+ c.mu.Lock()
534+ c.closed = true
535+ c.mu.Unlock()
536+ c.signalDone()
537+ _ = c.stdin.Close()
538+ c.writeMu.Unlock()
539+ killed := false
540+ if c.cmd.Process != nil && c.cmd.ProcessState == nil {
541+ killed = c.cmd.Process.Kill() == nil
542+ }
543+ waitErr = c.cmd.Wait()
544+ if killed {
545+ // The kill was part of normal cleanup, not a failed operation.
546+ waitErr = nil
547+ }
548+ _ = c.stdout.Close()
549+ _ = c.stderr.Close()
550+ c.failPending(errors.New("LSP client closed"))
551+ })
552+ return waitErr
553+}
554+
555+func mustURI(path string) string {
556+ uri, _ := pathToURI(path)
557+ return uri
558+}
559+
560+func mergedEnvironment(overrides map[string]string) []string {
561+ env := os.Environ()
562+ if len(overrides) == 0 {
563+ return env
564+ }
565+ index := make(map[string]int, len(env))
566+ for i, item := range env {
567+ if key, _, ok := strings.Cut(item, "="); ok {
568+ index[key] = i
569+ }
570+ }
571+ for key, value := range overrides {
572+ item := key + "=" + value
573+ if i, ok := index[key]; ok {
574+ env[i] = item
575+ } else {
576+ env = append(env, item)
577+ }
578+ }
579+ return env
580+}
581+
582+// ResolveCommand searches workspace-local tool directories before PATH.
583+func ResolveCommand(cwd, command string) (string, error) {
584+ if strings.TrimSpace(command) == "" {
585+ return "", errors.New("empty command")
586+ }
587+ cwd, _ = filepath.Abs(cwd)
588+ candidates := []string{}
589+ if filepath.IsAbs(command) || strings.ContainsAny(command, `/\\`) {
590+ if filepath.IsAbs(command) {
591+ candidates = append(candidates, command)
592+ } else {
593+ candidates = append(candidates, filepath.Join(cwd, command))
594+ }
595+ } else {
596+ // A model often runs in a package subdirectory while the project's
597+ // executable lives at the repository root. Search each workspace
598+ // ancestor before falling back to PATH.
599+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
600+ for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), "bin"} {
601+ candidates = append(candidates, filepath.Join(dir, local, command))
602+ }
603+ next := filepath.Dir(dir)
604+ if next == dir {
605+ break
606+ }
607+ }
608+ }
609+ for _, candidate := range candidates {
610+ for _, path := range executableVariants(candidate) {
611+ if isRunnable(path) {
612+ return path, nil
613+ }
614+ }
615+ }
616+ if path, err := exec.LookPath(command); err == nil {
617+ return path, nil
618+ }
619+ return "", fmt.Errorf("command %q not found in node_modules/.bin, .venv/bin, bin, or PATH", command)
620+}
621+
622+func executableVariants(path string) []string {
623+ if filepath.Ext(path) != "" {
624+ return []string{path}
625+ }
626+ return []string{path, path + ".exe", path + ".cmd", path + ".bat"}
627+}
628+
629+func isRunnable(path string) bool {
630+ info, err := os.Stat(path)
631+ if err != nil || info.IsDir() {
632+ return false
633+ }
634+ if info.Mode()&0111 != 0 || filepath.Ext(path) == ".cmd" || filepath.Ext(path) == ".bat" || filepath.Ext(path) == ".exe" {
635+ return true
636+ }
637+ return false
638+}
639+
640+// WaitForDiagnostics gives asynchronous LSP servers a bounded opportunity to
641+// publish diagnostics after didOpen/didChange.
642+func (c *Client) WaitForDiagnostics(ctx context.Context, delay time.Duration) error {
643+ if ctx == nil {
644+ ctx = context.Background()
645+ }
646+ if delay <= 0 {
647+ return nil
648+ }
649+ timer := time.NewTimer(delay)
650+ defer timer.Stop()
651+ select {
652+ case <-timer.C:
653+ return nil
654+ case <-ctx.Done():
655+ return ctx.Err()
656+ case <-c.done:
657+ return c.processError()
658+ }
659+}
A packages/agent/lsp/config.go +654 -0
@@ -0,0 +1,654 @@
1+// Package lsp implements a small, dependency-free LSP and linter backend.
2+package lsp
3+
4+import (
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// ServerConfig describes either a stdio LSP server or a command-line linter.
14+// Command is resolved relative to the workspace before it is started.
15+type ServerConfig struct {
16+ ID string `json:"-"`
17+ Kind string `json:"kind,omitempty"` // "lsp" or "cli"
18+ Command string `json:"command"`
19+ Args []string `json:"args,omitempty"`
20+ FileTypes []string `json:"fileTypes,omitempty"`
21+ LanguageID string `json:"languageId,omitempty"`
22+ RootMarkers []string `json:"rootMarkers,omitempty"`
23+ IsLinter bool `json:"isLinter,omitempty"`
24+ Disabled bool `json:"disabled,omitempty"`
25+ Parser string `json:"parser,omitempty"`
26+ Mode string `json:"mode,omitempty"` // files or workspace for CLI linters
27+ RunOnStartup bool `json:"runOnStartup,omitempty"`
28+ RunOnChange bool `json:"runOnChange,omitempty"`
29+ Description string `json:"description,omitempty"`
30+ Env map[string]string `json:"env,omitempty"`
31+ Settings map[string]any `json:"settings,omitempty"`
32+ InitializationOptions any `json:"initializationOptions,omitempty"`
33+}
34+
35+// Config is the merged global/project LSP configuration.
36+type Config struct {
37+ Servers map[string]ServerConfig
38+ AutoDetect bool
39+ // Explicit contains servers named or defined by a config file. When
40+ // auto-detection is disabled, only these configured providers remain
41+ // eligible, matching pi-lsp-bridge's providers/autoDetect behavior.
42+ Explicit map[string]bool
43+ Files []string
44+}
45+
46+// BuiltinServers returns the built-in language servers. The slice is a copy
47+// and may be modified by callers.
48+func BuiltinServers() []ServerConfig {
49+ return []ServerConfig{
50+ {ID: "gopls", Kind: "lsp", Command: "gopls", FileTypes: []string{"go", ".go"}, RootMarkers: []string{"go.work", "go.mod"}, Description: "Go language server"},
51+ {ID: "rust-analyzer", Kind: "lsp", Command: "rust-analyzer", FileTypes: []string{"rust", "rs", ".rs"}, RootMarkers: []string{"Cargo.toml"}, Description: "Rust language server"},
52+ {ID: "typescript-language-server", Kind: "lsp", Command: "typescript-language-server", Args: []string{"--stdio"}, FileTypes: []string{"typescript", "typescriptreact", "javascript", "javascriptreact", "ts", "tsx", "js", "jsx", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}, RootMarkers: []string{"tsconfig.json", "jsconfig.json", "package.json"}, Description: "TypeScript and JavaScript language server"},
53+ {ID: "pyright-langserver", Kind: "lsp", Command: "pyright-langserver", Args: []string{"--stdio"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"}, Description: "Python language server"},
54+ {ID: "clangd", Kind: "lsp", Command: "clangd", FileTypes: []string{"c", "cpp", "c++", "h", "hpp", "cc", "cpp", "cxx", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"}, RootMarkers: []string{"compile_commands.json", "compile_flags.txt", "CMakeLists.txt"}, Description: "C and C++ language server"},
55+ {ID: "yaml-language-server", Kind: "lsp", Command: "yaml-language-server", Args: []string{"--stdio"}, FileTypes: []string{"yaml", "yml", ".yaml", ".yml"}, Description: "YAML language server"},
56+ {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"},
57+ {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"},
58+ {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"},
59+ {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--out-format", "json"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"},
60+ {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"},
61+ }
62+}
63+
64+// LoadConfig reads the global and project lsp.json files. Project files are
65+// applied after the global file; within a project, lsp.json, .lsp.json, and
66+// .zot/lsp.json are applied in that order. Missing files are harmless.
67+func LoadConfig(cwd string) (Config, error) {
68+ cwd, err := absDir(cwd)
69+ if err != nil {
70+ return Config{}, err
71+ }
72+ var files []string
73+ if home := zotHome(); home != "" {
74+ files = append(files, filepath.Join(home, "lsp.json"))
75+ }
76+ files = append(files,
77+ filepath.Join(cwd, ".pi", "lsp-bridge.json"),
78+ filepath.Join(cwd, ".pi", "lsp.json"),
79+ filepath.Join(cwd, ".omp", "lsp.json"),
80+ filepath.Join(cwd, "pi-lsp-bridge.json"),
81+ filepath.Join(cwd, "lsp.json"),
82+ filepath.Join(cwd, ".lsp.json"),
83+ filepath.Join(cwd, ".zot", "lsp.json"),
84+ )
85+
86+ objects := make(map[string]map[string]json.RawMessage)
87+ explicit := make(map[string]bool)
88+ autoDetect := true
89+ for _, path := range files {
90+ data, _ := os.ReadFile(path)
91+ if len(data) > 0 {
92+ var metadata struct {
93+ AutoDetect *bool `json:"autoDetect"`
94+ }
95+ if json.Unmarshal(data, &metadata) == nil && metadata.AutoDetect != nil {
96+ autoDetect = *metadata.AutoDetect
97+ }
98+ }
99+ entries, exists, err := readConfigFile(path)
100+ if err != nil {
101+ return Config{}, err
102+ }
103+ if !exists {
104+ continue
105+ }
106+ for id := range entries {
107+ explicit[id] = true
108+ }
109+ for id := range providerIDs(data) {
110+ explicit[id] = true
111+ }
112+ for id, entry := range entries {
113+ if old := objects[id]; old != nil {
114+ objects[id] = mergeRawObjects(old, entry)
115+ } else {
116+ objects[id] = cloneRawObject(entry)
117+ }
118+ }
119+ }
120+
121+ merged := make(map[string]ServerConfig)
122+ for _, server := range BuiltinServers() {
123+ merged[server.ID] = server
124+ }
125+ for id, raw := range objects {
126+ server, err := decodeServer(id, raw)
127+ if err != nil {
128+ return Config{}, fmt.Errorf("lsp server %q: %w", id, err)
129+ }
130+ if base, ok := merged[id]; ok {
131+ // Decode the already-merged JSON over the built-in by marshaling the
132+ // built-in first. This retains fields that were omitted in the file.
133+ baseRaw, _ := json.Marshal(base)
134+ baseMap := map[string]json.RawMessage{}
135+ _ = json.Unmarshal(baseRaw, &baseMap)
136+ mergedRaw := mergeRawObjects(baseMap, raw)
137+ server, err = decodeServer(id, mergedRaw)
138+ if err != nil {
139+ return Config{}, fmt.Errorf("lsp server %q: %w", id, err)
140+ }
141+ // A parser/mode is an explicit CLI declaration even when the
142+ // overridden built-in was an LSP. This makes {"parser":"sarif"}
143+ // sufficient for a linter override.
144+ if _, hasKind := raw["kind"]; !hasKind {
145+ if _, hasParser := raw["parser"]; hasParser {
146+ server.Kind = "cli"
147+ }
148+ }
149+ }
150+ merged[id] = server
151+ }
152+ for id, server := range merged {
153+ if server.ID == "" {
154+ server.ID = id
155+ merged[id] = server
156+ }
157+ }
158+ return Config{Servers: merged, AutoDetect: autoDetect, Explicit: explicit, Files: files}, nil
159+}
160+
161+// LoadServers is a convenience for callers that only need auto-detected
162+// server configurations.
163+func LoadServers(cwd string) ([]ServerConfig, error) {
164+ config, err := LoadConfig(cwd)
165+ if err != nil {
166+ return nil, err
167+ }
168+ return config.EffectiveServers(cwd), nil
169+}
170+
171+// EffectiveServers returns enabled servers which are relevant to any file in
172+// the workspace. It is useful for status UIs; Manager uses ApplicableServers
173+// for a particular file.
174+func (c Config) EffectiveServers(cwd string) []ServerConfig {
175+ var out []ServerConfig
176+ for _, server := range c.Servers {
177+ if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) {
178+ continue
179+ }
180+ if len(server.FileTypes) == 0 && len(server.RootMarkers) == 0 {
181+ out = append(out, server)
182+ continue
183+ }
184+ if hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes) {
185+ out = append(out, server)
186+ }
187+ }
188+ sortServers(out)
189+ return out
190+}
191+
192+// ApplicableServers selects servers for path. An empty path means the cwd.
193+func (c Config) ApplicableServers(cwd, path string) []ServerConfig {
194+ var out []ServerConfig
195+ for _, server := range c.Servers {
196+ if server.Disabled || (!c.AutoDetect && !c.Explicit[server.ID]) || !serverMatchesPath(cwd, path, server) {
197+ continue
198+ }
199+ out = append(out, server)
200+ }
201+ sortServers(out)
202+ return out
203+}
204+
205+func serverMatchesPath(cwd, path string, server ServerConfig) bool {
206+ if len(server.FileTypes) == 0 {
207+ return len(server.RootMarkers) == 0 || hasRootMarker(cwd, server.RootMarkers)
208+ }
209+ if path != "" {
210+ if len(server.RootMarkers) > 0 && !hasRootMarker(cwd, server.RootMarkers) {
211+ return false
212+ }
213+ ext := strings.ToLower(filepath.Ext(path))
214+ base := strings.ToLower(filepath.Base(path))
215+ lang := languageForExtension(ext)
216+ for _, typ := range server.FileTypes {
217+ typ = strings.ToLower(strings.TrimSpace(typ))
218+ if typ == ext || typ == "."+strings.TrimPrefix(ext, ".") || typ == base || typ == lang || strings.TrimPrefix(typ, ".") == strings.TrimPrefix(ext, ".") {
219+ return true
220+ }
221+ if strings.HasPrefix(typ, "*.") && strings.HasSuffix(base, strings.TrimPrefix(typ, "*")) {
222+ return true
223+ }
224+ }
225+ return false
226+ }
227+ return hasRootMarker(cwd, server.RootMarkers) || hasMatchingFile(cwd, server.FileTypes)
228+}
229+
230+func hasMatchingFile(cwd string, types []string) bool {
231+ var found bool
232+ _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error {
233+ if err != nil || found {
234+ return filepath.SkipDir
235+ }
236+ if entry.IsDir() {
237+ if path != cwd && (entry.Name() == ".git" || entry.Name() == "node_modules" || entry.Name() == ".venv") {
238+ return filepath.SkipDir
239+ }
240+ return nil
241+ }
242+ found = serverMatchesPath(cwd, path, ServerConfig{FileTypes: types})
243+ return nil
244+ })
245+ return found
246+}
247+
248+func hasRootMarker(cwd string, markers []string) bool {
249+ if len(markers) == 0 {
250+ return false
251+ }
252+ cwd, _ = absDir(cwd)
253+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
254+ for _, marker := range markers {
255+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
256+ return true
257+ }
258+ }
259+ next := filepath.Dir(dir)
260+ if next == dir {
261+ break
262+ }
263+ }
264+ return false
265+}
266+
267+// FindRoot resolves a server's workspace root by walking up from cwd and
268+// choosing the nearest directory containing one of its root markers.
269+func FindRoot(cwd string, markers []string) string {
270+ cwd, err := absDir(cwd)
271+ if err != nil {
272+ return cwd
273+ }
274+ for dir := cwd; dir != ""; dir = filepath.Dir(dir) {
275+ for _, marker := range markers {
276+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
277+ return dir
278+ }
279+ }
280+ next := filepath.Dir(dir)
281+ if next == dir {
282+ break
283+ }
284+ }
285+ return cwd
286+}
287+
288+func sortServers(servers []ServerConfig) {
289+ rank := func(server ServerConfig) int {
290+ if server.Kind == "lsp" && !server.IsLinter {
291+ return 0
292+ }
293+ if server.Kind == "lsp" {
294+ return 1
295+ }
296+ return 2
297+ }
298+ for i := 1; i < len(servers); i++ {
299+ for j := i; j > 0; j-- {
300+ left, right := servers[j-1], servers[j]
301+ if rank(left) < rank(right) || (rank(left) == rank(right) && left.ID <= right.ID) {
302+ break
303+ }
304+ servers[j-1], servers[j] = right, left
305+ }
306+ }
307+}
308+
309+func absDir(cwd string) (string, error) {
310+ if strings.TrimSpace(cwd) == "" {
311+ cwd, _ = os.Getwd()
312+ }
313+ abs, err := filepath.Abs(cwd)
314+ if err != nil {
315+ return "", err
316+ }
317+ info, err := os.Stat(abs)
318+ if err == nil && !info.IsDir() {
319+ return "", fmt.Errorf("workspace %q is not a directory", cwd)
320+ }
321+ return filepath.Clean(abs), nil
322+}
323+
324+func zotHome() string {
325+ if value := strings.TrimSpace(os.Getenv("ZOT_HOME")); value != "" {
326+ return value
327+ }
328+ if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" {
329+ return filepath.Join(value, "zot")
330+ }
331+ home, _ := os.UserHomeDir()
332+ if home == "" {
333+ return ""
334+ }
335+ return filepath.Join(home, ".local", "state", "zot")
336+}
337+
338+func providerIDs(data []byte) map[string]struct{} {
339+ ids := make(map[string]struct{})
340+ var top struct {
341+ Providers []json.RawMessage `json:"providers"`
342+ }
343+ if json.Unmarshal(data, &top) != nil {
344+ return ids
345+ }
346+ for _, raw := range top.Providers {
347+ var id string
348+ if json.Unmarshal(raw, &id) == nil {
349+ if id = strings.TrimSpace(id); id != "" {
350+ ids[id] = struct{}{}
351+ }
352+ continue
353+ }
354+ var object struct {
355+ ID string `json:"id"`
356+ }
357+ if json.Unmarshal(raw, &object) == nil {
358+ if id = strings.TrimSpace(object.ID); id != "" {
359+ ids[id] = struct{}{}
360+ }
361+ }
362+ }
363+ return ids
364+}
365+
366+func readConfigFile(path string) (map[string]map[string]json.RawMessage, bool, error) {
367+ data, err := os.ReadFile(path)
368+ if errors.Is(err, os.ErrNotExist) {
369+ return nil, false, nil
370+ }
371+ if err != nil {
372+ return nil, true, err
373+ }
374+ var top map[string]json.RawMessage
375+ if err := json.Unmarshal(data, &top); err != nil {
376+ return nil, true, fmt.Errorf("read %s: %w", path, err)
377+ }
378+ root := top
379+ if raw, ok := top["providers"]; ok {
380+ // pi-lsp-bridge uses a providers array. Preserve its compact
381+ // string entries (built-ins are already in the default registry)
382+ // and turn object entries into the map shape used internally.
383+ var providers []json.RawMessage
384+ if err := json.Unmarshal(raw, &providers); err != nil {
385+ return nil, true, fmt.Errorf("%s.providers must be an array: %w", path, err)
386+ }
387+ root = make(map[string]json.RawMessage)
388+ for index, provider := range providers {
389+ var id string
390+ if json.Unmarshal(provider, &id) == nil {
391+ continue
392+ }
393+ var object map[string]json.RawMessage
394+ if err := json.Unmarshal(provider, &object); err != nil {
395+ return nil, true, fmt.Errorf("%s.providers[%d] must be a string or object: %w", path, index, err)
396+ }
397+ idRaw, ok := object["id"]
398+ if !ok || json.Unmarshal(idRaw, &id) != nil || strings.TrimSpace(id) == "" {
399+ return nil, true, fmt.Errorf("%s.providers[%d] is missing id", path, index)
400+ }
401+ id = strings.TrimSpace(id)
402+ delete(object, "id")
403+ flattenProviderObject(object)
404+ root[id] = mustJSON(object)
405+ }
406+ } else {
407+ for _, key := range []string{"servers", "lspServers", "linters"} {
408+ if raw, ok := top[key]; ok {
409+ var wrapped map[string]json.RawMessage
410+ if err := json.Unmarshal(raw, &wrapped); err != nil {
411+ return nil, true, fmt.Errorf("%s.%s must be an object: %w", path, key, err)
412+ }
413+ root = wrapped
414+ break
415+ }
416+ }
417+ }
418+ out := make(map[string]map[string]json.RawMessage)
419+ for id, raw := range root {
420+ if id == "$schema" || id == "servers" || id == "lspServers" || id == "linters" || id == "providers" || id == "autoDetect" || id == "version" || id == "debug" || id == "status" || id == "lifecycle" || id == "excludePaths" || id == "idleTimeoutMs" {
421+ continue
422+ }
423+ var object map[string]json.RawMessage
424+ if err := json.Unmarshal(raw, &object); err != nil {
425+ // A string is a convenient shorthand for a command.
426+ var command string
427+ if json.Unmarshal(raw, &command) == nil {
428+ object = map[string]json.RawMessage{"command": json.RawMessage(strconvQuote(command))}
429+ } else {
430+ return nil, true, fmt.Errorf("%s.%s must be an object", path, id)
431+ }
432+ }
433+ out[id] = object
434+ }
435+ return out, true, nil
436+}
437+
438+func strconvQuote(s string) string {
439+ b, _ := json.Marshal(s)
440+ return string(b)
441+}
442+
443+// flattenProviderObject adapts pi-lsp-bridge's nested provider shape to the
444+// flat ServerConfig fields used by zot. Unknown provider metadata remains
445+// harmless JSON and is ignored by decodeServer.
446+func flattenProviderObject(object map[string]json.RawMessage) {
447+ if _, exists := object["command"]; !exists {
448+ var candidates []struct {
449+ Command string `json:"command"`
450+ Args []string `json:"args"`
451+ }
452+ if raw, ok := object["commandCandidates"]; ok && json.Unmarshal(raw, &candidates) == nil && len(candidates) > 0 && candidates[0].Command != "" {
453+ object["command"] = mustJSON(candidates[0].Command)
454+ if len(candidates[0].Args) > 0 {
455+ object["args"] = mustJSON(candidates[0].Args)
456+ }
457+ }
458+ }
459+ if _, exists := object["kind"]; !exists {
460+ if _, ok := object["cli"]; ok {
461+ object["kind"] = mustJSON("cli")
462+ } else if _, ok := object["lsp"]; ok {
463+ object["kind"] = mustJSON("lsp")
464+ }
465+ }
466+ if _, exists := object["fileTypes"]; !exists {
467+ var selectors []struct {
468+ LanguageID string `json:"languageId"`
469+ Extensions []string `json:"extensions"`
470+ Filenames []string `json:"filenames"`
471+ }
472+ if raw, ok := object["selectors"]; ok && json.Unmarshal(raw, &selectors) == nil {
473+ var fileTypes []string
474+ for _, selector := range selectors {
475+ if selector.LanguageID != "" {
476+ fileTypes = append(fileTypes, selector.LanguageID)
477+ }
478+ fileTypes = append(fileTypes, selector.Extensions...)
479+ fileTypes = append(fileTypes, selector.Filenames...)
480+ }
481+ if len(fileTypes) > 0 {
482+ object["fileTypes"] = mustJSON(uniqueStrings(fileTypes))
483+ }
484+ }
485+ if _, exists := object["fileTypes"]; !exists {
486+ var detect struct {
487+ FileExtensions []string `json:"fileExtensionsAny"`
488+ }
489+ if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil && len(detect.FileExtensions) > 0 {
490+ object["fileTypes"] = mustJSON(uniqueStrings(detect.FileExtensions))
491+ }
492+ }
493+ }
494+ if _, exists := object["rootMarkers"]; !exists {
495+ var detect struct {
496+ RepoFiles []string `json:"repoFilesAny"`
497+ ConfigFiles []string `json:"configFilesAny"`
498+ }
499+ if raw, ok := object["detect"]; ok && json.Unmarshal(raw, &detect) == nil {
500+ markers := append(append([]string{}, detect.RepoFiles...), detect.ConfigFiles...)
501+ if len(markers) > 0 {
502+ object["rootMarkers"] = mustJSON(uniqueStrings(markers))
503+ }
504+ }
505+ if _, exists := object["rootMarkers"]; !exists {
506+ if raw, ok := object["configMarkers"]; ok {
507+ object["rootMarkers"] = append(json.RawMessage(nil), raw...)
508+ }
509+ }
510+ }
511+ if raw, ok := object["lsp"]; ok {
512+ var nested map[string]json.RawMessage
513+ if json.Unmarshal(raw, &nested) == nil {
514+ if _, exists := object["initializationOptions"]; !exists {
515+ if value, ok := nested["initializationOptions"]; ok {
516+ object["initializationOptions"] = append(json.RawMessage(nil), value...)
517+ }
518+ }
519+ }
520+ }
521+ if raw, ok := object["cli"]; ok {
522+ var nested map[string]json.RawMessage
523+ if json.Unmarshal(raw, &nested) == nil {
524+ for _, key := range []string{"parser", "mode", "runOnStartup", "runOnChange"} {
525+ if _, exists := object[key]; exists {
526+ continue
527+ }
528+ if value, ok := nested[key]; ok {
529+ object[key] = append(json.RawMessage(nil), value...)
530+ }
531+ }
532+ }
533+ }
534+}
535+
536+func uniqueStrings(values []string) []string {
537+ seen := make(map[string]struct{}, len(values))
538+ out := make([]string, 0, len(values))
539+ for _, value := range values {
540+ if strings.TrimSpace(value) == "" {
541+ continue
542+ }
543+ if _, exists := seen[value]; exists {
544+ continue
545+ }
546+ seen[value] = struct{}{}
547+ out = append(out, value)
548+ }
549+ return out
550+}
551+
552+func decodeServer(id string, raw map[string]json.RawMessage) (ServerConfig, error) {
553+ if _, exists := raw["initializationOptions"]; !exists {
554+ if value, ok := raw["initOptions"]; ok {
555+ raw["initializationOptions"] = append(json.RawMessage(nil), value...)
556+ }
557+ }
558+ if command, ok := raw["command"]; ok {
559+ var list []string
560+ if err := json.Unmarshal(command, &list); err == nil {
561+ if len(list) == 0 {
562+ return ServerConfig{}, fmt.Errorf("command array is empty")
563+ }
564+ raw["command"] = json.RawMessage(strconvQuote(list[0]))
565+ if len(list) > 1 {
566+ args, _ := json.Marshal(list[1:])
567+ if _, exists := raw["args"]; !exists {
568+ raw["args"] = args
569+ }
570+ }
571+ }
572+ }
573+ data, err := json.Marshal(raw)
574+ if err != nil {
575+ return ServerConfig{}, err
576+ }
577+ var server ServerConfig
578+ if err := json.Unmarshal(data, &server); err != nil {
579+ return ServerConfig{}, err
580+ }
581+ server.ID = id
582+ if server.Kind == "" {
583+ if server.Parser != "" || server.Mode != "" {
584+ server.Kind = "cli"
585+ } else {
586+ server.Kind = "lsp"
587+ }
588+ }
589+ server.Kind = strings.ToLower(server.Kind)
590+ if server.Kind != "lsp" && server.Kind != "cli" {
591+ return ServerConfig{}, fmt.Errorf("kind must be lsp or cli")
592+ }
593+ if server.Kind == "cli" && server.Parser == "" {
594+ server.Parser = "generic"
595+ }
596+ return server, nil
597+}
598+
599+func cloneRawObject(in map[string]json.RawMessage) map[string]json.RawMessage {
600+ out := make(map[string]json.RawMessage, len(in))
601+ for key, value := range in {
602+ out[key] = append(json.RawMessage(nil), value...)
603+ }
604+ return out
605+}
606+
607+func mergeRawObjects(base, override map[string]json.RawMessage) map[string]json.RawMessage {
608+ out := cloneRawObject(base)
609+ for key, value := range override {
610+ var baseObject, overrideObject map[string]json.RawMessage
611+ if json.Unmarshal(out[key], &baseObject) == nil && json.Unmarshal(value, &overrideObject) == nil {
612+ out[key] = mustJSON(mergeRawObjects(baseObject, overrideObject))
613+ continue
614+ }
615+ out[key] = append(json.RawMessage(nil), value...)
616+ }
617+ return out
618+}
619+
620+func mustJSON(value any) json.RawMessage {
621+ data, _ := json.Marshal(value)
622+ return data
623+}
624+
625+func languageForExtension(ext string) string {
626+ switch strings.ToLower(ext) {
627+ case ".go":
628+ return "go"
629+ case ".rs":
630+ return "rust"
631+ case ".py":
632+ return "python"
633+ case ".ts":
634+ return "typescript"
635+ case ".tsx":
636+ return "typescriptreact"
637+ case ".js", ".mjs", ".cjs":
638+ return "javascript"
639+ case ".jsx":
640+ return "javascriptreact"
641+ case ".c", ".h":
642+ return "c"
643+ case ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx":
644+ return "cpp"
645+ case ".yaml", ".yml":
646+ return "yaml"
647+ case ".json", ".jsonc":
648+ return "json"
649+ case ".sh", ".bash", ".zsh":
650+ return "shellscript"
651+ default:
652+ return ""
653+ }
654+}
A packages/agent/lsp/diagnostics.go +335 -0
@@ -0,0 +1,335 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+)
10+
11+const DefaultDiagnosticCap = 50
12+
13+var missingModulePatterns = []*regexp.Regexp{
14+ regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`),
15+ regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`),
16+ regexp.MustCompile(`(?i)cannot resolve ["']?([^"' ]+)["']?`),
17+ regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`),
18+}
19+
20+// DiagnosticKey is stable across providers. Server/source are intentionally
21+// excluded so an LSP and a CLI linter reporting the same issue collapse.
22+func DiagnosticKey(d Diagnostic) string {
23+ // Match the bridge's merge key: the start location is stable enough to
24+ // identify one report while providers may disagree on the end range.
25+ return strings.Join([]string{d.Path, defaultSeverity(d.Severity), d.Code,
26+ fmt.Sprintf("%d:%d", d.Range.Start.Line, d.Range.Start.Character), compactWhitespace(d.Message)}, "\x00")
27+}
28+
29+func diagnosticIssueKey(d Diagnostic) string {
30+ return strings.Join([]string{defaultSeverity(d.Severity), d.Code, compactWhitespace(d.Message)}, "\x00")
31+}
32+
33+// DiagnosticIdentity identifies the issue independently of its current
34+// location. It is used by write/edit diagnostics to avoid replaying the same
35+// error after a harmless line shift while still re-surfacing it after removal.
36+func DiagnosticIdentity(d Diagnostic) string {
37+ return diagnosticIssueKey(d)
38+}
39+
40+// DeduplicateDiagnostics removes duplicates across servers and sorts errors,
41+// warnings, info, and hints in that order, then path and location.
42+func DeduplicateDiagnostics(input []Diagnostic) []Diagnostic {
43+ seen := make(map[string]Diagnostic, len(input))
44+ for _, diagnostic := range input {
45+ key := DiagnosticKey(diagnostic)
46+ if old, exists := seen[key]; !exists || old.Server == "" {
47+ seen[key] = diagnostic
48+ }
49+ }
50+ out := make([]Diagnostic, 0, len(seen))
51+ for _, diagnostic := range seen {
52+ out = append(out, diagnostic)
53+ }
54+ sort.SliceStable(out, func(i, j int) bool {
55+ if severityRank(out[i].Severity) != severityRank(out[j].Severity) {
56+ return severityRank(out[i].Severity) < severityRank(out[j].Severity)
57+ }
58+ if out[i].Path != out[j].Path {
59+ return out[i].Path < out[j].Path
60+ }
61+ if out[i].Range.Start.Line != out[j].Range.Start.Line {
62+ return out[i].Range.Start.Line < out[j].Range.Start.Line
63+ }
64+ if out[i].Range.Start.Character != out[j].Range.Start.Character {
65+ return out[i].Range.Start.Character < out[j].Range.Start.Character
66+ }
67+ if out[i].Message != out[j].Message {
68+ return out[i].Message < out[j].Message
69+ }
70+ return out[i].Code < out[j].Code
71+ })
72+ return out
73+}
74+
75+func severityRank(value string) int {
76+ switch strings.ToLower(value) {
77+ case "error":
78+ return 0
79+ case "warning":
80+ return 1
81+ case "info":
82+ return 2
83+ case "hint":
84+ return 3
85+ default:
86+ return 2
87+ }
88+}
89+
90+// SummarizeDiagnostics renders a bounded diagnostic summary. Repeated issues
91+// are represented once with occurrence/file counts; module-resolution errors
92+// retain their root issue while grouping the cascade of secondary errors.
93+func SummarizeDiagnostics(input []Diagnostic, root string, cap int) string {
94+ if cap <= 0 {
95+ cap = DefaultDiagnosticCap
96+ }
97+ diagnostics := DeduplicateDiagnostics(input)
98+ if len(diagnostics) == 0 {
99+ return "No diagnostics."
100+ }
101+ counts := [4]int{}
102+ for _, diagnostic := range diagnostics {
103+ switch severityRank(diagnostic.Severity) {
104+ case 0:
105+ counts[0]++
106+ case 1:
107+ counts[1]++
108+ case 2:
109+ counts[2]++
110+ case 3:
111+ counts[3]++
112+ }
113+ }
114+ parts := make([]string, 0, 4)
115+ for i, label := range []string{"error", "warning", "info", "hint"} {
116+ if counts[i] > 0 {
117+ parts = append(parts, fmt.Sprintf("%d %s%s", counts[i], label, plural(counts[i])))
118+ }
119+ }
120+ lines := []string{fmt.Sprintf("Diagnostics: %s (deduplicated %d)", strings.Join(parts, ", "), len(diagnostics))}
121+ secondary := moduleSecondary(diagnostics)
122+ groups := make(map[string][]Diagnostic)
123+ for _, diagnostic := range diagnostics {
124+ if secondary[DiagnosticKey(diagnostic)] {
125+ continue
126+ }
127+ key := diagnosticIssueKey(diagnostic)
128+ groups[key] = append(groups[key], diagnostic)
129+ }
130+ emitted := make(map[string]bool)
131+ emittedSecondary := make(map[string]bool)
132+ displayed := 0
133+ for _, diagnostic := range diagnostics {
134+ if displayed >= cap {
135+ break
136+ }
137+ if secondary[DiagnosticKey(diagnostic)] {
138+ continue
139+ }
140+ groupKey := diagnosticIssueKey(diagnostic)
141+ bucket := groups[groupKey]
142+ if len(bucket) >= 3 || uniquePaths(bucket) >= 2 {
143+ if emitted[groupKey] {
144+ continue
145+ }
146+ emitted[groupKey] = true
147+ lines = append(lines, formatRepeated(bucket, root))
148+ displayed += len(bucket)
149+ } else {
150+ lines = append(lines, "- "+FormatDiagnostic(diagnostic, root))
151+ displayed++
152+ }
153+ if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] {
154+ if secondaryLine := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" {
155+ lines = append(lines, "- "+secondaryLine)
156+ emittedSecondary[diagnostic.Path] = true
157+ displayed++
158+ }
159+ }
160+ }
161+ omitted := len(diagnostics) - minInt(displayed, len(diagnostics))
162+ if omitted > 0 {
163+ lines = append(lines, fmt.Sprintf("... truncated; %d diagnostics omitted (hard cap %d)", omitted, cap))
164+ }
165+ return strings.Join(lines, "\n")
166+}
167+
168+func formatRepeated(bucket []Diagnostic, root string) string {
169+ paths := make(map[string]bool)
170+ lines := make(map[int]bool)
171+ for _, diagnostic := range bucket {
172+ paths[diagnostic.Path] = true
173+ lines[diagnostic.Range.Start.Line+1] = true
174+ }
175+ example := make([]string, 0, 3)
176+ for _, diagnostic := range bucket {
177+ path := diagnostic.Path
178+ if root != "" {
179+ if rel, err := filepathRel(root, path); err == nil {
180+ path = rel
181+ }
182+ }
183+ if !contains(example, path) && len(example) < 3 {
184+ example = append(example, path)
185+ }
186+ }
187+ extra := ""
188+ if len(example) > 0 {
189+ extra = fmt.Sprintf(" (examples: %s)", strings.Join(example, ", "))
190+ }
191+ code := ""
192+ if bucket[0].Code != "" {
193+ code = " [" + bucket[0].Code + "]"
194+ }
195+ where := fmt.Sprintf("%d occurrences across %d files", len(bucket), len(paths))
196+ if len(paths) == 1 {
197+ where = fmt.Sprintf("%d occurrences in %s", len(bucket), example[0])
198+ }
199+ if len(paths) == 1 && len(lines) > 0 {
200+ where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3))
201+ }
202+ message := compactWhitespace(bucket[0].Message)
203+ if len(message) > 220 {
204+ message = message[:219] + "…"
205+ }
206+ return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra)
207+}
208+
209+func moduleSecondary(diagnostics []Diagnostic) map[string]bool {
210+ roots := make(map[string]bool)
211+ byFile := make(map[string][]Diagnostic)
212+ for _, diagnostic := range diagnostics {
213+ byFile[diagnostic.Path] = append(byFile[diagnostic.Path], diagnostic)
214+ if missingModuleName(diagnostic) != "" {
215+ roots[diagnostic.Path] = true
216+ }
217+ }
218+ secondary := make(map[string]bool)
219+ for path, fileDiagnostics := range byFile {
220+ if !roots[path] {
221+ continue
222+ }
223+ count := 0
224+ for _, diagnostic := range fileDiagnostics {
225+ if missingModuleName(diagnostic) == "" {
226+ count++
227+ }
228+ }
229+ if count < 2 {
230+ continue
231+ }
232+ for _, diagnostic := range fileDiagnostics {
233+ if missingModuleName(diagnostic) == "" {
234+ secondary[DiagnosticKey(diagnostic)] = true
235+ }
236+ }
237+ }
238+ return secondary
239+}
240+
241+func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) string {
242+ count := 0
243+ modules := make(map[string]bool)
244+ for _, diagnostic := range diagnostics {
245+ if diagnostic.Path == path && secondary[DiagnosticKey(diagnostic)] {
246+ count++
247+ }
248+ if diagnostic.Path == path && missingModuleName(diagnostic) != "" {
249+ modules[missingModuleName(diagnostic)] = true
250+ }
251+ }
252+ if count == 0 {
253+ return ""
254+ }
255+ shownPath := path
256+ if root != "" {
257+ if rel, err := filepathRel(root, path); err == nil {
258+ shownPath = rel
259+ }
260+ }
261+ moduleNames := make([]string, 0, len(modules))
262+ for name := range modules {
263+ moduleNames = append(moduleNames, name)
264+ }
265+ sort.Strings(moduleNames)
266+ return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", "))
267+}
268+
269+func missingModuleName(diagnostic Diagnostic) string {
270+ if strings.ToLower(diagnostic.Severity) != "error" {
271+ return ""
272+ }
273+ for _, pattern := range missingModulePatterns {
274+ if match := pattern.FindStringSubmatch(diagnostic.Message); len(match) > 1 {
275+ return strings.Trim(match[1], `"'`)
276+ }
277+ }
278+ return ""
279+}
280+
281+func uniquePaths(diagnostics []Diagnostic) int {
282+ paths := make(map[string]bool)
283+ for _, diagnostic := range diagnostics {
284+ paths[diagnostic.Path] = true
285+ }
286+ return len(paths)
287+}
288+
289+func plural(n int) string {
290+ if n == 1 {
291+ return ""
292+ }
293+ return "s"
294+}
295+func minInt(a, b int) int {
296+ if a < b {
297+ return a
298+ }
299+ return b
300+}
301+func contains(values []string, value string) bool {
302+ for _, item := range values {
303+ if item == value {
304+ return true
305+ }
306+ }
307+ return false
308+}
309+func joinInts(values map[int]bool, max int) string {
310+ ordered := make([]int, 0, len(values))
311+ for value := range values {
312+ ordered = append(ordered, value)
313+ }
314+ sort.Ints(ordered)
315+ if len(ordered) > max {
316+ ordered = ordered[:max]
317+ }
318+ parts := make([]string, len(ordered))
319+ for i, value := range ordered {
320+ parts[i] = fmt.Sprint(value)
321+ }
322+ return strings.Join(parts, ", ")
323+}
324+func filepathRel(root, path string) (string, error) {
325+ // Kept here rather than importing agent/tools: lsp is intentionally
326+ // independent of the tool package.
327+ rel, err := filepath.Rel(root, path)
328+ if err != nil {
329+ return "", err
330+ }
331+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
332+ return path, nil
333+ }
334+ return filepath.ToSlash(rel), nil
335+}
A packages/agent/lsp/edits.go +195 -0
@@ -0,0 +1,195 @@
1+package lsp
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+)
11+
12+const maxWorkspaceEditFileBytes = 8 * 1024 * 1024
13+
14+// ApplyWorkspaceEdit applies only text edits inside root. It rejects create,
15+// rename, delete, non-file URIs, symlink escapes, malformed ranges, and files
16+// larger than the bounded safety limit.
17+func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error {
18+ root, err := filepath.Abs(root)
19+ if err != nil {
20+ return err
21+ }
22+ rootReal, err := filepath.EvalSymlinks(root)
23+ if err != nil {
24+ return fmt.Errorf("workspace root: %w", err)
25+ }
26+ all := make(map[string][]TextEdit, len(edit.Changes)+len(edit.DocumentChanges))
27+ for uri, changes := range edit.Changes {
28+ all[uri] = append(all[uri], changes...)
29+ }
30+ for _, raw := range edit.DocumentChanges {
31+ var change struct {
32+ TextDocument struct {
33+ URI string `json:"uri"`
34+ } `json:"textDocument"`
35+ Edits []TextEdit `json:"edits"`
36+ Kind string `json:"kind"`
37+ }
38+ if err := json.Unmarshal(raw, &change); err != nil {
39+ return fmt.Errorf("workspace edit document change: %w", err)
40+ }
41+ if change.Kind != "" || change.TextDocument.URI == "" {
42+ return fmt.Errorf("workspace edit contains an unsupported resource operation")
43+ }
44+ all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...)
45+ }
46+ for uri, changes := range all {
47+ path, err := uriToPath(uri)
48+ if err != nil {
49+ return err
50+ }
51+ if err := safeWorkspacePath(rootReal, path); err != nil {
52+ return err
53+ }
54+ if err := applyTextEdits(path, changes); err != nil {
55+ return fmt.Errorf("apply edit to %s: %w", path, err)
56+ }
57+ }
58+ return nil
59+}
60+
61+func safeWorkspacePath(root, path string) error {
62+ abs, err := filepath.Abs(path)
63+ if err != nil {
64+ return err
65+ }
66+ candidate := abs
67+ if real, err := filepath.EvalSymlinks(abs); err == nil {
68+ candidate = real
69+ } else {
70+ parent := filepath.Dir(abs)
71+ if real, parentErr := filepath.EvalSymlinks(parent); parentErr == nil {
72+ candidate = filepath.Join(real, filepath.Base(abs))
73+ }
74+ }
75+ rel, err := filepath.Rel(root, candidate)
76+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
77+ return fmt.Errorf("workspace edit path %q is outside workspace", path)
78+ }
79+ return nil
80+}
81+
82+type byteEdit struct {
83+ start int
84+ end int
85+ text string
86+}
87+
88+func applyTextEdits(path string, edits []TextEdit) error {
89+ if len(edits) == 0 {
90+ return nil
91+ }
92+ info, err := os.Stat(path)
93+ if err != nil {
94+ return err
95+ }
96+ if info.Size() > maxWorkspaceEditFileBytes {
97+ return fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes)
98+ }
99+ data, err := os.ReadFile(path)
100+ if err != nil {
101+ return err
102+ }
103+ converted := make([]byteEdit, 0, len(edits))
104+ for _, edit := range edits {
105+ start, err := positionOffset(data, edit.Range.Start)
106+ if err != nil {
107+ return err
108+ }
109+ end, err := positionOffset(data, edit.Range.End)
110+ if err != nil {
111+ return err
112+ }
113+ if end < start {
114+ return fmt.Errorf("edit range ends before it starts")
115+ }
116+ converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText})
117+ }
118+ sort.Slice(converted, func(i, j int) bool {
119+ if converted[i].start != converted[j].start {
120+ return converted[i].start > converted[j].start
121+ }
122+ return converted[i].end > converted[j].end
123+ })
124+ for i := 1; i < len(converted); i++ {
125+ if converted[i].end > converted[i-1].start {
126+ return fmt.Errorf("workspace text edits overlap")
127+ }
128+ }
129+ out := append([]byte(nil), data...)
130+ for _, edit := range converted {
131+ out = append(append(append([]byte(nil), out[:edit.start]...), []byte(edit.text)...), out[edit.end:]...)
132+ }
133+ tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*")
134+ if err != nil {
135+ return err
136+ }
137+ tmpName := tmp.Name()
138+ defer func() { _ = os.Remove(tmpName) }()
139+ if err := tmp.Chmod(info.Mode().Perm()); err != nil {
140+ _ = tmp.Close()
141+ return err
142+ }
143+ if _, err := tmp.Write(out); err != nil {
144+ _ = tmp.Close()
145+ return err
146+ }
147+ if err := tmp.Sync(); err != nil {
148+ _ = tmp.Close()
149+ return err
150+ }
151+ if err := tmp.Close(); err != nil {
152+ return err
153+ }
154+ return os.Rename(tmpName, path)
155+}
156+
157+func positionOffset(data []byte, position Position) (int, error) {
158+ if position.Line < 0 || position.Character < 0 {
159+ return 0, fmt.Errorf("invalid LSP position %d:%d", position.Line, position.Character)
160+ }
161+ lineStart := 0
162+ line := 0
163+ for line < position.Line {
164+ next := strings.IndexByte(string(data[lineStart:]), '\n')
165+ if next < 0 {
166+ return 0, fmt.Errorf("LSP line %d is outside file", position.Line)
167+ }
168+ lineStart += next + 1
169+ line++
170+ }
171+ lineEnd := len(data)
172+ if next := strings.IndexByte(string(data[lineStart:]), '\n'); next >= 0 {
173+ lineEnd = lineStart + next
174+ }
175+ lineBytes := data[lineStart:lineEnd]
176+ if len(lineBytes) > 0 && lineBytes[len(lineBytes)-1] == '\r' {
177+ lineBytes = lineBytes[:len(lineBytes)-1]
178+ lineEnd--
179+ }
180+ units := 0
181+ for offset, r := range string(lineBytes) {
182+ if units == position.Character {
183+ return lineStart + offset, nil
184+ }
185+ if r > 0xffff {
186+ units += 2
187+ } else {
188+ units++
189+ }
190+ }
191+ if units == position.Character {
192+ return lineEnd, nil
193+ }
194+ return 0, fmt.Errorf("LSP character %d is outside line %d", position.Character, position.Line)
195+}
A packages/agent/lsp/framing.go +77 -0
@@ -0,0 +1,77 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "strconv"
10+ "strings"
11+)
12+
13+const maxMessageBytes = 32 * 1024 * 1024
14+
15+// WriteMessage writes one LSP Content-Length framed JSON-RPC message.
16+func WriteMessage(w io.Writer, payload []byte) error {
17+ if len(payload) > maxMessageBytes {
18+ return fmt.Errorf("LSP message is too large: %d bytes", len(payload))
19+ }
20+ header := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload)))
21+ if n, err := w.Write(header); err != nil {
22+ return err
23+ } else if n != len(header) {
24+ return io.ErrShortWrite
25+ }
26+ n, err := w.Write(payload)
27+ if err == nil && n != len(payload) {
28+ return io.ErrShortWrite
29+ }
30+ return err
31+}
32+
33+// ReadMessage reads one Content-Length framed LSP message. It accepts other
34+// headers (including Content-Type) and either CRLF or LF header endings.
35+func ReadMessage(r *bufio.Reader) ([]byte, error) {
36+ length := -1
37+ for {
38+ line, err := r.ReadString('\n')
39+ if err != nil {
40+ return nil, err
41+ }
42+ line = strings.TrimRight(line, "\r\n")
43+ if line == "" {
44+ break
45+ }
46+ key, value, ok := strings.Cut(line, ":")
47+ if !ok {
48+ return nil, fmt.Errorf("malformed LSP header %q", line)
49+ }
50+ if strings.EqualFold(strings.TrimSpace(key), "content-length") {
51+ n, err := strconv.Atoi(strings.TrimSpace(value))
52+ if err != nil || n < 0 {
53+ return nil, fmt.Errorf("invalid Content-Length %q", strings.TrimSpace(value))
54+ }
55+ length = n
56+ }
57+ }
58+ if length < 0 {
59+ return nil, errors.New("LSP message has no Content-Length")
60+ }
61+ if length > maxMessageBytes {
62+ return nil, fmt.Errorf("LSP message is too large: %d bytes", length)
63+ }
64+ payload := make([]byte, length)
65+ if _, err := io.ReadFull(r, payload); err != nil {
66+ return nil, err
67+ }
68+ return payload, nil
69+}
70+
71+// Frame is useful in tests and for callers which already have a complete
72+// message. It returns the exact wire representation.
73+func Frame(payload []byte) []byte {
74+ var b bytes.Buffer
75+ _ = WriteMessage(&b, payload)
76+ return b.Bytes()
77+}
A packages/agent/lsp/lsp_test.go +310 -0
@@ -0,0 +1,310 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+func TestFakeLSPProcess(t *testing.T) {
16+ if os.Getenv("ZOT_FAKE_LSP") != "1" {
17+ return
18+ }
19+ reader := bufio.NewReader(os.Stdin)
20+ for {
21+ payload, err := ReadMessage(reader)
22+ if err != nil {
23+ return
24+ }
25+ var message rpcEnvelope
26+ if json.Unmarshal(payload, &message) != nil {
27+ continue
28+ }
29+ switch message.Method {
30+ case "initialize":
31+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"capabilities":{"definitionProvider":true}}}`)
32+ _ = WriteMessage(os.Stdout, response)
33+ case "test/echo":
34+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"echo":true}}`)
35+ _ = WriteMessage(os.Stdout, response)
36+ case "shutdown":
37+ response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":null}`)
38+ _ = WriteMessage(os.Stdout, response)
39+ case "textDocument/didOpen":
40+ var params struct {
41+ TextDocument struct {
42+ URI string `json:"uri"`
43+ } `json:"textDocument"`
44+ }
45+ _ = json.Unmarshal(message.Params, &params)
46+ response := map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": map[string]any{"uri": params.TextDocument.URI, "diagnostics": []any{map[string]any{"range": map[string]any{"start": map[string]int{"line": 0, "character": 0}, "end": map[string]int{"line": 0, "character": 1}}, "severity": 1, "code": "FAKE", "message": "fake problem"}}}}
47+ data, _ := json.Marshal(response)
48+ _ = WriteMessage(os.Stdout, data)
49+ }
50+ }
51+}
52+
53+func TestClientFakeStdioRPC(t *testing.T) {
54+ root := t.TempDir()
55+ script := os.Args[0]
56+ spec := ServerConfig{ID: "fake", Kind: "lsp", Command: script, Args: []string{"-test.run=TestFakeLSPProcess"}, Env: map[string]string{"ZOT_FAKE_LSP": "1"}}
57+ diagnostics := make(chan []Diagnostic, 1)
58+ client, err := NewClientWithContext(context.Background(), spec, root, ClientOptions{OnDiagnostics: func(value []Diagnostic) { diagnostics <- value }})
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ t.Cleanup(func() { _ = client.Close() })
63+ result, err := client.Request(context.Background(), "test/echo", map[string]any{})
64+ if err != nil || !bytes.Contains(result, []byte(`"echo":true`)) {
65+ t.Fatalf("echo result = %s, err = %v", result, err)
66+ }
67+ path := filepath.Join(root, "main.go")
68+ if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+ if err := client.DidOpen(path, "go", "package main\n"); err != nil {
72+ t.Fatal(err)
73+ }
74+ select {
75+ case values := <-diagnostics:
76+ if len(values) != 1 || values[0].Code != "FAKE" || values[0].Path != path {
77+ t.Fatalf("diagnostics = %#v", values)
78+ }
79+ case <-time.After(2 * time.Second):
80+ t.Fatal("timed out waiting for fake diagnostics")
81+ }
82+}
83+
84+func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
85+ payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
86+ wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
87+ got, err := ReadMessage(bufio.NewReader(bytes.NewReader(wire)))
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ if !bytes.Equal(got, payload) {
92+ t.Fatalf("payload = %q, want %q", got, payload)
93+ }
94+}
95+
96+func TestLoadConfigMergesGlobalProjectAndBuiltins(t *testing.T) {
97+ root := t.TempDir()
98+ zotHome := t.TempDir()
99+ t.Setenv("ZOT_HOME", zotHome)
100+ if err := os.WriteFile(filepath.Join(zotHome, "lsp.json"), []byte(`{"servers":{"gopls":{"settings":{"global":true},"args":["--global"]}}}`), 0o644); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"gopls":{"command":"local-gopls","args":["--local"],"settings":{"local":true}},"sarif-lint":{"command":"reviewdog","kind":"cli","parser":"sarif","fileTypes":[".go"]}}`), 0o644); err != nil {
107+ t.Fatal(err)
108+ }
109+ config, err := LoadConfig(root)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ gopls := config.Servers["gopls"]
114+ if gopls.Command != "local-gopls" || len(gopls.Args) != 1 || gopls.Args[0] != "--local" {
115+ t.Fatalf("gopls override = %#v", gopls)
116+ }
117+ if len(gopls.FileTypes) == 0 || len(gopls.RootMarkers) == 0 {
118+ t.Fatalf("builtin selectors were not retained: %#v", gopls)
119+ }
120+ if gopls.Settings["global"] != true || gopls.Settings["local"] != true {
121+ t.Fatalf("settings did not merge: %#v", gopls.Settings)
122+ }
123+ if config.Servers["sarif-lint"].Kind != "cli" || config.Servers["sarif-lint"].Parser != "sarif" {
124+ t.Fatalf("custom linter = %#v", config.Servers["sarif-lint"])
125+ }
126+ selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
127+ var foundGopls, foundSarif bool
128+ for _, server := range selected {
129+ foundGopls = foundGopls || server.ID == "gopls"
130+ foundSarif = foundSarif || server.ID == "sarif-lint"
131+ }
132+ if !foundGopls || !foundSarif {
133+ t.Fatalf("selected servers = %#v", selected)
134+ }
135+}
136+
137+func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) {
138+ root := t.TempDir()
139+ zotHome := t.TempDir()
140+ t.Setenv("ZOT_HOME", zotHome)
141+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil {
142+ t.Fatal(err)
143+ }
144+ configFile := `{
145+ "$schema": "./node_modules/pi-lsp-bridge/schema.json",
146+ "autoDetect": true,
147+ "providers": [
148+ "gopls",
149+ {"id":"gopls","args":["serve"]},
150+ {"id":"bridge-lint","kind":"cli","command":"bridge-lint","fileTypes":[".go"],"rootMarkers":["go.mod"],"cli":{"parser":"generic","mode":"files"}}
151+ ]
152+ }`
153+ if err := os.WriteFile(filepath.Join(root, "pi-lsp-bridge.json"), []byte(configFile), 0o644); err != nil {
154+ t.Fatal(err)
155+ }
156+ config, err := LoadConfig(root)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if got := config.Servers["gopls"].Args; len(got) != 1 || got[0] != "serve" {
161+ t.Fatalf("gopls provider override = %#v", got)
162+ }
163+ lint := config.Servers["bridge-lint"]
164+ if lint.Kind != "cli" || lint.Parser != "generic" || lint.Mode != "files" {
165+ t.Fatalf("bridge provider = %#v", lint)
166+ }
167+}
168+
169+func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
170+ root := t.TempDir()
171+ t.Setenv("ZOT_HOME", t.TempDir())
172+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil {
173+ t.Fatal(err)
174+ }
175+ if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil {
176+ t.Fatal(err)
177+ }
178+ config, err := LoadConfig(root)
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
183+ if len(selected) != 1 || selected[0].ID != "gopls" {
184+ t.Fatalf("explicit providers with autoDetect=false = %#v", selected)
185+ }
186+}
187+
188+func TestResolveCommandPrefersWorkspaceTools(t *testing.T) {
189+ root := t.TempDir()
190+ bin := filepath.Join(root, "node_modules", ".bin")
191+ if err := os.MkdirAll(bin, 0o755); err != nil {
192+ t.Fatal(err)
193+ }
194+ command := filepath.Join(bin, "fake-lsp")
195+ if err := os.WriteFile(command, []byte("#!/bin/sh\n"), 0o755); err != nil {
196+ t.Fatal(err)
197+ }
198+ got, err := ResolveCommand(root, "fake-lsp")
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ if got != command {
203+ t.Fatalf("resolved %q, want %q", got, command)
204+ }
205+}
206+
207+func TestParsers(t *testing.T) {
208+ root := t.TempDir()
209+ eslint := `[ {"filePath":"src/a.js","messages":[{"ruleId":"no-x","severity":2,"message":"bad","line":2,"column":3}] } ]`
210+ got := ParseDiagnostics("eslint-json", "eslint", root, eslint)
211+ if len(got) != 1 || got[0].Severity != "error" || got[0].Range.Start.Line != 1 {
212+ t.Fatalf("eslint = %#v", got)
213+ }
214+ golangci := `{"Issues":[{"FromLinter":"govet","Text":"bad","Severity":"warning","Pos":{"Filename":"a.go","Line":3,"Column":4}}]}`
215+ if got = ParseDiagnostics("golangci-lint-json", "golangci", root, golangci); len(got) != 1 || got[0].Severity != "warning" {
216+ t.Fatalf("golangci = %#v", got)
217+ }
218+ ruff := `[{"code":"F401","message":"unused","filename":"a.py","location":{"row":1,"column":2}}]`
219+ if got = ParseDiagnostics("ruff-json", "ruff", root, ruff); len(got) != 1 || got[0].Code != "F401" {
220+ t.Fatalf("ruff = %#v", got)
221+ }
222+ sarif := `{"runs":[{"results":[{"ruleId":"R1","level":"error","message":{"text":"bad"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"a.c"},"region":{"startLine":4,"startColumn":2}}}]}]}]}`
223+ if got = ParseDiagnostics("sarif", "scan", root, sarif); len(got) != 1 || got[0].Range.Start.Line != 3 {
224+ t.Fatalf("sarif = %#v", got)
225+ }
226+ if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" {
227+ t.Fatalf("generic = %#v", got)
228+ }
229+}
230+
231+func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) {
232+ path := filepath.Join(t.TempDir(), "main.go")
233+ base := Diagnostic{Path: path, Severity: "error", Code: "E1", Message: "same", Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 1, Character: 3}}}
234+ endChanged := base
235+ endChanged.Range.End.Character = 20
236+ otherCode := base
237+ otherCode.Code = "E2"
238+ otherStart := base
239+ otherStart.Range.Start.Line++
240+ if got := DeduplicateDiagnostics([]Diagnostic{base, endChanged, otherCode, otherStart}); len(got) != 3 {
241+ t.Fatalf("deduplicated diagnostics = %#v, want three reports", got)
242+ }
243+}
244+
245+func TestDiagnosticsDedupAndGrouping(t *testing.T) {
246+ root := t.TempDir()
247+ makeDiag := func(path, message string, line int, server string) Diagnostic {
248+ return Diagnostic{Path: filepath.Join(root, path), Severity: "error", Code: "E", Message: message, Server: server, Range: Range{Start: Position{Line: line}}}
249+ }
250+ input := []Diagnostic{makeDiag("a.go", "same", 0, "gopls"), makeDiag("a.go", "same", 0, "eslint"), makeDiag("a.go", `Cannot find module "missing"`, 1, "gopls"), makeDiag("a.go", "cascade one", 2, "gopls"), makeDiag("a.go", "cascade two", 3, "gopls"), makeDiag("b.go", "repeat", 1, "a"), makeDiag("c.go", "repeat", 2, "b"), makeDiag("d.go", "repeat", 3, "c")}
251+ if got := DeduplicateDiagnostics(input); len(got) != len(input)-1 {
252+ t.Fatalf("dedup count = %d", len(got))
253+ }
254+ text := SummarizeDiagnostics(input, root, 50)
255+ if !strings.Contains(text, "repeated") || !strings.Contains(text, "SECONDARY") {
256+ t.Fatalf("summary lacks grouping:\n%s", text)
257+ }
258+}
259+
260+func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) {
261+ root := t.TempDir()
262+ path := filepath.Join(root, "a.txt")
263+ if err := os.WriteFile(path, []byte("a😀b\n"), 0o644); err != nil {
264+ t.Fatal(err)
265+ }
266+ uri := URIForPath(path)
267+ ed := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: Range{Start: Position{Line: 0, Character: 1}, End: Position{Line: 0, Character: 3}}, NewText: "X"}}}}
268+ if err := ApplyWorkspaceEdit(root, ed); err != nil {
269+ t.Fatal(err)
270+ }
271+ data, _ := os.ReadFile(path)
272+ if string(data) != "aXb\n" {
273+ t.Fatalf("edited data = %q", data)
274+ }
275+ outside := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(filepath.Join(filepath.Dir(root), "outside.txt")): {{Range: Range{}, NewText: "x"}}}}
276+ if err := ApplyWorkspaceEdit(root, outside); err == nil {
277+ t.Fatal("outside edit was accepted")
278+ }
279+}
280+
281+func TestDiagnosticJSONCodeNumber(t *testing.T) {
282+ var diagnostic Diagnostic
283+ if err := json.Unmarshal([]byte(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"code":123,"message":"warning"}`), &diagnostic); err != nil {
284+ t.Fatal(err)
285+ }
286+ if diagnostic.Code != "123" || diagnostic.Severity != "warning" {
287+ t.Fatalf("diagnostic = %#v", diagnostic)
288+ }
289+}
290+
291+func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) {
292+ manager := NewManager()
293+ root := t.TempDir()
294+ path := filepath.Join(root, "main.go")
295+ first := Diagnostic{Path: path, Severity: "error", Message: "type mismatch", Range: Range{Start: Position{Line: 1, Character: 2}}}
296+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{first}); len(got) != 1 {
297+ t.Fatalf("first reduction returned %d diagnostics, want 1", len(got))
298+ }
299+ shifted := first
300+ shifted.Range.Start.Line = 20
301+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 0 {
302+ t.Fatalf("shifted diagnostic was replayed: %#v", got)
303+ }
304+ if got := manager.ReduceDiagnostics(root, path, nil); len(got) != 0 {
305+ t.Fatalf("clear returned %#v", got)
306+ }
307+ if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 1 {
308+ t.Fatalf("diagnostic did not reappear after clear: %#v", got)
309+ }
310+}
A packages/agent/lsp/manager.go +783 -0
@@ -0,0 +1,783 @@
1+package lsp
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+ "time"
15+)
16+
17+// ManagerOptions controls process and command timeouts.
18+type ManagerOptions struct {
19+ RequestTimeout time.Duration
20+ DiagnosticDelay time.Duration
21+ LinterTimeout time.Duration
22+ Settings map[string]any
23+ OnLog func(server, message string)
24+}
25+
26+// Response is one raw request result tagged with the server which produced
27+// it. Errors are retained so a partially installed workspace remains useful.
28+type Response struct {
29+ Server string
30+ Result json.RawMessage
31+ Error error
32+}
33+
34+type workspace struct {
35+ cwd string
36+ config Config
37+ clients map[string]*Client
38+ starting map[string]chan struct{}
39+ statuses map[string]string
40+ errors map[string]string
41+ diagnostics map[string]map[string][]Diagnostic
42+ cliDiagnostics map[string][]Diagnostic
43+ opened map[string]map[string]string // server -> path -> content hash
44+ surfaced map[string]map[string]bool // path -> location-independent diagnostic identities
45+ logs []string
46+}
47+
48+// Manager owns per-workspace LSP processes. A process is cached by cwd and
49+// server ID until Close or Reload, so a series of model calls does not spawn a
50+// new language server for every request.
51+type Manager struct {
52+ mu sync.Mutex
53+ workspaces map[string]*workspace
54+ options ManagerOptions
55+}
56+
57+func NewManager() *Manager {
58+ return NewManagerWithOptions(ManagerOptions{})
59+}
60+
61+func NewManagerWithOptions(options ManagerOptions) *Manager {
62+ if options.RequestTimeout <= 0 {
63+ options.RequestTimeout = 12 * time.Second
64+ }
65+ if options.DiagnosticDelay <= 0 {
66+ options.DiagnosticDelay = 300 * time.Millisecond
67+ }
68+ if options.LinterTimeout <= 0 {
69+ options.LinterTimeout = 30 * time.Second
70+ }
71+ return &Manager{workspaces: make(map[string]*workspace), options: options}
72+}
73+
74+func (m *Manager) workspace(cwd string) (*workspace, error) {
75+ cwd, err := absDir(cwd)
76+ if err != nil {
77+ return nil, err
78+ }
79+ m.mu.Lock()
80+ if value := m.workspaces[cwd]; value != nil {
81+ m.mu.Unlock()
82+ return value, nil
83+ }
84+ m.mu.Unlock()
85+ config, err := LoadConfig(cwd)
86+ if err != nil {
87+ return nil, err
88+ }
89+ value := &workspace{cwd: cwd, config: config, clients: make(map[string]*Client), starting: make(map[string]chan struct{}), statuses: make(map[string]string), errors: make(map[string]string), diagnostics: make(map[string]map[string][]Diagnostic), cliDiagnostics: make(map[string][]Diagnostic), opened: make(map[string]map[string]string), surfaced: make(map[string]map[string]bool)}
90+ m.mu.Lock()
91+ if existing := m.workspaces[cwd]; existing != nil {
92+ value = existing
93+ } else {
94+ m.workspaces[cwd] = value
95+ }
96+ m.mu.Unlock()
97+ return value, nil
98+}
99+
100+// Config returns the effective merged configuration for cwd.
101+func (m *Manager) Config(cwd string) (Config, error) {
102+ ws, err := m.workspace(cwd)
103+ if err != nil {
104+ return Config{}, err
105+ }
106+ return ws.config, nil
107+}
108+
109+func (m *Manager) applicable(ws *workspace, path string) []ServerConfig {
110+ return ws.config.ApplicableServers(ws.cwd, path)
111+}
112+
113+func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerConfig) (*Client, error) {
114+ if ctx == nil {
115+ ctx = context.Background()
116+ }
117+ for {
118+ m.mu.Lock()
119+ if client := ws.clients[spec.ID]; client != nil {
120+ m.mu.Unlock()
121+ return client, nil
122+ }
123+ if waiting := ws.starting[spec.ID]; waiting != nil {
124+ m.mu.Unlock()
125+ select {
126+ case <-waiting:
127+ continue
128+ case <-ctx.Done():
129+ return nil, ctx.Err()
130+ }
131+ }
132+ starting := make(chan struct{})
133+ ws.starting[spec.ID] = starting
134+ ws.statuses[spec.ID] = "starting"
135+ m.mu.Unlock()
136+ root := FindRoot(ws.cwd, spec.RootMarkers)
137+ opts := ClientOptions{
138+ Settings: wsSettings(m.options.Settings, spec.Settings),
139+ InitializationOptions: spec.InitializationOptions,
140+ OnDiagnostics: func(diagnostics []Diagnostic) { m.storeDiagnostics(ws, spec.ID, diagnostics) },
141+ OnLog: func(message string) { m.log(ws, spec.ID, message) },
142+ // A server's LSP root may be an ancestor of the session cwd.
143+ // Keep server-initiated edits inside the session workspace rather
144+ // than trusting that broader project root.
145+ ApplyEdit: func(edit WorkspaceEdit) error { return ApplyWorkspaceEdit(ws.cwd, edit) },
146+ }
147+ startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
148+ client, err := NewClientWithContext(startCtx, spec, root, opts)
149+ cancel()
150+ m.mu.Lock()
151+ delete(ws.starting, spec.ID)
152+ close(starting)
153+ if err != nil {
154+ ws.statuses[spec.ID] = "missing"
155+ ws.errors[spec.ID] = err.Error()
156+ m.mu.Unlock()
157+ return nil, err
158+ }
159+ if existing := ws.clients[spec.ID]; existing != nil {
160+ m.mu.Unlock()
161+ _ = client.Close()
162+ return existing, nil
163+ }
164+ ws.clients[spec.ID] = client
165+ ws.statuses[spec.ID] = "ready"
166+ delete(ws.errors, spec.ID)
167+ m.mu.Unlock()
168+ return client, nil
169+ }
170+}
171+
172+func wsSettings(global, local map[string]any) map[string]any {
173+ if len(global) == 0 && len(local) == 0 {
174+ return nil
175+ }
176+ out := make(map[string]any, len(global)+len(local))
177+ for key, value := range global {
178+ out[key] = value
179+ }
180+ for key, value := range local {
181+ out[key] = value
182+ }
183+ return out
184+}
185+
186+func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
187+ byPath := make(map[string][]Diagnostic)
188+ for _, diagnostic := range diagnostics {
189+ if diagnostic.Path == "" || !diagnosticInWorkspace(ws.cwd, diagnostic.Path) {
190+ continue
191+ }
192+ byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
193+ }
194+ m.mu.Lock()
195+ if ws.diagnostics[server] == nil {
196+ ws.diagnostics[server] = make(map[string][]Diagnostic)
197+ }
198+ for path, values := range byPath {
199+ if len(values) == 1 && values[0].Message == "" && values[0].Code == "" {
200+ delete(ws.diagnostics[server], path)
201+ continue
202+ }
203+ ws.diagnostics[server][path] = values
204+ }
205+ m.mu.Unlock()
206+}
207+
208+func (m *Manager) log(ws *workspace, server, message string) {
209+ message = strings.TrimSpace(message)
210+ if message == "" {
211+ return
212+ }
213+ if len(message) > 4096 {
214+ message = message[:4095] + "…"
215+ }
216+ m.mu.Lock()
217+ ws.logs = append(ws.logs, server+": "+message)
218+ if len(ws.logs) > 100 {
219+ ws.logs = ws.logs[len(ws.logs)-100:]
220+ }
221+ m.mu.Unlock()
222+ if m.options.OnLog != nil {
223+ m.options.OnLog(server, message)
224+ }
225+}
226+
227+// Open opens a document in every applicable LSP server.
228+func (m *Manager) Open(ctx context.Context, cwd, path string) error {
229+ ws, err := m.workspace(cwd)
230+ if err != nil {
231+ return err
232+ }
233+ path, err = absoluteFile(ws.cwd, path)
234+ if err != nil {
235+ return err
236+ }
237+ text, err := os.ReadFile(path)
238+ if err != nil {
239+ return err
240+ }
241+ for _, spec := range m.applicable(ws, path) {
242+ if spec.Kind != "lsp" {
243+ continue
244+ }
245+ client, clientErr := m.ensureClient(ctx, ws, spec)
246+ if clientErr != nil {
247+ if err == nil {
248+ err = clientErr
249+ }
250+ continue
251+ }
252+ hash := string(text)
253+ m.mu.Lock()
254+ if ws.opened[spec.ID] == nil {
255+ ws.opened[spec.ID] = make(map[string]string)
256+ }
257+ old, open := ws.opened[spec.ID][path]
258+ m.mu.Unlock()
259+ if !open {
260+ languageID := spec.LanguageID
261+ if languageID == "" {
262+ languageID = languageForExtension(filepath.Ext(path))
263+ }
264+ if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil {
265+ m.mu.Lock()
266+ ws.opened[spec.ID][path] = hash
267+ m.mu.Unlock()
268+ }
269+ } else if old != hash {
270+ if clientErr = client.DidChange(path, string(text)); clientErr == nil {
271+ m.mu.Lock()
272+ ws.opened[spec.ID][path] = hash
273+ m.mu.Unlock()
274+ }
275+ }
276+ if clientErr != nil && err == nil {
277+ err = clientErr
278+ }
279+ }
280+ return err
281+}
282+
283+func (m *Manager) Save(ctx context.Context, cwd, path string) error {
284+ openErr := m.Open(ctx, cwd, path)
285+ ws, err := m.workspace(cwd)
286+ if err != nil {
287+ return err
288+ }
289+ path, err = absoluteFile(ws.cwd, path)
290+ if err != nil {
291+ return err
292+ }
293+ text, err := os.ReadFile(path)
294+ if err != nil {
295+ return err
296+ }
297+ for _, spec := range m.applicable(ws, path) {
298+ if spec.Kind != "lsp" {
299+ continue
300+ }
301+ m.mu.Lock()
302+ client := ws.clients[spec.ID]
303+ m.mu.Unlock()
304+ if client != nil {
305+ _ = client.DidSave(path, string(text))
306+ }
307+ }
308+ return openErr
309+}
310+
311+// Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits
312+// only a bounded debounce interval for asynchronous publishDiagnostics.
313+func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) {
314+ return m.DiagnosticsWithOptions(ctx, cwd, path, true)
315+}
316+
317+func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) {
318+ ws, err := m.workspace(cwd)
319+ if err != nil {
320+ return nil, err
321+ }
322+ var firstErr error
323+ if path != "" {
324+ if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) {
325+ // Keep cached diagnostics, but make an unavailable or failed
326+ // server visible to the model-facing lsp action.
327+ firstErr = saveErr
328+ }
329+ }
330+ for _, spec := range m.applicable(ws, path) {
331+ if spec.Kind != "lsp" {
332+ continue
333+ }
334+ m.mu.Lock()
335+ client := ws.clients[spec.ID]
336+ m.mu.Unlock()
337+ if client != nil {
338+ if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) {
339+ firstErr = waitErr
340+ }
341+ }
342+ }
343+ if runCLI {
344+ if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil {
345+ firstErr = err
346+ }
347+ }
348+ return m.snapshot(ws, path), firstErr
349+}
350+
351+// ReduceDiagnostics returns only diagnostics that have not already been
352+// surfaced for this workspace path. The ledger deliberately ignores line
353+// and column changes, matching the behavior users want after a harmless edit:
354+// unchanged problems do not consume another tool-result context window.
355+// Passing an empty snapshot clears the path so a later recurrence is fresh.
356+func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic {
357+ ws, err := m.workspace(cwd)
358+ if err != nil {
359+ return diagnostics
360+ }
361+ path, err = absoluteFile(ws.cwd, path)
362+ if err != nil {
363+ return diagnostics
364+ }
365+ m.mu.Lock()
366+ defer m.mu.Unlock()
367+ if len(diagnostics) == 0 {
368+ delete(ws.surfaced, path)
369+ return nil
370+ }
371+ previous := ws.surfaced[path]
372+ current := make(map[string]bool, len(diagnostics))
373+ fresh := make([]Diagnostic, 0, len(diagnostics))
374+ for _, diagnostic := range diagnostics {
375+ identity := DiagnosticIdentity(diagnostic)
376+ current[identity] = true
377+ if !previous[identity] {
378+ fresh = append(fresh, diagnostic)
379+ }
380+ }
381+ ws.surfaced[path] = current
382+ return fresh
383+}
384+
385+func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic {
386+ m.mu.Lock()
387+ defer m.mu.Unlock()
388+ var all []Diagnostic
389+ for _, files := range ws.diagnostics {
390+ for file, diagnostics := range files {
391+ if path == "" || samePath(file, path) {
392+ all = append(all, diagnostics...)
393+ }
394+ }
395+ }
396+ for _, diagnostics := range ws.cliDiagnostics {
397+ for _, diagnostic := range diagnostics {
398+ if path == "" || samePath(diagnostic.Path, path) {
399+ all = append(all, diagnostic)
400+ }
401+ }
402+ }
403+ return DeduplicateDiagnostics(all)
404+}
405+
406+// Request sends one request to a named LSP server.
407+func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) {
408+ ws, err := m.workspace(cwd)
409+ if err != nil {
410+ return nil, err
411+ }
412+ spec, ok := ws.config.Servers[server]
413+ if !ok || spec.Disabled {
414+ return nil, fmt.Errorf("unknown or disabled LSP server %q", server)
415+ }
416+ if spec.Kind != "lsp" || spec.IsLinter {
417+ return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server)
418+ }
419+ client, err := m.ensureClient(ctx, ws, spec)
420+ if err != nil {
421+ return nil, err
422+ }
423+ return m.requestWithTimeout(ctx, client, method, params)
424+}
425+
426+func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) {
427+ if ctx == nil {
428+ ctx = context.Background()
429+ }
430+ requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
431+ defer cancel()
432+ return client.Request(requestCtx, method, params)
433+}
434+
435+// RequestAll sends a request to every applicable LSP server. If path is set,
436+// the document is opened first so servers have current text and URI context.
437+func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response {
438+ ws, err := m.workspace(cwd)
439+ if err != nil {
440+ return []Response{{Error: err}}
441+ }
442+ if path != "" {
443+ _ = m.Open(ctx, ws.cwd, path)
444+ }
445+ var responses []Response
446+ for _, spec := range m.applicable(ws, path) {
447+ if spec.Kind != "lsp" || spec.IsLinter {
448+ continue
449+ }
450+ client, clientErr := m.ensureClient(ctx, ws, spec)
451+ if clientErr != nil {
452+ responses = append(responses, Response{Server: spec.ID, Error: clientErr})
453+ continue
454+ }
455+ result, requestErr := m.requestWithTimeout(ctx, client, method, params)
456+ responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr})
457+ }
458+ return responses
459+}
460+
461+// ApplyEdit applies a workspace edit under the selected workspace root.
462+func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error {
463+ ws, err := m.workspace(cwd)
464+ if err != nil {
465+ return err
466+ }
467+ return ApplyWorkspaceEdit(ws.cwd, edit)
468+}
469+
470+// Status describes configured and cached servers without starting missing
471+// processes. It is safe to call for a workspace with no installed tools.
472+type ServerStatus struct {
473+ ID string `json:"id"`
474+ Kind string `json:"kind"`
475+ Command string `json:"command"`
476+ Root string `json:"root,omitempty"`
477+ State string `json:"state"`
478+ Error string `json:"error,omitempty"`
479+ Diagnostics int `json:"diagnostics,omitempty"`
480+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
481+}
482+
483+func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
484+ ws, err := m.workspace(cwd)
485+ if err != nil {
486+ return nil, err
487+ }
488+ m.mu.Lock()
489+ defer m.mu.Unlock()
490+ out := make([]ServerStatus, 0)
491+ for _, spec := range m.applicable(ws, path) {
492+ status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers)), State: ws.statuses[spec.ID]}
493+ if status.State == "" {
494+ status.State = "stopped"
495+ }
496+ if status.State == "stopped" {
497+ if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
498+ status.State = "missing"
499+ status.Error = resolveErr.Error()
500+ }
501+ }
502+ if client := ws.clients[spec.ID]; client != nil {
503+ status.Capabilities = client.Capabilities()
504+ }
505+ for _, values := range ws.diagnostics[spec.ID] {
506+ status.Diagnostics += len(values)
507+ }
508+ status.Diagnostics += len(ws.cliDiagnostics[spec.ID])
509+ if configuredErr := ws.errors[spec.ID]; configuredErr != "" {
510+ status.Error = configuredErr
511+ }
512+ out = append(out, status)
513+ }
514+ return out, nil
515+}
516+
517+func (m *Manager) Capabilities(cwd, path, server string) (map[string]json.RawMessage, error) {
518+ ws, err := m.workspace(cwd)
519+ if err != nil {
520+ return nil, err
521+ }
522+ ensure := func(spec ServerConfig) (*Client, error) {
523+ if spec.Kind != "lsp" || spec.IsLinter {
524+ return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID)
525+ }
526+ m.mu.Lock()
527+ client := ws.clients[spec.ID]
528+ m.mu.Unlock()
529+ if client != nil {
530+ return client, nil
531+ }
532+ return m.ensureClient(context.Background(), ws, spec)
533+ }
534+ if server != "" {
535+ spec, ok := ws.config.Servers[server]
536+ if !ok || spec.Disabled {
537+ return nil, fmt.Errorf("unknown or disabled server %q", server)
538+ }
539+ client, err := ensure(spec)
540+ if err != nil {
541+ return nil, err
542+ }
543+ return map[string]json.RawMessage{server: client.Capabilities()}, nil
544+ }
545+ statuses, err := m.Status(cwd, path)
546+ if err != nil {
547+ return nil, err
548+ }
549+ out := make(map[string]json.RawMessage)
550+ var firstErr error
551+ for _, status := range statuses {
552+ spec := ws.config.Servers[status.ID]
553+ if spec.Kind != "lsp" || spec.IsLinter {
554+ continue
555+ }
556+ client, clientErr := ensure(spec)
557+ if clientErr != nil {
558+ if firstErr == nil {
559+ firstErr = clientErr
560+ }
561+ continue
562+ }
563+ if capabilities := client.Capabilities(); len(capabilities) > 0 {
564+ out[status.ID] = capabilities
565+ }
566+ }
567+ return out, firstErr
568+}
569+
570+// Reload terminates cached processes for cwd and reloads all lsp.json layers.
571+func (m *Manager) Reload(cwd string) error {
572+ cwd, err := absDir(cwd)
573+ if err != nil {
574+ return err
575+ }
576+ m.mu.Lock()
577+ ws := m.workspaces[cwd]
578+ delete(m.workspaces, cwd)
579+ m.mu.Unlock()
580+ if ws != nil {
581+ for _, client := range m.clientsSnapshot(ws) {
582+ _ = client.Close()
583+ }
584+ }
585+ return nil
586+}
587+
588+func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
589+ m.mu.Lock()
590+ defer m.mu.Unlock()
591+ clients := make([]*Client, 0, len(ws.clients))
592+ for _, client := range ws.clients {
593+ clients = append(clients, client)
594+ }
595+ return clients
596+}
597+
598+func (m *Manager) Close() error {
599+ m.mu.Lock()
600+ workspaces := make([]*workspace, 0, len(m.workspaces))
601+ for _, ws := range m.workspaces {
602+ workspaces = append(workspaces, ws)
603+ }
604+ m.workspaces = make(map[string]*workspace)
605+ m.mu.Unlock()
606+ var first error
607+ for _, ws := range workspaces {
608+ for _, client := range m.clientsSnapshot(ws) {
609+ if err := client.Close(); err != nil && first == nil {
610+ first = err
611+ }
612+ }
613+ }
614+ return first
615+}
616+
617+// RunLinters executes configured CLI providers without a shell. A non-zero
618+// linter exit is normal when it emitted parseable findings.
619+func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
620+ if ctx == nil {
621+ ctx = context.Background()
622+ }
623+ ws, err := m.workspace(cwd)
624+ if err != nil {
625+ return err
626+ }
627+ var files []string
628+ if path != "" {
629+ if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil {
630+ files = []string{abs}
631+ }
632+ }
633+ var firstErr error
634+ for _, spec := range m.applicable(ws, path) {
635+ if spec.Kind != "cli" || spec.Disabled {
636+ continue
637+ }
638+ command, resolveErr := ResolveCommand(ws.cwd, spec.Command)
639+ if resolveErr != nil {
640+ m.mu.Lock()
641+ ws.statuses[spec.ID] = "missing"
642+ ws.errors[spec.ID] = resolveErr.Error()
643+ m.mu.Unlock()
644+ if firstErr == nil {
645+ firstErr = resolveErr
646+ }
647+ continue
648+ }
649+ args := make([]string, 0, len(spec.Args)+len(files))
650+ containsFiles := false
651+ relative := relativeFiles(ws.cwd, files)
652+ for _, arg := range spec.Args {
653+ if arg == "{files}" {
654+ containsFiles = true
655+ args = append(args, relative...)
656+ continue
657+ }
658+ if strings.Contains(arg, "{files}") {
659+ containsFiles = true
660+ args = append(args, strings.ReplaceAll(arg, "{files}", strings.Join(relative, " ")))
661+ continue
662+ }
663+ args = append(args, arg)
664+ }
665+ if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) {
666+ if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") {
667+ args = append(args, ".")
668+ }
669+ } else if !containsFiles {
670+ args = append(args, relativeFiles(ws.cwd, files)...)
671+ }
672+ commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
673+ cmd := exec.CommandContext(commandCtx, command, args...)
674+ cmd.Dir = ws.cwd
675+ cmd.Env = mergedEnvironment(spec.Env)
676+ var stdout, stderr limitedBuffer
677+ stdout.limit = 8 * 1024 * 1024
678+ stderr.limit = 8 * 1024 * 1024
679+ cmd.Stdout = &stdout
680+ cmd.Stderr = &stderr
681+ m.mu.Lock()
682+ ws.statuses[spec.ID] = "running"
683+ m.mu.Unlock()
684+ runErr := cmd.Run()
685+ cancel()
686+ text := strings.TrimSpace(stdout.String())
687+ if text == "" {
688+ text = strings.TrimSpace(stderr.String())
689+ }
690+ diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
691+ filtered := diagnostics[:0]
692+ for _, diagnostic := range diagnostics {
693+ if diagnostic.Path != "" && diagnosticInWorkspace(ws.cwd, diagnostic.Path) {
694+ filtered = append(filtered, diagnostic)
695+ }
696+ }
697+ diagnostics = filtered
698+ m.mu.Lock()
699+ ws.cliDiagnostics[spec.ID] = diagnostics
700+ if runErr != nil && len(diagnostics) == 0 {
701+ ws.errors[spec.ID] = runErr.Error()
702+ ws.statuses[spec.ID] = "error"
703+ } else {
704+ delete(ws.errors, spec.ID)
705+ ws.statuses[spec.ID] = "ready"
706+ }
707+ m.mu.Unlock()
708+ if runErr != nil && len(diagnostics) == 0 && firstErr == nil {
709+ firstErr = fmt.Errorf("%s: %w", spec.ID, runErr)
710+ }
711+ }
712+ return firstErr
713+}
714+
715+func displayWorkspacePath(root, path string) string {
716+ if path == "" {
717+ return ""
718+ }
719+ rel, err := filepath.Rel(root, path)
720+ if err != nil {
721+ return ""
722+ }
723+ if rel == "." {
724+ return "."
725+ }
726+ return filepath.ToSlash(rel)
727+}
728+
729+func relativeFiles(cwd string, files []string) []string {
730+ out := make([]string, len(files))
731+ for i, path := range files {
732+ rel, err := filepath.Rel(cwd, path)
733+ if err != nil {
734+ out[i] = path
735+ } else {
736+ out[i] = filepath.ToSlash(rel)
737+ }
738+ }
739+ return out
740+}
741+func absoluteFile(cwd, path string) (string, error) {
742+ if path == "" {
743+ return "", errors.New("path is required")
744+ }
745+ if !filepath.IsAbs(path) {
746+ path = filepath.Join(cwd, path)
747+ }
748+ return filepath.Abs(path)
749+}
750+func diagnosticInWorkspace(root, path string) bool {
751+ rootReal, err := filepath.EvalSymlinks(root)
752+ if err != nil {
753+ rootReal, err = filepath.Abs(root)
754+ if err != nil {
755+ return false
756+ }
757+ }
758+ return safeWorkspacePath(rootReal, path) == nil
759+}
760+
761+func samePath(a, b string) bool {
762+ aa, _ := filepath.Abs(a)
763+ bb, _ := filepath.Abs(b)
764+ return filepath.Clean(aa) == filepath.Clean(bb)
765+}
766+
767+type limitedBuffer struct {
768+ bytes.Buffer
769+ limit int
770+}
771+
772+func (b *limitedBuffer) Write(p []byte) (int, error) {
773+ original := len(p)
774+ if b.limit <= b.Len() {
775+ return original, nil
776+ }
777+ room := b.limit - b.Len()
778+ if len(p) > room {
779+ p = p[:room]
780+ }
781+ _, _ = b.Buffer.Write(p)
782+ return original, nil
783+}
A packages/agent/lsp/parsers.go +276 -0
@@ -0,0 +1,276 @@
1+package lsp
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "fmt"
7+ "path/filepath"
8+ "regexp"
9+ "strconv"
10+ "strings"
11+)
12+
13+// ParseDiagnostics parses output from one of the built-in CLI linter formats.
14+// Unknown parser names use the generic file:line:column parser.
15+func ParseDiagnostics(parser, provider, cwd, output string) []Diagnostic {
16+ switch strings.ToLower(strings.TrimSpace(parser)) {
17+ case "eslint-json", "eslint":
18+ return parseESLint(provider, cwd, output)
19+ case "golangci-lint-json", "golangci", "golangci-json":
20+ return parseGolangCILint(provider, cwd, output)
21+ case "ruff-json", "ruff":
22+ return parseRuff(provider, cwd, output)
23+ case "sarif":
24+ return parseSARIF(provider, cwd, output)
25+ default:
26+ return parseGeneric(provider, cwd, output)
27+ }
28+}
29+
30+func parseESLint(provider, cwd, output string) []Diagnostic {
31+ var results []struct {
32+ FilePath string `json:"filePath"`
33+ Messages []struct {
34+ RuleID string `json:"ruleId"`
35+ Severity int `json:"severity"`
36+ Message string `json:"message"`
37+ Line int `json:"line"`
38+ Column int `json:"column"`
39+ EndLine int `json:"endLine"`
40+ EndColumn int `json:"endColumn"`
41+ } `json:"messages"`
42+ }
43+ if json.Unmarshal([]byte(output), &results) != nil {
44+ return parseGeneric(provider, cwd, output)
45+ }
46+ var out []Diagnostic
47+ for _, result := range results {
48+ path := normalizeDiagnosticPath(cwd, result.FilePath)
49+ for _, message := range result.Messages {
50+ severity := "info"
51+ if message.Severity >= 2 {
52+ severity = "error"
53+ } else if message.Severity == 1 {
54+ severity = "warning"
55+ }
56+ out = append(out, Diagnostic{Path: path, Server: provider, Source: provider, Severity: severity, Code: message.RuleID, Message: message.Message, Range: oneBasedRange(message.Line, message.Column, message.EndLine, message.EndColumn)})
57+ }
58+ }
59+ return out
60+}
61+
62+func parseGolangCILint(provider, cwd, output string) []Diagnostic {
63+ var root struct {
64+ Issues []struct {
65+ FromLinter string `json:"FromLinter"`
66+ Text string `json:"Text"`
67+ Severity string `json:"Severity"`
68+ Pos struct {
69+ Filename string `json:"Filename"`
70+ Line int `json:"Line"`
71+ Column int `json:"Column"`
72+ Offset int `json:"Offset"`
73+ } `json:"Pos"`
74+ LineRange *struct {
75+ From int `json:"From"`
76+ To int `json:"To"`
77+ } `json:"LineRange"`
78+ } `json:"Issues"`
79+ }
80+ if json.Unmarshal([]byte(output), &root) != nil {
81+ return parseGeneric(provider, cwd, output)
82+ }
83+ var out []Diagnostic
84+ for _, issue := range root.Issues {
85+ severity := normalizeSeverity(issue.Severity)
86+ endLine := issue.Pos.Line
87+ if issue.LineRange != nil && issue.LineRange.To > endLine {
88+ endLine = issue.LineRange.To
89+ }
90+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, issue.Pos.Filename), Server: provider, Source: issue.FromLinter, Severity: severity, Code: issue.FromLinter, Message: issue.Text, Range: oneBasedRange(issue.Pos.Line, issue.Pos.Column, endLine, issue.Pos.Column)})
91+ }
92+ return out
93+}
94+
95+func parseRuff(provider, cwd, output string) []Diagnostic {
96+ var records []struct {
97+ Code string `json:"code"`
98+ Message string `json:"message"`
99+ Filename string `json:"filename"`
100+ Location struct {
101+ Row int `json:"row"`
102+ Column int `json:"column"`
103+ } `json:"location"`
104+ EndLocation struct {
105+ Row int `json:"row"`
106+ Column int `json:"column"`
107+ } `json:"end_location"`
108+ Fix any `json:"fix"`
109+ }
110+ if json.Unmarshal([]byte(output), &records) != nil {
111+ return parseGeneric(provider, cwd, output)
112+ }
113+ var out []Diagnostic
114+ for _, record := range records {
115+ endLine, endColumn := record.EndLocation.Row, record.EndLocation.Column
116+ if endLine == 0 {
117+ endLine, endColumn = record.Location.Row, record.Location.Column
118+ }
119+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, record.Filename), Server: provider, Source: provider, Severity: "error", Code: record.Code, Message: record.Message, Range: oneBasedRange(record.Location.Row, record.Location.Column, endLine, endColumn)})
120+ }
121+ return out
122+}
123+
124+func parseSARIF(provider, cwd, output string) []Diagnostic {
125+ var root struct {
126+ Runs []struct {
127+ Tool struct {
128+ Driver struct {
129+ Rules []struct {
130+ ID string `json:"id"`
131+ } `json:"rules"`
132+ } `json:"driver"`
133+ } `json:"tool"`
134+ Results []struct {
135+ RuleID string `json:"ruleId"`
136+ Level string `json:"level"`
137+ Message struct {
138+ Text string `json:"text"`
139+ } `json:"message"`
140+ Locations []struct {
141+ Physical struct {
142+ ArtifactLocation struct {
143+ URI string `json:"uri"`
144+ } `json:"artifactLocation"`
145+ Region struct {
146+ StartLine int `json:"startLine"`
147+ StartColumn int `json:"startColumn"`
148+ EndLine int `json:"endLine"`
149+ EndColumn int `json:"endColumn"`
150+ } `json:"region"`
151+ } `json:"physicalLocation"`
152+ } `json:"locations"`
153+ } `json:"results"`
154+ } `json:"runs"`
155+ }
156+ if json.Unmarshal([]byte(output), &root) != nil {
157+ return parseGeneric(provider, cwd, output)
158+ }
159+ var out []Diagnostic
160+ for _, run := range root.Runs {
161+ for _, result := range run.Results {
162+ if len(result.Locations) == 0 {
163+ continue
164+ }
165+ location := result.Locations[0].Physical
166+ path := location.ArtifactLocation.URI
167+ if strings.HasPrefix(path, "file://") {
168+ if decoded, err := uriToPath(path); err == nil {
169+ path = decoded
170+ }
171+ }
172+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, path), Server: provider, Source: provider, Severity: normalizeSeverity(result.Level), Code: result.RuleID, Message: result.Message.Text, Range: oneBasedRange(location.Region.StartLine, location.Region.StartColumn, location.Region.EndLine, location.Region.EndColumn)})
173+ }
174+ }
175+ return out
176+}
177+
178+var genericDiagnosticPattern = regexp.MustCompile(`^\s*(.+?):([0-9]+):([0-9]+)(?::\s*([A-Za-z]+))?\s*[-:]?\s*(.*)$`)
179+
180+func parseGeneric(provider, cwd, output string) []Diagnostic {
181+ var out []Diagnostic
182+ scanner := bufio.NewScanner(strings.NewReader(output))
183+ for scanner.Scan() {
184+ line := strings.TrimSpace(scanner.Text())
185+ match := genericDiagnosticPattern.FindStringSubmatch(line)
186+ if len(match) == 0 {
187+ continue
188+ }
189+ lineNo, _ := strconv.Atoi(match[2])
190+ columnNo, _ := strconv.Atoi(match[3])
191+ message := strings.TrimSpace(match[5])
192+ if message == "" {
193+ message = strings.TrimSpace(match[4])
194+ }
195+ severity := normalizeSeverity(match[4])
196+ out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, match[1]), Server: provider, Source: provider, Severity: severity, Message: message, Range: oneBasedRange(lineNo, columnNo, lineNo, columnNo)})
197+ }
198+ return out
199+}
200+
201+func oneBasedRange(line, column, endLine, endColumn int) Range {
202+ if line < 1 {
203+ line = 1
204+ }
205+ if column < 1 {
206+ column = 1
207+ }
208+ if endLine < 1 {
209+ endLine = line
210+ }
211+ if endColumn < 1 {
212+ endColumn = column
213+ }
214+ return Range{Start: Position{Line: line - 1, Character: column - 1}, End: Position{Line: endLine - 1, Character: endColumn - 1}}
215+}
216+
217+func normalizeSeverity(value string) string {
218+ switch strings.ToLower(strings.TrimSpace(value)) {
219+ case "error", "err", "fatal", "failure":
220+ return "error"
221+ case "warning", "warn":
222+ return "warning"
223+ case "hint":
224+ return "hint"
225+ default:
226+ return "info"
227+ }
228+}
229+
230+func normalizeDiagnosticPath(cwd, path string) string {
231+ path = strings.TrimSpace(path)
232+ if path == "" {
233+ return ""
234+ }
235+ if strings.HasPrefix(path, "file://") {
236+ if decoded, err := uriToPath(path); err == nil {
237+ path = decoded
238+ }
239+ }
240+ if !filepath.IsAbs(path) {
241+ path = filepath.Join(cwd, path)
242+ }
243+ abs, err := filepath.Abs(path)
244+ if err == nil {
245+ return filepath.Clean(abs)
246+ }
247+ return filepath.Clean(path)
248+}
249+
250+// FormatDiagnostic is the compact, model-facing form of one diagnostic.
251+func FormatDiagnostic(d Diagnostic, root string) string {
252+ path := d.Path
253+ if root != "" {
254+ if rel, err := filepath.Rel(root, d.Path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
255+ path = filepath.ToSlash(rel)
256+ }
257+ }
258+ code := ""
259+ if d.Code != "" {
260+ code = " [" + d.Code + "]"
261+ }
262+ message := compactWhitespace(d.Message)
263+ if len(message) > 220 {
264+ message = message[:219] + "…"
265+ }
266+ return fmt.Sprintf("%s: %s:%d:%d%s %s", strings.ToUpper(defaultSeverity(d.Severity)), filepath.ToSlash(path), d.Range.Start.Line+1, d.Range.Start.Character+1, code, message)
267+}
268+
269+func defaultSeverity(value string) string {
270+ if value == "" {
271+ return "info"
272+ }
273+ return value
274+}
275+
276+func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") }
A packages/agent/lsp/protocol.go +146 -0
@@ -0,0 +1,146 @@
1+package lsp
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "net/url"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// Position and Range use the zero-based line and UTF-16 character indexing
12+// defined by LSP.
13+type Position struct {
14+ Line int `json:"line"`
15+ Character int `json:"character"`
16+}
17+
18+type Range struct {
19+ Start Position `json:"start"`
20+ End Position `json:"end"`
21+}
22+
23+type TextEdit struct {
24+ Range Range `json:"range"`
25+ NewText string `json:"newText"`
26+}
27+
28+// WorkspaceEdit is the safe subset of a workspace edit understood by zot.
29+// Resource operations in DocumentChanges are deliberately rejected.
30+type WorkspaceEdit struct {
31+ Changes map[string][]TextEdit `json:"changes,omitempty"`
32+ DocumentChanges []json.RawMessage `json:"documentChanges,omitempty"`
33+ ChangeAnnotations json.RawMessage `json:"changeAnnotations,omitempty"`
34+}
35+
36+// Diagnostic is the normalized diagnostic representation shared by LSP and
37+// CLI linters. Path is absolute when it came from a workspace-backed source.
38+type Diagnostic struct {
39+ Path string `json:"path"`
40+ Range Range `json:"range"`
41+ Severity string `json:"severity"`
42+ Code string `json:"code,omitempty"`
43+ Message string `json:"message"`
44+ Source string `json:"source,omitempty"`
45+ Server string `json:"server,omitempty"`
46+ Clear bool `json:"-"`
47+}
48+
49+func (d *Diagnostic) UnmarshalJSON(data []byte) error {
50+ var raw struct {
51+ Range Range `json:"range"`
52+ Severity json.RawMessage `json:"severity"`
53+ Code json.RawMessage `json:"code"`
54+ Message string `json:"message"`
55+ Source string `json:"source"`
56+ }
57+ if err := json.Unmarshal(data, &raw); err != nil {
58+ return err
59+ }
60+ d.Range, d.Message, d.Source = raw.Range, raw.Message, raw.Source
61+ d.Severity = severityFromJSON(raw.Severity)
62+ d.Code = stringFromJSON(raw.Code)
63+ return nil
64+}
65+
66+func severityFromJSON(raw json.RawMessage) string {
67+ var n int
68+ if json.Unmarshal(raw, &n) == nil {
69+ switch n {
70+ case 1:
71+ return "error"
72+ case 2:
73+ return "warning"
74+ case 3:
75+ return "info"
76+ case 4:
77+ return "hint"
78+ }
79+ }
80+ var s string
81+ if json.Unmarshal(raw, &s) == nil {
82+ switch strings.ToLower(s) {
83+ case "error", "err", "fatal":
84+ return "error"
85+ case "warning", "warn":
86+ return "warning"
87+ case "hint":
88+ return "hint"
89+ default:
90+ return "info"
91+ }
92+ }
93+ return "info"
94+}
95+
96+func stringFromJSON(raw json.RawMessage) string {
97+ if len(raw) == 0 || string(raw) == "null" {
98+ return ""
99+ }
100+ var s string
101+ if json.Unmarshal(raw, &s) == nil {
102+ return s
103+ }
104+ var n json.Number
105+ if json.Unmarshal(raw, &n) == nil {
106+ return n.String()
107+ }
108+ return strings.Trim(string(raw), `"`)
109+}
110+
111+// URIForPath returns the escaped file URI used by LSP document params.
112+func URIForPath(path string) string {
113+ uri, _ := pathToURI(path)
114+ return uri
115+}
116+
117+func pathToURI(path string) (string, error) {
118+ abs, err := filepath.Abs(path)
119+ if err != nil {
120+ return "", err
121+ }
122+ // url.URL escapes spaces and non-ASCII while retaining path separators.
123+ u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}
124+ return u.String(), nil
125+}
126+
127+func uriToPath(uri string) (string, error) {
128+ u, err := url.Parse(uri)
129+ if err != nil {
130+ return "", err
131+ }
132+ if u.Scheme != "" && !strings.EqualFold(u.Scheme, "file") {
133+ return "", fmt.Errorf("unsupported document URI scheme %q", u.Scheme)
134+ }
135+ path := u.Path
136+ if u.Host != "" && u.Host != "localhost" {
137+ path = "//" + u.Host + path
138+ }
139+ if path == "" {
140+ return "", fmt.Errorf("document URI has no path")
141+ }
142+ if len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/...
143+ path = path[1:]
144+ }
145+ return filepath.Clean(filepath.FromSlash(path)), nil
146+}
M packages/agent/modes/interactive.go +75 -1
@@ -77,6 +77,14 @@ type InteractiveConfig struct {
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+ val := value
4232+ i.cfg.LSPEnabled = &val
4233+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4234+ if err := store.SetLSPEnabled(value); err != nil {
4235+ i.mu.Lock()
4236+ i.statusErr = "settings: " + err.Error()
4237+ i.mu.Unlock()
4238+ return
4239+ }
4240+ }
4241+ if i.cfg.RefreshTools != nil {
4242+ i.cfg.RefreshTools()
4243+ }
4244+ i.mu.Lock()
4245+ i.statusOK = "lsp in main session " + onOff(value)
4246+ i.statusErr = ""
4247+ i.mu.Unlock()
4248+ case "subagent_lsp_enabled":
4249+ val := value
4250+ i.cfg.SubagentLSPEnabled = &val
4251+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4252+ if err := store.SetSubagentLSPEnabled(value); err != nil {
4253+ i.mu.Lock()
4254+ i.statusErr = "settings: " + err.Error()
4255+ i.mu.Unlock()
4256+ return
4257+ }
4258+ }
4259+ i.mu.Lock()
4260+ i.statusOK = "lsp in sub-agents " + onOff(value)
4261+ i.statusErr = ""
4262+ i.mu.Unlock()
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
M packages/agent/rpc.go +1 -0
@@ -79,6 +79,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
7979 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
8080
8181 ag := r.NewAgent()
82+ defer closeAgentLSP(ag)
8283 server.agent = ag
8384 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
8485 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
M packages/agent/sdk/sdk.go +7 -2
@@ -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
@@ -339,10 +340,14 @@ func (r *Runtime) Close() error {
339340 }
340341 r.closed = true
341342 cancel := r.activeCancel
343+ ag := r.agent
342344 r.mu.Unlock()
343345 if cancel != nil {
344346 cancel()
345347 }
348+ if ag != nil {
349+ return agenttools.CloseLSPManagers(ag.Tools)
350+ }
346351 return nil
347352 }
348353
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 +44 -0
@@ -106,3 +106,47 @@ 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+}
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 +1 -0
@@ -59,6 +59,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
5959 defer stopExt()
6060
6161 ag := r.NewAgent()
62+ defer closeAgentLSP(ag)
6263 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
6364 sess, err := openOrCreateSession(args, r, ag, version)
6465 if err != nil {
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 +340 -0
@@ -0,0 +1,340 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/agent/lsp"
13+ "github.com/patriceckhart/zot/packages/core"
14+ "github.com/patriceckhart/zot/packages/provider"
15+)
16+
17+const maxLSPToolOutput = 60 * 1024
18+
19+// LSPTool exposes diagnostics, language navigation, and server management to
20+// the model. Manager owns processes and is normally shared by all tool calls
21+// in one agent session.
22+type LSPTool struct {
23+ CWD string
24+ Manager *lsp.Manager
25+ Sandbox *Sandbox
26+}
27+
28+// NewLSPTool constructs the model-facing LSP tool.
29+func NewLSPTool(cwd string, manager *lsp.Manager) *LSPTool {
30+ if manager == nil {
31+ manager = lsp.NewManager()
32+ }
33+ return &LSPTool{CWD: cwd, Manager: manager}
34+}
35+
36+type lspArgs struct {
37+ Action string `json:"action"`
38+ Path string `json:"path,omitempty"`
39+ Line int `json:"line,omitempty"`
40+ Column int `json:"column,omitempty"`
41+ Query string `json:"query,omitempty"`
42+ NewName string `json:"new_name,omitempty"`
43+ Server string `json:"server,omitempty"`
44+ Method string `json:"method,omitempty"`
45+ Params json.RawMessage `json:"params,omitempty"`
46+ Apply *bool `json:"apply,omitempty"`
47+ Timeout int `json:"timeout_ms,omitempty"`
48+ Max int `json:"max,omitempty"`
49+ RunCLI *bool `json:"run_cli,omitempty"`
50+}
51+
52+const lspSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["diagnostics","definition","references","hover","symbols","rename","code_actions","type_definition","implementation","status","reload","capabilities","request"]},"path":{"type":"string","description":"File path relative to the workspace, required for document actions."},"line":{"type":"integer","minimum":1,"description":"1-based line for a document action."},"column":{"type":"integer","minimum":1,"description":"1-based column for a document action."},"query":{"type":"string","description":"Workspace symbol query; symbols uses document symbols when omitted."},"new_name":{"type":"string","description":"New identifier for rename."},"server":{"type":"string","description":"Optional server id for request or capabilities."},"method":{"type":"string","description":"Raw LSP method for request."},"params":{"type":"object","description":"Raw JSON-RPC params for request."},"apply":{"type":"boolean","description":"Apply a returned rename/code-action WorkspaceEdit. Only files inside the workspace are accepted."},"timeout_ms":{"type":"integer","minimum":1},"max":{"type":"integer","minimum":1,"maximum":50},"run_cli":{"type":"boolean","description":"Run configured CLI linters for diagnostics (default true)."}},"required":["action"]}`
53+
54+func (t *LSPTool) Name() string { return "lsp" }
55+func (t *LSPTool) Description() string {
56+ return "Query configured language servers and linters for diagnostics, navigation, refactors, symbols, capabilities, and raw LSP requests."
57+}
58+func (t *LSPTool) Schema() json.RawMessage { return json.RawMessage(lspSchema) }
59+
60+func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
61+ var args lspArgs
62+ if err := json.Unmarshal(raw, &args); err != nil {
63+ return core.ToolResult{}, fmt.Errorf("invalid lsp args: %w", err)
64+ }
65+ args.Action = strings.ToLower(strings.TrimSpace(args.Action))
66+ if args.Action == "" {
67+ return core.ToolResult{}, fmt.Errorf("action is required")
68+ }
69+ if t.Manager == nil {
70+ return core.ToolResult{}, fmt.Errorf("lsp manager is unavailable")
71+ }
72+ cwd := t.CWD
73+ if cwd == "" {
74+ cwd, _ = os.Getwd()
75+ }
76+ absCWD, err := filepath.Abs(cwd)
77+ if err != nil {
78+ return core.ToolResult{}, err
79+ }
80+ path := ""
81+ if args.Path != "" && args.Path != "*" {
82+ path = resolvePath(absCWD, args.Path)
83+ if t.Sandbox != nil {
84+ if err := t.Sandbox.CheckPath(path); err != nil {
85+ return core.ToolResult{}, err
86+ }
87+ }
88+ }
89+ runCtx := ctx
90+ if args.Timeout > 0 {
91+ var cancel context.CancelFunc
92+ runCtx, cancel = context.WithTimeout(ctx, time.Duration(args.Timeout)*time.Millisecond)
93+ defer cancel()
94+ }
95+ var text string
96+ var details any
97+ var actionErr error
98+ switch args.Action {
99+ case "diagnostics":
100+ runCLI := true
101+ if args.RunCLI != nil {
102+ runCLI = *args.RunCLI
103+ }
104+ var diagnostics []lsp.Diagnostic
105+ if path != "" && hasGlobMeta(args.Path) {
106+ matches, globErr := filepath.Glob(path)
107+ if globErr != nil {
108+ actionErr = globErr
109+ break
110+ }
111+ for _, match := range matches {
112+ if t.Sandbox != nil {
113+ if checkErr := t.Sandbox.CheckPath(match); checkErr != nil {
114+ if actionErr == nil {
115+ actionErr = checkErr
116+ }
117+ continue
118+ }
119+ }
120+ values, diagErr := t.Manager.DiagnosticsWithOptions(runCtx, absCWD, match, runCLI)
121+ if actionErr == nil {
122+ actionErr = diagErr
123+ }
124+ diagnostics = append(diagnostics, values...)
125+ }
126+ } else {
127+ diagnostics, actionErr = t.Manager.DiagnosticsWithOptions(runCtx, absCWD, path, runCLI)
128+ }
129+ max := boundedMax(args.Max)
130+ text = lsp.SummarizeDiagnostics(diagnostics, absCWD, max)
131+ visible := diagnostics
132+ if len(visible) > max {
133+ visible = visible[:max]
134+ }
135+ details = map[string]any{"diagnostics": visible, "count": len(diagnostics), "truncated": len(visible) < len(diagnostics), "run_cli": runCLI}
136+ case "status":
137+ var statuses []lsp.ServerStatus
138+ statuses, actionErr = t.Manager.Status(absCWD, path)
139+ text = prettyJSON(statuses)
140+ details = statuses
141+ case "reload":
142+ actionErr = t.Manager.Reload(absCWD)
143+ text = "Reloaded LSP and linter processes and configuration."
144+ case "capabilities":
145+ var capabilities map[string]json.RawMessage
146+ capabilities, actionErr = t.Manager.Capabilities(absCWD, path, args.Server)
147+ text = prettyJSON(capabilities)
148+ details = capabilities
149+ case "request":
150+ text, details, actionErr = t.executeRawRequest(runCtx, absCWD, path, args)
151+ case "definition", "references", "hover", "symbols", "rename", "code_actions", "type_definition", "implementation":
152+ text, details, actionErr = t.executeLanguageAction(runCtx, absCWD, path, args)
153+ default:
154+ return core.ToolResult{}, fmt.Errorf("unknown lsp action %q", args.Action)
155+ }
156+ if actionErr != nil && text == "" {
157+ return core.ToolResult{}, actionErr
158+ }
159+ text = boundLSPText(text)
160+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}, nil
161+}
162+
163+func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
164+ if path == "" && (args.Action != "symbols" || args.Query == "") {
165+ return "", nil, fmt.Errorf("path is required for %s", args.Action)
166+ }
167+ if args.Action != "symbols" && (args.Line < 1 || args.Column < 1) {
168+ return "", nil, fmt.Errorf("line and column (1-based) are required for %s", args.Action)
169+ }
170+ position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1}
171+ method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action]
172+ var params any
173+ if args.Action == "symbols" {
174+ if args.Query != "" {
175+ method, params = "workspace/symbol", map[string]any{"query": args.Query}
176+ } else {
177+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}}
178+ }
179+ } else {
180+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position}
181+ if args.Action == "references" {
182+ params.(map[string]any)["context"] = map[string]any{"includeDeclaration": true}
183+ }
184+ if args.Action == "code_actions" {
185+ params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}}
186+ }
187+ if args.Action == "rename" {
188+ if strings.TrimSpace(args.NewName) == "" {
189+ return "", nil, fmt.Errorf("new_name is required for rename")
190+ }
191+ params.(map[string]any)["newName"] = args.NewName
192+ }
193+ }
194+ responses := t.Manager.RequestAll(ctx, cwd, path, method, params)
195+ apply := args.Apply != nil && *args.Apply
196+ if args.Action == "rename" && args.Apply == nil {
197+ apply = true
198+ }
199+ text, applyCount, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
200+ if applyCount > 0 {
201+ text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)
202+ }
203+ return text, map[string]any{"action": args.Action, "responses": responses, "applied": applyCount}, applyErr
204+}
205+
206+func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
207+ if strings.TrimSpace(args.Method) == "" {
208+ return "", nil, fmt.Errorf("method is required for request")
209+ }
210+ var params any = map[string]any{}
211+ if len(args.Params) > 0 && string(args.Params) != "null" {
212+ if err := json.Unmarshal(args.Params, &params); err != nil {
213+ return "", nil, fmt.Errorf("params must be JSON: %w", err)
214+ }
215+ }
216+ var responses []lsp.Response
217+ if path != "" {
218+ _ = t.Manager.Open(ctx, cwd, path)
219+ }
220+ if args.Server != "" {
221+ result, err := t.Manager.Request(ctx, cwd, args.Server, args.Method, params)
222+ responses = []lsp.Response{{Server: args.Server, Result: result, Error: err}}
223+ } else {
224+ responses = t.Manager.RequestAll(ctx, cwd, path, args.Method, params)
225+ }
226+ return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)
227+}
228+
229+func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, error) {
230+ var b strings.Builder
231+ applied := 0
232+ var firstErr error
233+ for _, response := range responses {
234+ if response.Error != nil {
235+ fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
236+ if firstErr == nil {
237+ firstErr = response.Error
238+ }
239+ continue
240+ }
241+ fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
242+ if apply && (action == "rename" || action == "code_actions") {
243+ edits := workspaceEdits(response.Result, action)
244+ for _, edit := range edits {
245+ if err := manager.ApplyEdit(cwd, edit); err != nil {
246+ if firstErr == nil {
247+ firstErr = err
248+ }
249+ fmt.Fprintf(&b, "apply: error: %s\n", err)
250+ } else {
251+ applied++
252+ }
253+ }
254+ }
255+ }
256+ if b.Len() == 0 {
257+ return "No results.", applied, firstErr
258+ }
259+ return b.String(), applied, firstErr
260+}
261+
262+func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {
263+ if action == "rename" {
264+ var edit lsp.WorkspaceEdit
265+ if json.Unmarshal(raw, &edit) == nil && (len(edit.Changes) > 0 || len(edit.DocumentChanges) > 0) {
266+ return []lsp.WorkspaceEdit{edit}
267+ }
268+ return nil
269+ }
270+ var actions []struct {
271+ Edit lsp.WorkspaceEdit `json:"edit"`
272+ }
273+ if json.Unmarshal(raw, &actions) != nil {
274+ return nil
275+ }
276+ out := make([]lsp.WorkspaceEdit, 0, len(actions))
277+ for _, action := range actions {
278+ if len(action.Edit.Changes) > 0 || len(action.Edit.DocumentChanges) > 0 {
279+ out = append(out, action.Edit)
280+ }
281+ }
282+ return out
283+}
284+
285+func formatRawResponses(responses []lsp.Response) string {
286+ var b strings.Builder
287+ for _, response := range responses {
288+ if response.Error != nil {
289+ fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
290+ continue
291+ }
292+ fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
293+ }
294+ if b.Len() == 0 {
295+ return "No results."
296+ }
297+ return b.String()
298+}
299+
300+func firstResponseError(responses []lsp.Response) error {
301+ for _, response := range responses {
302+ if response.Error != nil {
303+ return response.Error
304+ }
305+ }
306+ return nil
307+}
308+func fileURI(path string) string { return lsp.URIForPath(path) }
309+func prettyJSON(raw any) string {
310+ var data []byte
311+ switch value := raw.(type) {
312+ case json.RawMessage:
313+ data = value
314+ default:
315+ data, _ = json.Marshal(value)
316+ }
317+ var value any
318+ if json.Unmarshal(data, &value) == nil {
319+ if formatted, err := json.MarshalIndent(value, "", " "); err == nil {
320+ return string(formatted)
321+ }
322+ }
323+ return strings.TrimSpace(string(data))
324+}
325+func hasGlobMeta(value string) bool {
326+ return strings.ContainsAny(value, "*?[")
327+}
328+
329+func boundedMax(value int) int {
330+ if value <= 0 || value > lsp.DefaultDiagnosticCap {
331+ return lsp.DefaultDiagnosticCap
332+ }
333+ return value
334+}
335+func boundLSPText(value string) string {
336+ if len(value) <= maxLSPToolOutput {
337+ return value
338+ }
339+ return value[:maxLSPToolOutput] + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput)
340+}
A packages/agent/tools/lsp_diagnostics.go +84 -0
@@ -0,0 +1,84 @@
1+package tools
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+
8+ "github.com/patriceckhart/zot/packages/agent/lsp"
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+const writeDiagnosticsTimeout = 2 * time.Second
14+
15+// CloseLSPManagers closes every manager referenced by the registry. Write,
16+// edit, and lsp share one manager in normal runs, so de-duplicate pointers
17+// before closing them. This is used by mode owners when an agent or registry
18+// is replaced; LSP processes should not outlive the session that created them.
19+func CloseLSPManagers(reg core.Registry) error {
20+ managers := make(map[*lsp.Manager]struct{})
21+ for _, tool := range reg {
22+ switch value := tool.(type) {
23+ case *LSPTool:
24+ if value != nil && value.Manager != nil {
25+ managers[value.Manager] = struct{}{}
26+ }
27+ case *WriteTool:
28+ if value != nil && value.LSP != nil {
29+ managers[value.LSP] = struct{}{}
30+ }
31+ case *EditTool:
32+ if value != nil && value.LSP != nil {
33+ managers[value.LSP] = struct{}{}
34+ }
35+ }
36+ }
37+ var firstErr error
38+ for manager := range managers {
39+ if err := manager.Close(); err != nil && firstErr == nil {
40+ firstErr = err
41+ }
42+ }
43+ return firstErr
44+}
45+
46+// attachWriteDiagnostics performs a bounded post-write diagnostics pass. A
47+// failed or slow language server must never turn a successful filesystem
48+// mutation into a failed tool call, so callers only attach diagnostics that
49+// were actually returned before the short budget expired.
50+func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) {
51+ if manager == nil || result == nil {
52+ return
53+ }
54+ if ctx == nil {
55+ ctx = context.Background()
56+ }
57+ diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout)
58+ defer cancel()
59+ diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
60+ if err != nil {
61+ return
62+ }
63+ diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics)
64+ if len(diagnostics) == 0 {
65+ return
66+ }
67+ summary := lsp.SummarizeDiagnostics(diagnostics, cwd, 8)
68+ if strings.TrimSpace(summary) == "" || summary == "No diagnostics." {
69+ return
70+ }
71+ for index, block := range result.Content {
72+ text, ok := block.(provider.TextBlock)
73+ if !ok {
74+ continue
75+ }
76+ text.Text = strings.TrimRight(text.Text, "\n") + "\n\n" + summary + "\n"
77+ result.Content[index] = text
78+ break
79+ }
80+ if details, ok := result.Details.(map[string]any); ok {
81+ details["diagnostics"] = diagnostics
82+ details["diagnostics_summary"] = summary
83+ }
84+}
A packages/agent/tools/lsp_test.go +69 -0
@@ -0,0 +1,69 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/agent/lsp"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+func TestLSPToolSchemaAndFormatting(t *testing.T) {
17+ tool := NewLSPTool(t.TempDir(), lsp.NewManager())
18+ var schema map[string]any
19+ if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
20+ t.Fatal(err)
21+ }
22+ if tool.Name() != "lsp" || !strings.Contains(tool.Description(), "diagnostics") {
23+ t.Fatalf("tool metadata missing")
24+ }
25+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"status"}`), nil)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ text, ok := result.Content[0].(provider.TextBlock)
30+ if !ok || text.Text == "" {
31+ t.Fatalf("status result = %#v", result.Content)
32+ }
33+ if !strings.Contains(text.Text, "[") && !strings.Contains(text.Text, "{") {
34+ t.Fatalf("status was not formatted JSON: %q", text.Text)
35+ }
36+}
37+
38+func TestLSPToolGlobRespectsJailForSymlinkMatches(t *testing.T) {
39+ if runtime.GOOS == "windows" {
40+ t.Skip("symlink permissions vary on Windows")
41+ }
42+ root := t.TempDir()
43+ outside := t.TempDir()
44+ outsidePath := filepath.Join(outside, "outside.go")
45+ if err := os.WriteFile(outsidePath, []byte("package outside\\n"), 0o644); err != nil {
46+ t.Fatal(err)
47+ }
48+ if err := os.Symlink(outsidePath, filepath.Join(root, "link.go")); err != nil {
49+ t.Fatal(err)
50+ }
51+ sandbox := NewSandbox(root)
52+ sandbox.Lock()
53+ tool := NewLSPTool(root, lsp.NewManager())
54+ tool.Sandbox = sandbox
55+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"diagnostics","path":"*.go","run_cli":false}`), nil)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if !result.IsError {
60+ t.Fatalf("jailed symlink glob was accepted: %#v", result)
61+ }
62+}
63+
64+func TestBoundLSPText(t *testing.T) {
65+ value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10))
66+ if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") {
67+ t.Fatalf("bound output = %d bytes", len(value))
68+ }
69+}
M packages/agent/tools/read.go +1 -1
@@ -1,4 +1,4 @@
1-// Package tools implements zot's built-in tools: read, write, edit, bash.
1+// Package tools implements zot's built-in tools: read, write, edit, bash, and lsp.
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 sub-agent has the same built-in tools as the main agent (including lsp when enabled) and shares this working directory, but starts with NO context from this conversation."
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