fix(agent): harden LSP process and edit lifecycles
- commit
- bcf27138f111d0889421912afefd7c0ecee06ccf
- author
- brice · · 2026-08-04 12:48 UTC
- committer
- brice · · 2026-08-04 12:56 UTC
- parent
- d007024
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +333 -125
M docs/lsp.md +7 -2
| @@ -130,8 +130,13 @@ explicit tool list omits `lsp`. Both settings can also be controlled through | |||
| 130 | 130 | ||
| 131 | 131 | ## Safety and lifecycle | |
| 132 | 132 | ||
| 133 | - | Language servers and linters run as local child processes. The `lsp` tool never | |
| 134 | - | executes configured commands through a shell. Workspace edits returned by | |
| 133 | + | Language servers and linters run as local child processes. Project and | |
| 134 | + | global LSP configuration is executable local configuration: review configured | |
| 135 | + | commands, arguments, environment variables, and workspace-local binaries before | |
| 136 | + | using it. zot does not add a separate trust prompt or sandbox around those child | |
| 137 | + | processes; it does avoid shell expansion and keeps returned workspace edits | |
| 138 | + | inside the session workspace. The `lsp` tool never executes configured commands | |
| 139 | + | through a shell. Workspace edits returned by | |
| 135 | 140 | rename and code actions are restricted to the session workspace, reject | |
| 136 | 141 | resource operations, validate UTF-16 LSP positions, and write through a | |
| 137 | 142 | temporary file before replacement. Server-initiated `workspace/applyEdit` | |
| @@ -130,8 +130,13 @@ explicit tool list omits `lsp`. Both settings can also be controlled through | |||||
| 130 | 130 | ||||
| 131 | ## Safety and lifecycle | 131 | ## Safety and lifecycle | ||
| 132 | 132 | ||||
| 133 | - | Language servers and linters run as local child processes. The `lsp` tool never | 133 | + | Language servers and linters run as local child processes. Project and |
| 134 | - | executes configured commands through a shell. Workspace edits returned by | 134 | + | global LSP configuration is executable local configuration: review configured |
| 135 | + | commands, arguments, environment variables, and workspace-local binaries before | |||
| 136 | + | using it. zot does not add a separate trust prompt or sandbox around those child | |||
| 137 | + | processes; it does avoid shell expansion and keeps returned workspace edits | |||
| 138 | + | inside the session workspace. The `lsp` tool never executes configured commands | |||
| 139 | + | through a shell. Workspace edits returned by | |||
| 135 | rename and code actions are restricted to the session workspace, reject | 140 | rename and code actions are restricted to the session workspace, reject | ||
| 136 | resource operations, validate UTF-16 LSP positions, and write through a | 141 | resource operations, validate UTF-16 LSP positions, and write through a | ||
| 137 | temporary file before replacement. Server-initiated `workspace/applyEdit` | 142 | temporary file before replacement. Server-initiated `workspace/applyEdit` | ||
M docs/subagents.md +2 -2
| @@ -40,13 +40,13 @@ Supported metadata: | |||
| 40 | 40 | |---|---| | |
| 41 | 41 | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | |
| 42 | 42 | | `description` | Short description shown to the main agent. | | |
| 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. | | |
| 43 | + | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | |
| 44 | 44 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |
| 45 | 45 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | |
| 46 | 46 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | |
| 47 | 47 | | `systemPromptMode` | `append` (default) or `replace`. | | |
| 48 | 48 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |
| 49 | - | | `inheritSkills` | Set to `false` to omit skill discovery from the child. | | |
| 49 | + | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | |
| 50 | 50 | ||
| 51 | 51 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | |
| 52 | 52 | ||
| @@ -40,13 +40,13 @@ Supported metadata: | |||||
| 40 | |---|---| | 40 | |---|---| | ||
| 41 | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | 41 | | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. | | ||
| 42 | | `description` | Short description shown to the main agent. | | 42 | | `description` | Short description shown to the main agent. | | ||
| 43 | - | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; unknown names do not grant capabilities. | | 43 | + | | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | |
| 44 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | 44 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | ||
| 45 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | 45 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | ||
| 46 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | 46 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | ||
| 47 | | `systemPromptMode` | `append` (default) or `replace`. | | 47 | | `systemPromptMode` | `append` (default) or `replace`. | | ||
| 48 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | 48 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | ||
| 49 | - | | `inheritSkills` | Set to `false` to omit skill discovery from the child. | | 49 | + | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | |
| 50 | 50 | ||||
| 51 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | 51 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | ||
| 52 | 52 | ||||
M packages/agent/build.go +6 -2
| @@ -624,7 +624,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 624 | 624 | } | |
| 625 | 625 | subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != "" | |
| 626 | 626 | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession) | |
| 627 | - | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(), cfg.LSPDiagnosticsOnEditEnabled()) | |
| 627 | + | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession)) | |
| 628 | 628 | ||
| 629 | 629 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | |
| 630 | 630 | ||
| @@ -1055,7 +1055,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1055 | 1055 | } | |
| 1056 | 1056 | var manager *lsp.Manager | |
| 1057 | 1057 | if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { | |
| 1058 | - | manager = lsp.NewManager() | |
| 1058 | + | options := lsp.ManagerOptions{} | |
| 1059 | + | if sandbox != nil { | |
| 1060 | + | options.CheckWritePath = sandbox.CheckWritePath | |
| 1061 | + | } | |
| 1062 | + | manager = lsp.NewManagerWithOptions(options) | |
| 1059 | 1063 | } | |
| 1060 | 1064 | all := map[string]core.Tool{ | |
| 1061 | 1065 | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| @@ -624,7 +624,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 624 | } | 624 | } | ||
| 625 | subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != "" | 625 | subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != "" | ||
| 626 | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession) | 626 | lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession) | ||
| 627 | - | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(), cfg.LSPDiagnosticsOnEditEnabled()) | 627 | + | reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession)) |
| 628 | 628 | ||||
| 629 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | 629 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | ||
| 630 | 630 | ||||
| @@ -1055,7 +1055,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1055 | } | 1055 | } | ||
| 1056 | var manager *lsp.Manager | 1056 | var manager *lsp.Manager | ||
| 1057 | if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { | 1057 | if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { | ||
| 1058 | - | manager = lsp.NewManager() | 1058 | + | options := lsp.ManagerOptions{} |
| 1059 | + | if sandbox != nil { | |||
| 1060 | + | options.CheckWritePath = sandbox.CheckWritePath | |||
| 1061 | + | } | |||
| 1062 | + | manager = lsp.NewManagerWithOptions(options) | |||
| 1059 | } | 1063 | } | ||
| 1060 | all := map[string]core.Tool{ | 1064 | all := map[string]core.Tool{ | ||
| 1061 | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | 1065 | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | ||
M packages/agent/cli.go +21 -3
| @@ -658,7 +658,13 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 658 | 658 | defer stopExt() | |
| 659 | 659 | ||
| 660 | 660 | ag := r.NewAgent() | |
| 661 | - | defer closeAgentLSP(ag) | |
| 661 | + | initialAg := ag | |
| 662 | + | defer func() { | |
| 663 | + | closeAgentLSP(ag) | |
| 664 | + | if ag != initialAg { | |
| 665 | + | closeAgentLSP(initialAg) | |
| 666 | + | } | |
| 667 | + | }() | |
| 662 | 668 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 663 | 669 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 664 | 670 | if err != nil { | |
| @@ -713,7 +719,13 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 713 | 719 | defer stopExt() | |
| 714 | 720 | ||
| 715 | 721 | ag := r.NewAgent() | |
| 716 | - | defer closeAgentLSP(ag) | |
| 722 | + | initialAg := ag | |
| 723 | + | defer func() { | |
| 724 | + | closeAgentLSP(ag) | |
| 725 | + | if ag != initialAg { | |
| 726 | + | closeAgentLSP(initialAg) | |
| 727 | + | } | |
| 728 | + | }() | |
| 717 | 729 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 718 | 730 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 719 | 731 | if err != nil { | |
| @@ -807,7 +819,13 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 807 | 819 | defer stopExt() | |
| 808 | 820 | ||
| 809 | 821 | ag := r.NewAgent() | |
| 810 | - | defer closeAgentLSP(ag) | |
| 822 | + | initialAg := ag | |
| 823 | + | defer func() { | |
| 824 | + | closeAgentLSP(ag) | |
| 825 | + | if ag != initialAg { | |
| 826 | + | closeAgentLSP(initialAg) | |
| 827 | + | } | |
| 828 | + | }() | |
| 811 | 829 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 812 | 830 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 813 | 831 | if err != nil { | |
| @@ -658,7 +658,13 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 658 | defer stopExt() | 658 | defer stopExt() | ||
| 659 | 659 | ||||
| 660 | ag := r.NewAgent() | 660 | ag := r.NewAgent() | ||
| 661 | - | defer closeAgentLSP(ag) | 661 | + | initialAg := ag |
| 662 | + | defer func() { | |||
| 663 | + | closeAgentLSP(ag) | |||
| 664 | + | if ag != initialAg { | |||
| 665 | + | closeAgentLSP(initialAg) | |||
| 666 | + | } | |||
| 667 | + | }() | |||
| 662 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 668 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 663 | sess, err := openOrCreateSession(args, r, ag, version) | 669 | sess, err := openOrCreateSession(args, r, ag, version) | ||
| 664 | if err != nil { | 670 | if err != nil { | ||
| @@ -713,7 +719,13 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||||
| 713 | defer stopExt() | 719 | defer stopExt() | ||
| 714 | 720 | ||||
| 715 | ag := r.NewAgent() | 721 | ag := r.NewAgent() | ||
| 716 | - | defer closeAgentLSP(ag) | 722 | + | initialAg := ag |
| 723 | + | defer func() { | |||
| 724 | + | closeAgentLSP(ag) | |||
| 725 | + | if ag != initialAg { | |||
| 726 | + | closeAgentLSP(initialAg) | |||
| 727 | + | } | |||
| 728 | + | }() | |||
| 717 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 729 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 718 | sess, err := openOrCreateSession(args, r, ag, version) | 730 | sess, err := openOrCreateSession(args, r, ag, version) | ||
| 719 | if err != nil { | 731 | if err != nil { | ||
| @@ -807,7 +819,13 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||||
| 807 | defer stopExt() | 819 | defer stopExt() | ||
| 808 | 820 | ||||
| 809 | ag := r.NewAgent() | 821 | ag := r.NewAgent() | ||
| 810 | - | defer closeAgentLSP(ag) | 822 | + | initialAg := ag |
| 823 | + | defer func() { | |||
| 824 | + | closeAgentLSP(ag) | |||
| 825 | + | if ag != initialAg { | |||
| 826 | + | closeAgentLSP(initialAg) | |||
| 827 | + | } | |||
| 828 | + | }() | |||
| 811 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 829 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 812 | sess, err := openOrCreateSession(args, r, ag, version) | 830 | sess, err := openOrCreateSession(args, r, ag, version) | ||
| 813 | if err != nil { | 831 | if err != nil { | ||
M packages/agent/config.go +4 -4
| @@ -226,14 +226,14 @@ func (c Config) LSPEnabledFor(subagent bool) bool { | |||
| 226 | 226 | ||
| 227 | 227 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | |
| 228 | 228 | // preference without requiring a generated config entry. | |
| 229 | - | func (c Config) LSPDiagnosticsOnWriteEnabled() bool { | |
| 230 | - | return c.LSPEnabledFor(false) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) | |
| 229 | + | func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { | |
| 230 | + | return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | 233 | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | |
| 234 | 234 | // preference without requiring a generated config entry. | |
| 235 | - | func (c Config) LSPDiagnosticsOnEditEnabled() bool { | |
| 236 | - | return c.LSPEnabledFor(false) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | |
| 235 | + | func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool { | |
| 236 | + | return c.LSPEnabledFor(subagent) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | // AuthPath returns the path to auth.json. | |
| @@ -226,14 +226,14 @@ func (c Config) LSPEnabledFor(subagent bool) bool { | |||||
| 226 | 226 | ||||
| 227 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | 227 | // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics | ||
| 228 | // preference without requiring a generated config entry. | 228 | // preference without requiring a generated config entry. | ||
| 229 | - | func (c Config) LSPDiagnosticsOnWriteEnabled() bool { | 229 | + | func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { |
| 230 | - | return c.LSPEnabledFor(false) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) | 230 | + | return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) |
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | 233 | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | ||
| 234 | // preference without requiring a generated config entry. | 234 | // preference without requiring a generated config entry. | ||
| 235 | - | func (c Config) LSPDiagnosticsOnEditEnabled() bool { | 235 | + | func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool { |
| 236 | - | return c.LSPEnabledFor(false) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit | 236 | + | return c.LSPEnabledFor(subagent) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit |
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| 239 | // AuthPath returns the path to auth.json. | 239 | // AuthPath returns the path to auth.json. | ||
M packages/agent/lsp/client.go +46 -33
| @@ -58,15 +58,16 @@ type Client struct { | |||
| 58 | 58 | stdout io.ReadCloser | |
| 59 | 59 | stderr io.ReadCloser | |
| 60 | 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 | |
| 61 | + | writeMu sync.Mutex | |
| 62 | + | applyEditMu sync.Mutex | |
| 63 | + | mu sync.Mutex | |
| 64 | + | pending map[int64]chan rpcResponse | |
| 65 | + | nextID int64 | |
| 66 | + | closed bool | |
| 67 | + | closeOnce sync.Once | |
| 68 | + | done chan struct{} | |
| 69 | + | doneOnce sync.Once | |
| 70 | + | readErr atomic.Value // string; one concrete type keeps atomic.Value safe | |
| 70 | 71 | ||
| 71 | 72 | options ClientOptions | |
| 72 | 73 | capMu sync.RWMutex | |
| @@ -259,15 +260,13 @@ func (c *Client) DidOpen(path, languageID, text string) error { | |||
| 259 | 260 | if err != nil { | |
| 260 | 261 | return err | |
| 261 | 262 | } | |
| 262 | - | c.docsMu.Lock() | |
| 263 | - | version := c.docs[uri] | |
| 264 | - | if version == 0 { | |
| 265 | - | version = 1 | |
| 266 | - | } else { | |
| 267 | - | version++ | |
| 263 | + | version, alreadyOpen := c.nextDocumentVersion(uri) | |
| 264 | + | if alreadyOpen { | |
| 265 | + | return c.Notify("textDocument/didChange", map[string]any{ | |
| 266 | + | "textDocument": map[string]any{"uri": uri, "version": version}, | |
| 267 | + | "contentChanges": []map[string]any{{"text": text}}, | |
| 268 | + | }) | |
| 268 | 269 | } | |
| 269 | - | c.docs[uri] = version | |
| 270 | - | c.docsMu.Unlock() | |
| 271 | 270 | return c.Notify("textDocument/didOpen", map[string]any{ | |
| 272 | 271 | "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text}, | |
| 273 | 272 | }) | |
| @@ -280,19 +279,25 @@ func (c *Client) DidChange(path, text string) error { | |||
| 280 | 279 | if err != nil { | |
| 281 | 280 | return err | |
| 282 | 281 | } | |
| 282 | + | version, _ := c.nextDocumentVersion(uri) | |
| 283 | + | return c.Notify("textDocument/didChange", map[string]any{ | |
| 284 | + | "textDocument": map[string]any{"uri": uri, "version": version}, | |
| 285 | + | "contentChanges": []map[string]any{{"text": text}}, | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (c *Client) nextDocumentVersion(uri string) (int, bool) { | |
| 283 | 290 | c.docsMu.Lock() | |
| 291 | + | defer c.docsMu.Unlock() | |
| 284 | 292 | version := c.docs[uri] | |
| 293 | + | alreadyOpen := version != 0 | |
| 285 | 294 | if version == 0 { | |
| 286 | 295 | version = 1 | |
| 287 | 296 | } else { | |
| 288 | 297 | version++ | |
| 289 | 298 | } | |
| 290 | 299 | c.docs[uri] = version | |
| 291 | - | c.docsMu.Unlock() | |
| 292 | - | return c.Notify("textDocument/didChange", map[string]any{ | |
| 293 | - | "textDocument": map[string]any{"uri": uri, "version": version}, | |
| 294 | - | "contentChanges": []map[string]any{{"text": text}}, | |
| 295 | - | }) | |
| 300 | + | return version, alreadyOpen | |
| 296 | 301 | } | |
| 297 | 302 | ||
| 298 | 303 | func (c *Client) DidSave(path, text string) error { | |
| @@ -338,12 +343,10 @@ func (c *Client) readLoop() { | |||
| 338 | 343 | continue | |
| 339 | 344 | } | |
| 340 | 345 | if envelope.Method != "" { | |
| 341 | - | // Diagnostics are ordered state: process publications on the | |
| 342 | - | // reader goroutine so a rapid clear/update sequence cannot be | |
| 343 | - | // reordered by notification handlers. Server requests may | |
| 344 | - | // block while they wait for an edit response, so those remain | |
| 345 | - | // asynchronous. | |
| 346 | - | if envelope.Method == "textDocument/publishDiagnostics" { | |
| 346 | + | // Notifications carry ordered state, so handle them on the | |
| 347 | + | // reader goroutine. Server requests may block while they wait | |
| 348 | + | // for an edit response, so those remain asynchronous. | |
| 349 | + | if len(envelope.ID) == 0 || string(envelope.ID) == "null" { | |
| 347 | 350 | c.handleServerMessage(envelope) | |
| 348 | 351 | } else { | |
| 349 | 352 | go c.handleServerMessage(envelope) | |
| @@ -387,7 +390,9 @@ func (c *Client) handleServerMessage(message rpcEnvelope) { | |||
| 387 | 390 | _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil) | |
| 388 | 391 | return | |
| 389 | 392 | } | |
| 393 | + | c.applyEditMu.Lock() | |
| 390 | 394 | err := c.applyEdit(params.Edit) | |
| 395 | + | c.applyEditMu.Unlock() | |
| 391 | 396 | result := map[string]any{"applied": err == nil} | |
| 392 | 397 | if err != nil { | |
| 393 | 398 | result["failureReason"] = err.Error() | |
| @@ -449,7 +454,7 @@ func (c *Client) applyEdit(edit WorkspaceEdit) error { | |||
| 449 | 454 | } | |
| 450 | 455 | ||
| 451 | 456 | func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error { | |
| 452 | - | message := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(id)} | |
| 457 | + | message := map[string]any{"jsonrpc": "2.0", "id": id} | |
| 453 | 458 | if rpcErr != nil { | |
| 454 | 459 | message["error"] = rpcErr | |
| 455 | 460 | } else { | |
| @@ -538,10 +543,18 @@ func (c *Client) Close() error { | |||
| 538 | 543 | _ = c.stdin.Close() | |
| 539 | 544 | c.writeMu.Unlock() | |
| 540 | 545 | killed := false | |
| 541 | - | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | |
| 542 | - | killed = c.cmd.Process.Kill() == nil | |
| 546 | + | exited := make(chan error, 1) | |
| 547 | + | go func() { exited <- c.cmd.Wait() }() | |
| 548 | + | timer := time.NewTimer(500 * time.Millisecond) | |
| 549 | + | select { | |
| 550 | + | case waitErr = <-exited: | |
| 551 | + | timer.Stop() | |
| 552 | + | case <-timer.C: | |
| 553 | + | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | |
| 554 | + | killed = c.cmd.Process.Kill() == nil | |
| 555 | + | } | |
| 556 | + | waitErr = <-exited | |
| 543 | 557 | } | |
| 544 | - | waitErr = c.cmd.Wait() | |
| 545 | 558 | if killed { | |
| 546 | 559 | // A process terminated by this cleanup path normally returns an | |
| 547 | 560 | // ExitError; preserve unrelated wait failures such as pipe | |
| @@ -603,7 +616,7 @@ func ResolveCommand(cwd, command string) (string, error) { | |||
| 603 | 616 | // executable lives at the repository root. Search each workspace | |
| 604 | 617 | // ancestor before falling back to PATH. | |
| 605 | 618 | for dir := cwd; dir != ""; dir = filepath.Dir(dir) { | |
| 606 | - | for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), "bin"} { | |
| 619 | + | for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), filepath.Join(".venv", "Scripts"), "bin", "Scripts"} { | |
| 607 | 620 | candidates = append(candidates, filepath.Join(dir, local, command)) | |
| 608 | 621 | } | |
| 609 | 622 | next := filepath.Dir(dir) | |
| @@ -58,15 +58,16 @@ type Client struct { | |||||
| 58 | stdout io.ReadCloser | 58 | stdout io.ReadCloser | ||
| 59 | stderr io.ReadCloser | 59 | stderr io.ReadCloser | ||
| 60 | 60 | ||||
| 61 | - | writeMu sync.Mutex | 61 | + | writeMu sync.Mutex |
| 62 | - | mu sync.Mutex | 62 | + | applyEditMu sync.Mutex |
| 63 | - | pending map[int64]chan rpcResponse | 63 | + | mu sync.Mutex |
| 64 | - | nextID int64 | 64 | + | pending map[int64]chan rpcResponse |
| 65 | - | closed bool | 65 | + | nextID int64 |
| 66 | - | closeOnce sync.Once | 66 | + | closed bool |
| 67 | - | done chan struct{} | 67 | + | closeOnce sync.Once |
| 68 | - | doneOnce sync.Once | 68 | + | done chan struct{} |
| 69 | - | readErr atomic.Value // string; one concrete type keeps atomic.Value safe | 69 | + | doneOnce sync.Once |
| 70 | + | readErr atomic.Value // string; one concrete type keeps atomic.Value safe | |||
| 70 | 71 | ||||
| 71 | options ClientOptions | 72 | options ClientOptions | ||
| 72 | capMu sync.RWMutex | 73 | capMu sync.RWMutex | ||
| @@ -259,15 +260,13 @@ func (c *Client) DidOpen(path, languageID, text string) error { | |||||
| 259 | if err != nil { | 260 | if err != nil { | ||
| 260 | return err | 261 | return err | ||
| 261 | } | 262 | } | ||
| 262 | - | c.docsMu.Lock() | 263 | + | version, alreadyOpen := c.nextDocumentVersion(uri) |
| 263 | - | version := c.docs[uri] | 264 | + | if alreadyOpen { |
| 264 | - | if version == 0 { | 265 | + | return c.Notify("textDocument/didChange", map[string]any{ |
| 265 | - | version = 1 | 266 | + | "textDocument": map[string]any{"uri": uri, "version": version}, |
| 266 | - | } else { | 267 | + | "contentChanges": []map[string]any{{"text": text}}, |
| 267 | - | version++ | 268 | + | }) |
| 268 | } | 269 | } | ||
| 269 | - | c.docs[uri] = version | |||
| 270 | - | c.docsMu.Unlock() | |||
| 271 | return c.Notify("textDocument/didOpen", map[string]any{ | 270 | return c.Notify("textDocument/didOpen", map[string]any{ | ||
| 272 | "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text}, | 271 | "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text}, | ||
| 273 | }) | 272 | }) | ||
| @@ -280,19 +279,25 @@ func (c *Client) DidChange(path, text string) error { | |||||
| 280 | if err != nil { | 279 | if err != nil { | ||
| 281 | return err | 280 | return err | ||
| 282 | } | 281 | } | ||
| 282 | + | version, _ := c.nextDocumentVersion(uri) | |||
| 283 | + | return c.Notify("textDocument/didChange", map[string]any{ | |||
| 284 | + | "textDocument": map[string]any{"uri": uri, "version": version}, | |||
| 285 | + | "contentChanges": []map[string]any{{"text": text}}, | |||
| 286 | + | }) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func (c *Client) nextDocumentVersion(uri string) (int, bool) { | |||
| 283 | c.docsMu.Lock() | 290 | c.docsMu.Lock() | ||
| 291 | + | defer c.docsMu.Unlock() | |||
| 284 | version := c.docs[uri] | 292 | version := c.docs[uri] | ||
| 293 | + | alreadyOpen := version != 0 | |||
| 285 | if version == 0 { | 294 | if version == 0 { | ||
| 286 | version = 1 | 295 | version = 1 | ||
| 287 | } else { | 296 | } else { | ||
| 288 | version++ | 297 | version++ | ||
| 289 | } | 298 | } | ||
| 290 | c.docs[uri] = version | 299 | c.docs[uri] = version | ||
| 291 | - | c.docsMu.Unlock() | 300 | + | return version, alreadyOpen |
| 292 | - | return c.Notify("textDocument/didChange", map[string]any{ | |||
| 293 | - | "textDocument": map[string]any{"uri": uri, "version": version}, | |||
| 294 | - | "contentChanges": []map[string]any{{"text": text}}, | |||
| 295 | - | }) | |||
| 296 | } | 301 | } | ||
| 297 | 302 | ||||
| 298 | func (c *Client) DidSave(path, text string) error { | 303 | func (c *Client) DidSave(path, text string) error { | ||
| @@ -338,12 +343,10 @@ func (c *Client) readLoop() { | |||||
| 338 | continue | 343 | continue | ||
| 339 | } | 344 | } | ||
| 340 | if envelope.Method != "" { | 345 | if envelope.Method != "" { | ||
| 341 | - | // Diagnostics are ordered state: process publications on the | 346 | + | // Notifications carry ordered state, so handle them on the |
| 342 | - | // reader goroutine so a rapid clear/update sequence cannot be | 347 | + | // reader goroutine. Server requests may block while they wait |
| 343 | - | // reordered by notification handlers. Server requests may | 348 | + | // for an edit response, so those remain asynchronous. |
| 344 | - | // block while they wait for an edit response, so those remain | 349 | + | if len(envelope.ID) == 0 || string(envelope.ID) == "null" { |
| 345 | - | // asynchronous. | |||
| 346 | - | if envelope.Method == "textDocument/publishDiagnostics" { | |||
| 347 | c.handleServerMessage(envelope) | 350 | c.handleServerMessage(envelope) | ||
| 348 | } else { | 351 | } else { | ||
| 349 | go c.handleServerMessage(envelope) | 352 | go c.handleServerMessage(envelope) | ||
| @@ -387,7 +390,9 @@ func (c *Client) handleServerMessage(message rpcEnvelope) { | |||||
| 387 | _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil) | 390 | _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil) | ||
| 388 | return | 391 | return | ||
| 389 | } | 392 | } | ||
| 393 | + | c.applyEditMu.Lock() | |||
| 390 | err := c.applyEdit(params.Edit) | 394 | err := c.applyEdit(params.Edit) | ||
| 395 | + | c.applyEditMu.Unlock() | |||
| 391 | result := map[string]any{"applied": err == nil} | 396 | result := map[string]any{"applied": err == nil} | ||
| 392 | if err != nil { | 397 | if err != nil { | ||
| 393 | result["failureReason"] = err.Error() | 398 | result["failureReason"] = err.Error() | ||
| @@ -449,7 +454,7 @@ func (c *Client) applyEdit(edit WorkspaceEdit) error { | |||||
| 449 | } | 454 | } | ||
| 450 | 455 | ||||
| 451 | func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error { | 456 | func (c *Client) respond(id json.RawMessage, result any, rpcErr *rpcError) error { | ||
| 452 | - | message := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(id)} | 457 | + | message := map[string]any{"jsonrpc": "2.0", "id": id} |
| 453 | if rpcErr != nil { | 458 | if rpcErr != nil { | ||
| 454 | message["error"] = rpcErr | 459 | message["error"] = rpcErr | ||
| 455 | } else { | 460 | } else { | ||
| @@ -538,10 +543,18 @@ func (c *Client) Close() error { | |||||
| 538 | _ = c.stdin.Close() | 543 | _ = c.stdin.Close() | ||
| 539 | c.writeMu.Unlock() | 544 | c.writeMu.Unlock() | ||
| 540 | killed := false | 545 | killed := false | ||
| 541 | - | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | 546 | + | exited := make(chan error, 1) |
| 542 | - | killed = c.cmd.Process.Kill() == nil | 547 | + | go func() { exited <- c.cmd.Wait() }() |
| 548 | + | timer := time.NewTimer(500 * time.Millisecond) | |||
| 549 | + | select { | |||
| 550 | + | case waitErr = <-exited: | |||
| 551 | + | timer.Stop() | |||
| 552 | + | case <-timer.C: | |||
| 553 | + | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | |||
| 554 | + | killed = c.cmd.Process.Kill() == nil | |||
| 555 | + | } | |||
| 556 | + | waitErr = <-exited | |||
| 543 | } | 557 | } | ||
| 544 | - | waitErr = c.cmd.Wait() | |||
| 545 | if killed { | 558 | if killed { | ||
| 546 | // A process terminated by this cleanup path normally returns an | 559 | // A process terminated by this cleanup path normally returns an | ||
| 547 | // ExitError; preserve unrelated wait failures such as pipe | 560 | // ExitError; preserve unrelated wait failures such as pipe | ||
| @@ -603,7 +616,7 @@ func ResolveCommand(cwd, command string) (string, error) { | |||||
| 603 | // executable lives at the repository root. Search each workspace | 616 | // executable lives at the repository root. Search each workspace | ||
| 604 | // ancestor before falling back to PATH. | 617 | // ancestor before falling back to PATH. | ||
| 605 | for dir := cwd; dir != ""; dir = filepath.Dir(dir) { | 618 | for dir := cwd; dir != ""; dir = filepath.Dir(dir) { | ||
| 606 | - | for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), "bin"} { | 619 | + | for _, local := range []string{filepath.Join("node_modules", ".bin"), filepath.Join(".venv", "bin"), filepath.Join(".venv", "Scripts"), "bin", "Scripts"} { |
| 607 | candidates = append(candidates, filepath.Join(dir, local, command)) | 620 | candidates = append(candidates, filepath.Join(dir, local, command)) | ||
| 608 | } | 621 | } | ||
| 609 | next := filepath.Dir(dir) | 622 | next := filepath.Dir(dir) | ||
M packages/agent/lsp/config.go +5 -2
| @@ -57,7 +57,7 @@ func BuiltinServers() []ServerConfig { | |||
| 57 | 57 | {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"}, | |
| 58 | 58 | {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"}, | |
| 59 | 59 | {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"}, | |
| 60 | - | {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--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: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--output.json.path", "stdout"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"}, | |
| 61 | 61 | {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"}, | |
| 62 | 62 | } | |
| 63 | 63 | } | |
| @@ -219,7 +219,10 @@ func hasMatchingFile(cwd string, types []string) bool { | |||
| 219 | 219 | normalizedTypes := normalizeFileTypes(types) | |
| 220 | 220 | var found bool | |
| 221 | 221 | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | |
| 222 | - | if err != nil || found { | |
| 222 | + | if err != nil { | |
| 223 | + | return nil | |
| 224 | + | } | |
| 225 | + | if found { | |
| 223 | 226 | return filepath.SkipAll | |
| 224 | 227 | } | |
| 225 | 228 | if entry.IsDir() { | |
| @@ -57,7 +57,7 @@ func BuiltinServers() []ServerConfig { | |||||
| 57 | {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"}, | 57 | {ID: "vscode-json-language-server", Kind: "lsp", Command: "vscode-json-language-server", Args: []string{"--stdio"}, FileTypes: []string{"json", "jsonc", ".json", ".jsonc"}, Description: "JSON language server"}, | ||
| 58 | {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"}, | 58 | {ID: "bash-language-server", Kind: "lsp", Command: "bash-language-server", Args: []string{"start"}, FileTypes: []string{"shellscript", "bash", "sh", "zsh", ".sh", ".bash", ".zsh"}, RootMarkers: []string{".shellcheckrc"}, Description: "Bash language server"}, | ||
| 59 | {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"}, | 59 | {ID: "eslint", Kind: "cli", Command: "eslint", Args: []string{"--format", "json"}, FileTypes: []string{"javascript", "javascriptreact", "typescript", "typescriptreact", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, RootMarkers: []string{"eslint.config.js", "eslint.config.cjs", "eslint.config.mjs", "eslint.config.ts", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml"}, Parser: "eslint-json", Mode: "files", Description: "ESLint diagnostics"}, | ||
| 60 | - | {ID: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--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: "golangci-lint", Kind: "cli", Command: "golangci-lint", Args: []string{"run", "--output.json.path", "stdout"}, FileTypes: []string{"go", ".go"}, RootMarkers: []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}, Parser: "golangci-lint-json", Mode: "workspace", Description: "golangci-lint diagnostics"}, |
| 61 | {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"}, | 61 | {ID: "ruff", Kind: "cli", Command: "ruff", Args: []string{"check", "--output-format", "json"}, FileTypes: []string{"python", "py", ".py"}, RootMarkers: []string{"ruff.toml", ".ruff.toml", "pyproject.toml"}, Parser: "ruff-json", Mode: "files", Description: "Ruff diagnostics"}, | ||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| @@ -219,7 +219,10 @@ func hasMatchingFile(cwd string, types []string) bool { | |||||
| 219 | normalizedTypes := normalizeFileTypes(types) | 219 | normalizedTypes := normalizeFileTypes(types) | ||
| 220 | var found bool | 220 | var found bool | ||
| 221 | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | 221 | _ = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, err error) error { | ||
| 222 | - | if err != nil || found { | 222 | + | if err != nil { |
| 223 | + | return nil | |||
| 224 | + | } | |||
| 225 | + | if found { | |||
| 223 | return filepath.SkipAll | 226 | return filepath.SkipAll | ||
| 224 | } | 227 | } | ||
| 225 | if entry.IsDir() { | 228 | if entry.IsDir() { | ||
M packages/agent/lsp/diagnostics.go +5 -5
| @@ -151,10 +151,10 @@ func SummarizeDiagnostics(input []Diagnostic, root string, cap int) string { | |||
| 151 | 151 | displayed++ | |
| 152 | 152 | } | |
| 153 | 153 | if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] { | |
| 154 | - | if secondaryLine := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" { | |
| 154 | + | if secondaryLine, secondaryCount := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" { | |
| 155 | 155 | lines = append(lines, "- "+secondaryLine) | |
| 156 | 156 | emittedSecondary[diagnostic.Path] = true | |
| 157 | - | displayed++ | |
| 157 | + | displayed += secondaryCount | |
| 158 | 158 | } | |
| 159 | 159 | } | |
| 160 | 160 | } | |
| @@ -235,7 +235,7 @@ func moduleSecondary(diagnostics []Diagnostic) map[string]bool { | |||
| 235 | 235 | return secondary | |
| 236 | 236 | } | |
| 237 | 237 | ||
| 238 | - | func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) string { | |
| 238 | + | func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) (string, int) { | |
| 239 | 239 | count := 0 | |
| 240 | 240 | modules := make(map[string]bool) | |
| 241 | 241 | for _, diagnostic := range diagnostics { | |
| @@ -247,7 +247,7 @@ func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map | |||
| 247 | 247 | } | |
| 248 | 248 | } | |
| 249 | 249 | if count == 0 { | |
| 250 | - | return "" | |
| 250 | + | return "", 0 | |
| 251 | 251 | } | |
| 252 | 252 | shownPath := path | |
| 253 | 253 | if root != "" { | |
| @@ -260,7 +260,7 @@ func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map | |||
| 260 | 260 | moduleNames = append(moduleNames, name) | |
| 261 | 261 | } | |
| 262 | 262 | sort.Strings(moduleNames) | |
| 263 | - | return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", ")) | |
| 263 | + | return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", ")), count | |
| 264 | 264 | } | |
| 265 | 265 | ||
| 266 | 266 | func missingModuleName(diagnostic Diagnostic) string { | |
| @@ -151,10 +151,10 @@ func SummarizeDiagnostics(input []Diagnostic, root string, cap int) string { | |||||
| 151 | displayed++ | 151 | displayed++ | ||
| 152 | } | 152 | } | ||
| 153 | if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] { | 153 | if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] { | ||
| 154 | - | if secondaryLine := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" { | 154 | + | if secondaryLine, secondaryCount := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" { |
| 155 | lines = append(lines, "- "+secondaryLine) | 155 | lines = append(lines, "- "+secondaryLine) | ||
| 156 | emittedSecondary[diagnostic.Path] = true | 156 | emittedSecondary[diagnostic.Path] = true | ||
| 157 | - | displayed++ | 157 | + | displayed += secondaryCount |
| 158 | } | 158 | } | ||
| 159 | } | 159 | } | ||
| 160 | } | 160 | } | ||
| @@ -235,7 +235,7 @@ func moduleSecondary(diagnostics []Diagnostic) map[string]bool { | |||||
| 235 | return secondary | 235 | return secondary | ||
| 236 | } | 236 | } | ||
| 237 | 237 | ||||
| 238 | - | func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) string { | 238 | + | func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) (string, int) { |
| 239 | count := 0 | 239 | count := 0 | ||
| 240 | modules := make(map[string]bool) | 240 | modules := make(map[string]bool) | ||
| 241 | for _, diagnostic := range diagnostics { | 241 | for _, diagnostic := range diagnostics { | ||
| @@ -247,7 +247,7 @@ func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map | |||||
| 247 | } | 247 | } | ||
| 248 | } | 248 | } | ||
| 249 | if count == 0 { | 249 | if count == 0 { | ||
| 250 | - | return "" | 250 | + | return "", 0 |
| 251 | } | 251 | } | ||
| 252 | shownPath := path | 252 | shownPath := path | ||
| 253 | if root != "" { | 253 | if root != "" { | ||
| @@ -260,7 +260,7 @@ func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map | |||||
| 260 | moduleNames = append(moduleNames, name) | 260 | moduleNames = append(moduleNames, name) | ||
| 261 | } | 261 | } | ||
| 262 | sort.Strings(moduleNames) | 262 | sort.Strings(moduleNames) | ||
| 263 | - | return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", ")) | 263 | + | return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", ")), count |
| 264 | } | 264 | } | ||
| 265 | 265 | ||||
| 266 | func missingModuleName(diagnostic Diagnostic) string { | 266 | func missingModuleName(diagnostic Diagnostic) string { | ||
M packages/agent/lsp/edits.go +55 -23
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package lsp | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| @@ -15,6 +16,10 @@ const maxWorkspaceEditFileBytes = 8 * 1024 * 1024 | |||
| 15 | 16 | // rename, delete, non-file URIs, symlink escapes, malformed ranges, and files | |
| 16 | 17 | // larger than the bounded safety limit. | |
| 17 | 18 | func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |
| 19 | + | return applyWorkspaceEdit(root, edit, nil) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func applyWorkspaceEdit(root string, edit WorkspaceEdit, checkWritePath func(string) error) error { | |
| 18 | 23 | root, err := filepath.Abs(root) | |
| 19 | 24 | if err != nil { | |
| 20 | 25 | return err | |
| @@ -43,13 +48,29 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |||
| 43 | 48 | } | |
| 44 | 49 | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | |
| 45 | 50 | } | |
| 46 | - | targets, err := prepareEditTargets(rootReal, all) | |
| 51 | + | targets, err := prepareEditTargets(rootReal, all, checkWritePath) | |
| 47 | 52 | if err != nil { | |
| 48 | 53 | return err | |
| 49 | 54 | } | |
| 55 | + | type plannedEdit struct { | |
| 56 | + | location editTarget | |
| 57 | + | output []byte | |
| 58 | + | mode os.FileMode | |
| 59 | + | } | |
| 60 | + | plans := make([]plannedEdit, 0, len(targets)) | |
| 50 | 61 | for _, location := range targets { | |
| 51 | - | if err := applyTextEdits(location.target, location.changes); err != nil { | |
| 52 | - | return fmt.Errorf("apply edit to %s: %w", location.path, err) | |
| 62 | + | if len(location.changes) == 0 { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | output, mode, err := prepareTextEdits(location.target, location.changes) | |
| 66 | + | if err != nil { | |
| 67 | + | return fmt.Errorf("validate edit to %s: %w", location.path, err) | |
| 68 | + | } | |
| 69 | + | plans = append(plans, plannedEdit{location: location, output: output, mode: mode}) | |
| 70 | + | } | |
| 71 | + | for _, plan := range plans { | |
| 72 | + | if err := writeTextEdits(plan.location.target, plan.output, plan.mode); err != nil { | |
| 73 | + | return fmt.Errorf("apply edit to %s: %w", plan.location.path, err) | |
| 53 | 74 | } | |
| 54 | 75 | } | |
| 55 | 76 | return nil | |
| @@ -61,7 +82,7 @@ type editTarget struct { | |||
| 61 | 82 | changes []TextEdit | |
| 62 | 83 | } | |
| 63 | 84 | ||
| 64 | - | func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarget, error) { | |
| 85 | + | func prepareEditTargets(rootReal string, all map[string][]TextEdit, checkWritePath func(string) error) ([]editTarget, error) { | |
| 65 | 86 | targets := make([]editTarget, 0, len(all)) | |
| 66 | 87 | for uri, changes := range all { | |
| 67 | 88 | path, err := uriToPath(uri) | |
| @@ -78,6 +99,11 @@ func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarge | |||
| 78 | 99 | if err := safeWorkspacePath(rootReal, target); err != nil { | |
| 79 | 100 | return nil, err | |
| 80 | 101 | } | |
| 102 | + | if checkWritePath != nil { | |
| 103 | + | if err := checkWritePath(target); err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | } | |
| 81 | 107 | targets = append(targets, editTarget{path: path, target: target, changes: changes}) | |
| 82 | 108 | } | |
| 83 | 109 | return targets, nil | |
| @@ -89,12 +115,12 @@ func safeWorkspacePath(root, path string) error { | |||
| 89 | 115 | return err | |
| 90 | 116 | } | |
| 91 | 117 | candidate := abs | |
| 92 | - | if real, err := filepath.EvalSymlinks(abs); err == nil { | |
| 93 | - | candidate = real | |
| 118 | + | if resolved, err := filepath.EvalSymlinks(abs); err == nil { | |
| 119 | + | candidate = resolved | |
| 94 | 120 | } else { | |
| 95 | 121 | parent := filepath.Dir(abs) | |
| 96 | - | if real, parentErr := filepath.EvalSymlinks(parent); parentErr == nil { | |
| 97 | - | candidate = filepath.Join(real, filepath.Base(abs)) | |
| 122 | + | if resolvedParent, parentErr := filepath.EvalSymlinks(parent); parentErr == nil { | |
| 123 | + | candidate = filepath.Join(resolvedParent, filepath.Base(abs)) | |
| 98 | 124 | } | |
| 99 | 125 | } | |
| 100 | 126 | rel, err := filepath.Rel(root, candidate) | |
| @@ -110,33 +136,30 @@ type byteEdit struct { | |||
| 110 | 136 | text string | |
| 111 | 137 | } | |
| 112 | 138 | ||
| 113 | - | func applyTextEdits(path string, edits []TextEdit) error { | |
| 114 | - | if len(edits) == 0 { | |
| 115 | - | return nil | |
| 116 | - | } | |
| 139 | + | func prepareTextEdits(path string, edits []TextEdit) ([]byte, os.FileMode, error) { | |
| 117 | 140 | info, err := os.Stat(path) | |
| 118 | 141 | if err != nil { | |
| 119 | - | return err | |
| 142 | + | return nil, 0, err | |
| 120 | 143 | } | |
| 121 | 144 | if info.Size() > maxWorkspaceEditFileBytes { | |
| 122 | - | return fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes) | |
| 145 | + | return nil, 0, fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes) | |
| 123 | 146 | } | |
| 124 | 147 | data, err := os.ReadFile(path) | |
| 125 | 148 | if err != nil { | |
| 126 | - | return err | |
| 149 | + | return nil, 0, err | |
| 127 | 150 | } | |
| 128 | 151 | converted := make([]byteEdit, 0, len(edits)) | |
| 129 | 152 | for _, edit := range edits { | |
| 130 | 153 | start, err := positionOffset(data, edit.Range.Start) | |
| 131 | 154 | if err != nil { | |
| 132 | - | return err | |
| 155 | + | return nil, 0, err | |
| 133 | 156 | } | |
| 134 | 157 | end, err := positionOffset(data, edit.Range.End) | |
| 135 | 158 | if err != nil { | |
| 136 | - | return err | |
| 159 | + | return nil, 0, err | |
| 137 | 160 | } | |
| 138 | 161 | if end < start { | |
| 139 | - | return fmt.Errorf("edit range ends before it starts") | |
| 162 | + | return nil, 0, fmt.Errorf("edit range ends before it starts") | |
| 140 | 163 | } | |
| 141 | 164 | converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText}) | |
| 142 | 165 | } | |
| @@ -148,20 +171,29 @@ func applyTextEdits(path string, edits []TextEdit) error { | |||
| 148 | 171 | }) | |
| 149 | 172 | for i := 1; i < len(converted); i++ { | |
| 150 | 173 | if converted[i].end > converted[i-1].start { | |
| 151 | - | return fmt.Errorf("workspace text edits overlap") | |
| 174 | + | return nil, 0, fmt.Errorf("workspace text edits overlap") | |
| 152 | 175 | } | |
| 153 | 176 | } | |
| 154 | - | out := append([]byte(nil), data...) | |
| 155 | - | for _, edit := range converted { | |
| 156 | - | out = append(append(append([]byte(nil), out[:edit.start]...), []byte(edit.text)...), out[edit.end:]...) | |
| 177 | + | var builder bytes.Buffer | |
| 178 | + | cursor := 0 | |
| 179 | + | for i := len(converted) - 1; i >= 0; i-- { | |
| 180 | + | edit := converted[i] | |
| 181 | + | _, _ = builder.Write(data[cursor:edit.start]) | |
| 182 | + | _, _ = builder.WriteString(edit.text) | |
| 183 | + | cursor = edit.end | |
| 157 | 184 | } | |
| 185 | + | _, _ = builder.Write(data[cursor:]) | |
| 186 | + | return builder.Bytes(), info.Mode().Perm(), nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func writeTextEdits(path string, out []byte, mode os.FileMode) error { | |
| 158 | 190 | tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*") | |
| 159 | 191 | if err != nil { | |
| 160 | 192 | return err | |
| 161 | 193 | } | |
| 162 | 194 | tmpName := tmp.Name() | |
| 163 | 195 | defer func() { _ = os.Remove(tmpName) }() | |
| 164 | - | if err := tmp.Chmod(info.Mode().Perm()); err != nil { | |
| 196 | + | if err := tmp.Chmod(mode); err != nil { | |
| 165 | 197 | _ = tmp.Close() | |
| 166 | 198 | return err | |
| 167 | 199 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package lsp | 1 | package lsp | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "os" | 7 | "os" | ||
| @@ -15,6 +16,10 @@ const maxWorkspaceEditFileBytes = 8 * 1024 * 1024 | |||||
| 15 | // rename, delete, non-file URIs, symlink escapes, malformed ranges, and files | 16 | // rename, delete, non-file URIs, symlink escapes, malformed ranges, and files | ||
| 16 | // larger than the bounded safety limit. | 17 | // larger than the bounded safety limit. | ||
| 17 | func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | 18 | func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | ||
| 19 | + | return applyWorkspaceEdit(root, edit, nil) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func applyWorkspaceEdit(root string, edit WorkspaceEdit, checkWritePath func(string) error) error { | |||
| 18 | root, err := filepath.Abs(root) | 23 | root, err := filepath.Abs(root) | ||
| 19 | if err != nil { | 24 | if err != nil { | ||
| 20 | return err | 25 | return err | ||
| @@ -43,13 +48,29 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |||||
| 43 | } | 48 | } | ||
| 44 | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | 49 | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | ||
| 45 | } | 50 | } | ||
| 46 | - | targets, err := prepareEditTargets(rootReal, all) | 51 | + | targets, err := prepareEditTargets(rootReal, all, checkWritePath) |
| 47 | if err != nil { | 52 | if err != nil { | ||
| 48 | return err | 53 | return err | ||
| 49 | } | 54 | } | ||
| 55 | + | type plannedEdit struct { | |||
| 56 | + | location editTarget | |||
| 57 | + | output []byte | |||
| 58 | + | mode os.FileMode | |||
| 59 | + | } | |||
| 60 | + | plans := make([]plannedEdit, 0, len(targets)) | |||
| 50 | for _, location := range targets { | 61 | for _, location := range targets { | ||
| 51 | - | if err := applyTextEdits(location.target, location.changes); err != nil { | 62 | + | if len(location.changes) == 0 { |
| 52 | - | return fmt.Errorf("apply edit to %s: %w", location.path, err) | 63 | + | continue |
| 64 | + | } | |||
| 65 | + | output, mode, err := prepareTextEdits(location.target, location.changes) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return fmt.Errorf("validate edit to %s: %w", location.path, err) | |||
| 68 | + | } | |||
| 69 | + | plans = append(plans, plannedEdit{location: location, output: output, mode: mode}) | |||
| 70 | + | } | |||
| 71 | + | for _, plan := range plans { | |||
| 72 | + | if err := writeTextEdits(plan.location.target, plan.output, plan.mode); err != nil { | |||
| 73 | + | return fmt.Errorf("apply edit to %s: %w", plan.location.path, err) | |||
| 53 | } | 74 | } | ||
| 54 | } | 75 | } | ||
| 55 | return nil | 76 | return nil | ||
| @@ -61,7 +82,7 @@ type editTarget struct { | |||||
| 61 | changes []TextEdit | 82 | changes []TextEdit | ||
| 62 | } | 83 | } | ||
| 63 | 84 | ||||
| 64 | - | func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarget, error) { | 85 | + | func prepareEditTargets(rootReal string, all map[string][]TextEdit, checkWritePath func(string) error) ([]editTarget, error) { |
| 65 | targets := make([]editTarget, 0, len(all)) | 86 | targets := make([]editTarget, 0, len(all)) | ||
| 66 | for uri, changes := range all { | 87 | for uri, changes := range all { | ||
| 67 | path, err := uriToPath(uri) | 88 | path, err := uriToPath(uri) | ||
| @@ -78,6 +99,11 @@ func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarge | |||||
| 78 | if err := safeWorkspacePath(rootReal, target); err != nil { | 99 | if err := safeWorkspacePath(rootReal, target); err != nil { | ||
| 79 | return nil, err | 100 | return nil, err | ||
| 80 | } | 101 | } | ||
| 102 | + | if checkWritePath != nil { | |||
| 103 | + | if err := checkWritePath(target); err != nil { | |||
| 104 | + | return nil, err | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 81 | targets = append(targets, editTarget{path: path, target: target, changes: changes}) | 107 | targets = append(targets, editTarget{path: path, target: target, changes: changes}) | ||
| 82 | } | 108 | } | ||
| 83 | return targets, nil | 109 | return targets, nil | ||
| @@ -89,12 +115,12 @@ func safeWorkspacePath(root, path string) error { | |||||
| 89 | return err | 115 | return err | ||
| 90 | } | 116 | } | ||
| 91 | candidate := abs | 117 | candidate := abs | ||
| 92 | - | if real, err := filepath.EvalSymlinks(abs); err == nil { | 118 | + | if resolved, err := filepath.EvalSymlinks(abs); err == nil { |
| 93 | - | candidate = real | 119 | + | candidate = resolved |
| 94 | } else { | 120 | } else { | ||
| 95 | parent := filepath.Dir(abs) | 121 | parent := filepath.Dir(abs) | ||
| 96 | - | if real, parentErr := filepath.EvalSymlinks(parent); parentErr == nil { | 122 | + | if resolvedParent, parentErr := filepath.EvalSymlinks(parent); parentErr == nil { |
| 97 | - | candidate = filepath.Join(real, filepath.Base(abs)) | 123 | + | candidate = filepath.Join(resolvedParent, filepath.Base(abs)) |
| 98 | } | 124 | } | ||
| 99 | } | 125 | } | ||
| 100 | rel, err := filepath.Rel(root, candidate) | 126 | rel, err := filepath.Rel(root, candidate) | ||
| @@ -110,33 +136,30 @@ type byteEdit struct { | |||||
| 110 | text string | 136 | text string | ||
| 111 | } | 137 | } | ||
| 112 | 138 | ||||
| 113 | - | func applyTextEdits(path string, edits []TextEdit) error { | 139 | + | func prepareTextEdits(path string, edits []TextEdit) ([]byte, os.FileMode, error) { |
| 114 | - | if len(edits) == 0 { | |||
| 115 | - | return nil | |||
| 116 | - | } | |||
| 117 | info, err := os.Stat(path) | 140 | info, err := os.Stat(path) | ||
| 118 | if err != nil { | 141 | if err != nil { | ||
| 119 | - | return err | 142 | + | return nil, 0, err |
| 120 | } | 143 | } | ||
| 121 | if info.Size() > maxWorkspaceEditFileBytes { | 144 | if info.Size() > maxWorkspaceEditFileBytes { | ||
| 122 | - | return fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes) | 145 | + | return nil, 0, fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes) |
| 123 | } | 146 | } | ||
| 124 | data, err := os.ReadFile(path) | 147 | data, err := os.ReadFile(path) | ||
| 125 | if err != nil { | 148 | if err != nil { | ||
| 126 | - | return err | 149 | + | return nil, 0, err |
| 127 | } | 150 | } | ||
| 128 | converted := make([]byteEdit, 0, len(edits)) | 151 | converted := make([]byteEdit, 0, len(edits)) | ||
| 129 | for _, edit := range edits { | 152 | for _, edit := range edits { | ||
| 130 | start, err := positionOffset(data, edit.Range.Start) | 153 | start, err := positionOffset(data, edit.Range.Start) | ||
| 131 | if err != nil { | 154 | if err != nil { | ||
| 132 | - | return err | 155 | + | return nil, 0, err |
| 133 | } | 156 | } | ||
| 134 | end, err := positionOffset(data, edit.Range.End) | 157 | end, err := positionOffset(data, edit.Range.End) | ||
| 135 | if err != nil { | 158 | if err != nil { | ||
| 136 | - | return err | 159 | + | return nil, 0, err |
| 137 | } | 160 | } | ||
| 138 | if end < start { | 161 | if end < start { | ||
| 139 | - | return fmt.Errorf("edit range ends before it starts") | 162 | + | return nil, 0, fmt.Errorf("edit range ends before it starts") |
| 140 | } | 163 | } | ||
| 141 | converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText}) | 164 | converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText}) | ||
| 142 | } | 165 | } | ||
| @@ -148,20 +171,29 @@ func applyTextEdits(path string, edits []TextEdit) error { | |||||
| 148 | }) | 171 | }) | ||
| 149 | for i := 1; i < len(converted); i++ { | 172 | for i := 1; i < len(converted); i++ { | ||
| 150 | if converted[i].end > converted[i-1].start { | 173 | if converted[i].end > converted[i-1].start { | ||
| 151 | - | return fmt.Errorf("workspace text edits overlap") | 174 | + | return nil, 0, fmt.Errorf("workspace text edits overlap") |
| 152 | } | 175 | } | ||
| 153 | } | 176 | } | ||
| 154 | - | out := append([]byte(nil), data...) | 177 | + | var builder bytes.Buffer |
| 155 | - | for _, edit := range converted { | 178 | + | cursor := 0 |
| 156 | - | out = append(append(append([]byte(nil), out[:edit.start]...), []byte(edit.text)...), out[edit.end:]...) | 179 | + | for i := len(converted) - 1; i >= 0; i-- { |
| 180 | + | edit := converted[i] | |||
| 181 | + | _, _ = builder.Write(data[cursor:edit.start]) | |||
| 182 | + | _, _ = builder.WriteString(edit.text) | |||
| 183 | + | cursor = edit.end | |||
| 157 | } | 184 | } | ||
| 185 | + | _, _ = builder.Write(data[cursor:]) | |||
| 186 | + | return builder.Bytes(), info.Mode().Perm(), nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func writeTextEdits(path string, out []byte, mode os.FileMode) error { | |||
| 158 | tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*") | 190 | tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*") | ||
| 159 | if err != nil { | 191 | if err != nil { | ||
| 160 | return err | 192 | return err | ||
| 161 | } | 193 | } | ||
| 162 | tmpName := tmp.Name() | 194 | tmpName := tmp.Name() | ||
| 163 | defer func() { _ = os.Remove(tmpName) }() | 195 | defer func() { _ = os.Remove(tmpName) }() | ||
| 164 | - | if err := tmp.Chmod(info.Mode().Perm()); err != nil { | 196 | + | if err := tmp.Chmod(mode); err != nil { |
| 165 | _ = tmp.Close() | 197 | _ = tmp.Close() | ||
| 166 | return err | 198 | return err | ||
| 167 | } | 199 | } | ||
M packages/agent/lsp/framing.go +23 -7
| @@ -10,7 +10,11 @@ import ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | const maxMessageBytes = 32 * 1024 * 1024 | |
| 13 | + | const ( | |
| 14 | + | maxMessageBytes = 32 * 1024 * 1024 | |
| 15 | + | maxHeaderBytes = 8 * 1024 | |
| 16 | + | maxHeaderLines = 64 | |
| 17 | + | ) | |
| 14 | 18 | ||
| 15 | 19 | // WriteMessage writes one LSP Content-Length framed JSON-RPC message. | |
| 16 | 20 | func WriteMessage(w io.Writer, payload []byte) error { | |
| @@ -34,18 +38,28 @@ func WriteMessage(w io.Writer, payload []byte) error { | |||
| 34 | 38 | // headers (including Content-Type) and either CRLF or LF header endings. | |
| 35 | 39 | func ReadMessage(r *bufio.Reader) ([]byte, error) { | |
| 36 | 40 | length := -1 | |
| 41 | + | headerBytes := 0 | |
| 42 | + | headerLines := 0 | |
| 37 | 43 | for { | |
| 38 | - | line, err := r.ReadString('\n') | |
| 44 | + | line, err := r.ReadSlice('\n') | |
| 39 | 45 | if err != nil { | |
| 46 | + | if err == bufio.ErrBufferFull { | |
| 47 | + | return nil, errors.New("LSP headers are too large") | |
| 48 | + | } | |
| 40 | 49 | return nil, err | |
| 41 | 50 | } | |
| 42 | - | line = strings.TrimRight(line, "\r\n") | |
| 43 | - | if line == "" { | |
| 51 | + | headerBytes += len(line) | |
| 52 | + | headerLines++ | |
| 53 | + | if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines { | |
| 54 | + | return nil, errors.New("LSP headers are too large") | |
| 55 | + | } | |
| 56 | + | lineText := strings.TrimRight(string(line), "\r\n") | |
| 57 | + | if lineText == "" { | |
| 44 | 58 | break | |
| 45 | 59 | } | |
| 46 | - | key, value, ok := strings.Cut(line, ":") | |
| 60 | + | key, value, ok := strings.Cut(lineText, ":") | |
| 47 | 61 | if !ok { | |
| 48 | - | return nil, fmt.Errorf("malformed LSP header %q", line) | |
| 62 | + | return nil, fmt.Errorf("malformed LSP header %q", lineText) | |
| 49 | 63 | } | |
| 50 | 64 | if strings.EqualFold(strings.TrimSpace(key), "content-length") { | |
| 51 | 65 | n, err := strconv.Atoi(strings.TrimSpace(value)) | |
| @@ -72,6 +86,8 @@ func ReadMessage(r *bufio.Reader) ([]byte, error) { | |||
| 72 | 86 | // message. It returns the exact wire representation. | |
| 73 | 87 | func Frame(payload []byte) []byte { | |
| 74 | 88 | var b bytes.Buffer | |
| 75 | - | _ = WriteMessage(&b, payload) | |
| 89 | + | if err := WriteMessage(&b, payload); err != nil { | |
| 90 | + | panic(err) | |
| 91 | + | } | |
| 76 | 92 | return b.Bytes() | |
| 77 | 93 | } | |
| @@ -10,7 +10,11 @@ import ( | |||||
| 10 | "strings" | 10 | "strings" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | const maxMessageBytes = 32 * 1024 * 1024 | 13 | + | const ( |
| 14 | + | maxMessageBytes = 32 * 1024 * 1024 | |||
| 15 | + | maxHeaderBytes = 8 * 1024 | |||
| 16 | + | maxHeaderLines = 64 | |||
| 17 | + | ) | |||
| 14 | 18 | ||||
| 15 | // WriteMessage writes one LSP Content-Length framed JSON-RPC message. | 19 | // WriteMessage writes one LSP Content-Length framed JSON-RPC message. | ||
| 16 | func WriteMessage(w io.Writer, payload []byte) error { | 20 | func WriteMessage(w io.Writer, payload []byte) error { | ||
| @@ -34,18 +38,28 @@ func WriteMessage(w io.Writer, payload []byte) error { | |||||
| 34 | // headers (including Content-Type) and either CRLF or LF header endings. | 38 | // headers (including Content-Type) and either CRLF or LF header endings. | ||
| 35 | func ReadMessage(r *bufio.Reader) ([]byte, error) { | 39 | func ReadMessage(r *bufio.Reader) ([]byte, error) { | ||
| 36 | length := -1 | 40 | length := -1 | ||
| 41 | + | headerBytes := 0 | |||
| 42 | + | headerLines := 0 | |||
| 37 | for { | 43 | for { | ||
| 38 | - | line, err := r.ReadString('\n') | 44 | + | line, err := r.ReadSlice('\n') |
| 39 | if err != nil { | 45 | if err != nil { | ||
| 46 | + | if err == bufio.ErrBufferFull { | |||
| 47 | + | return nil, errors.New("LSP headers are too large") | |||
| 48 | + | } | |||
| 40 | return nil, err | 49 | return nil, err | ||
| 41 | } | 50 | } | ||
| 42 | - | line = strings.TrimRight(line, "\r\n") | 51 | + | headerBytes += len(line) |
| 43 | - | if line == "" { | 52 | + | headerLines++ |
| 53 | + | if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines { | |||
| 54 | + | return nil, errors.New("LSP headers are too large") | |||
| 55 | + | } | |||
| 56 | + | lineText := strings.TrimRight(string(line), "\r\n") | |||
| 57 | + | if lineText == "" { | |||
| 44 | break | 58 | break | ||
| 45 | } | 59 | } | ||
| 46 | - | key, value, ok := strings.Cut(line, ":") | 60 | + | key, value, ok := strings.Cut(lineText, ":") |
| 47 | if !ok { | 61 | if !ok { | ||
| 48 | - | return nil, fmt.Errorf("malformed LSP header %q", line) | 62 | + | return nil, fmt.Errorf("malformed LSP header %q", lineText) |
| 49 | } | 63 | } | ||
| 50 | if strings.EqualFold(strings.TrimSpace(key), "content-length") { | 64 | if strings.EqualFold(strings.TrimSpace(key), "content-length") { | ||
| 51 | n, err := strconv.Atoi(strings.TrimSpace(value)) | 65 | n, err := strconv.Atoi(strings.TrimSpace(value)) | ||
| @@ -72,6 +86,8 @@ func ReadMessage(r *bufio.Reader) ([]byte, error) { | |||||
| 72 | // message. It returns the exact wire representation. | 86 | // message. It returns the exact wire representation. | ||
| 73 | func Frame(payload []byte) []byte { | 87 | func Frame(payload []byte) []byte { | ||
| 74 | var b bytes.Buffer | 88 | var b bytes.Buffer | ||
| 75 | - | _ = WriteMessage(&b, payload) | 89 | + | if err := WriteMessage(&b, payload); err != nil { |
| 90 | + | panic(err) | |||
| 91 | + | } | |||
| 76 | return b.Bytes() | 92 | return b.Bytes() | ||
| 77 | } | 93 | } | ||
M packages/agent/lsp/lsp_test.go +29 -3
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "runtime" | |
| 12 | + | "strconv" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "testing" | |
| 14 | 15 | "time" | |
| @@ -103,6 +104,12 @@ func TestFileURIPathRoundTrip(t *testing.T) { | |||
| 103 | 104 | if filepath.Clean(got) != filepath.Clean(abs) { | |
| 104 | 105 | t.Fatalf("round-trip path = %q, want %q", got, abs) | |
| 105 | 106 | } | |
| 107 | + | if runtime.GOOS != "windows" { | |
| 108 | + | posixPath, err := uriToPath("file:///x:tmp/main.go") | |
| 109 | + | if err != nil || posixPath != "/x:tmp/main.go" { | |
| 110 | + | t.Fatalf("POSIX drive-like URI = %q, err = %v", posixPath, err) | |
| 111 | + | } | |
| 112 | + | } | |
| 106 | 113 | if runtime.GOOS == "windows" { | |
| 107 | 114 | unc := `\\server\share\main.go` | |
| 108 | 115 | uncURI, err := pathToURI(unc) | |
| @@ -119,6 +126,25 @@ func TestFileURIPathRoundTrip(t *testing.T) { | |||
| 119 | 126 | } | |
| 120 | 127 | } | |
| 121 | 128 | ||
| 129 | + | func TestFramingRejectsMalformedAndOversizedMessages(t *testing.T) { | |
| 130 | + | cases := []string{ | |
| 131 | + | "\r\n{}", | |
| 132 | + | "Content-Length: -1\r\n\r\n", | |
| 133 | + | "Content-Length: nope\r\n\r\n", | |
| 134 | + | "Content-Length: 1\r\n" + strings.Repeat("X", maxHeaderBytes) + "\r\n", | |
| 135 | + | "Content-Length: " + strconv.Itoa(maxMessageBytes+1) + "\r\n\r\n", | |
| 136 | + | } | |
| 137 | + | for _, wire := range cases { | |
| 138 | + | if _, err := ReadMessage(bufio.NewReader(strings.NewReader(wire))); err == nil { | |
| 139 | + | t.Fatalf("accepted malformed frame %q", wire[:min(len(wire), 80)]) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | var out bytes.Buffer | |
| 143 | + | if err := WriteMessage(&out, make([]byte, maxMessageBytes+1)); err == nil { | |
| 144 | + | t.Fatal("accepted oversized outbound frame") | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 122 | 148 | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | |
| 123 | 149 | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | |
| 124 | 150 | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | |
| @@ -190,7 +216,7 @@ func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) { | |||
| 190 | 216 | root := t.TempDir() | |
| 191 | 217 | zotHome := t.TempDir() | |
| 192 | 218 | t.Setenv("ZOT_HOME", zotHome) | |
| 193 | - | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil { | |
| 219 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { | |
| 194 | 220 | t.Fatal(err) | |
| 195 | 221 | } | |
| 196 | 222 | configFile := `{ | |
| @@ -221,7 +247,7 @@ func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) { | |||
| 221 | 247 | func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) { | |
| 222 | 248 | root := t.TempDir() | |
| 223 | 249 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 224 | - | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil { | |
| 250 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { | |
| 225 | 251 | t.Fatal(err) | |
| 226 | 252 | } | |
| 227 | 253 | if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil { | |
| @@ -374,7 +400,7 @@ func TestWorkspaceEditPreflightsAllTargets(t *testing.T) { | |||
| 374 | 400 | _, err = prepareEditTargets(rootReal, map[string][]TextEdit{ | |
| 375 | 401 | URIForPath(inside): {{Range: Range{}, NewText: "inside"}}, | |
| 376 | 402 | URIForPath(outside): {{Range: Range{}, NewText: "outside"}}, | |
| 377 | - | }) | |
| 403 | + | }, nil) | |
| 378 | 404 | if err == nil { | |
| 379 | 405 | t.Fatal("workspace edit accepted an unsafe target after a safe target") | |
| 380 | 406 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "os" | 9 | "os" | ||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | "runtime" | 11 | "runtime" | ||
| 12 | + | "strconv" | |||
| 12 | "strings" | 13 | "strings" | ||
| 13 | "testing" | 14 | "testing" | ||
| 14 | "time" | 15 | "time" | ||
| @@ -103,6 +104,12 @@ func TestFileURIPathRoundTrip(t *testing.T) { | |||||
| 103 | if filepath.Clean(got) != filepath.Clean(abs) { | 104 | if filepath.Clean(got) != filepath.Clean(abs) { | ||
| 104 | t.Fatalf("round-trip path = %q, want %q", got, abs) | 105 | t.Fatalf("round-trip path = %q, want %q", got, abs) | ||
| 105 | } | 106 | } | ||
| 107 | + | if runtime.GOOS != "windows" { | |||
| 108 | + | posixPath, err := uriToPath("file:///x:tmp/main.go") | |||
| 109 | + | if err != nil || posixPath != "/x:tmp/main.go" { | |||
| 110 | + | t.Fatalf("POSIX drive-like URI = %q, err = %v", posixPath, err) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 106 | if runtime.GOOS == "windows" { | 113 | if runtime.GOOS == "windows" { | ||
| 107 | unc := `\\server\share\main.go` | 114 | unc := `\\server\share\main.go` | ||
| 108 | uncURI, err := pathToURI(unc) | 115 | uncURI, err := pathToURI(unc) | ||
| @@ -119,6 +126,25 @@ func TestFileURIPathRoundTrip(t *testing.T) { | |||||
| 119 | } | 126 | } | ||
| 120 | } | 127 | } | ||
| 121 | 128 | ||||
| 129 | + | func TestFramingRejectsMalformedAndOversizedMessages(t *testing.T) { | |||
| 130 | + | cases := []string{ | |||
| 131 | + | "\r\n{}", | |||
| 132 | + | "Content-Length: -1\r\n\r\n", | |||
| 133 | + | "Content-Length: nope\r\n\r\n", | |||
| 134 | + | "Content-Length: 1\r\n" + strings.Repeat("X", maxHeaderBytes) + "\r\n", | |||
| 135 | + | "Content-Length: " + strconv.Itoa(maxMessageBytes+1) + "\r\n\r\n", | |||
| 136 | + | } | |||
| 137 | + | for _, wire := range cases { | |||
| 138 | + | if _, err := ReadMessage(bufio.NewReader(strings.NewReader(wire))); err == nil { | |||
| 139 | + | t.Fatalf("accepted malformed frame %q", wire[:min(len(wire), 80)]) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | var out bytes.Buffer | |||
| 143 | + | if err := WriteMessage(&out, make([]byte, maxMessageBytes+1)); err == nil { | |||
| 144 | + | t.Fatal("accepted oversized outbound frame") | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 122 | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | 148 | func TestFramingRoundTripWithAdditionalHeader(t *testing.T) { | ||
| 123 | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | 149 | payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`) | ||
| 124 | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | 150 | wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...) | ||
| @@ -190,7 +216,7 @@ func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) { | |||||
| 190 | root := t.TempDir() | 216 | root := t.TempDir() | ||
| 191 | zotHome := t.TempDir() | 217 | zotHome := t.TempDir() | ||
| 192 | t.Setenv("ZOT_HOME", zotHome) | 218 | t.Setenv("ZOT_HOME", zotHome) | ||
| 193 | - | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil { | 219 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { |
| 194 | t.Fatal(err) | 220 | t.Fatal(err) | ||
| 195 | } | 221 | } | ||
| 196 | configFile := `{ | 222 | configFile := `{ | ||
| @@ -221,7 +247,7 @@ func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) { | |||||
| 221 | func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) { | 247 | func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) { | ||
| 222 | root := t.TempDir() | 248 | root := t.TempDir() | ||
| 223 | t.Setenv("ZOT_HOME", t.TempDir()) | 249 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 224 | - | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\\n"), 0o644); err != nil { | 250 | + | if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil { |
| 225 | t.Fatal(err) | 251 | t.Fatal(err) | ||
| 226 | } | 252 | } | ||
| 227 | if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil { | 253 | if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil { | ||
| @@ -374,7 +400,7 @@ func TestWorkspaceEditPreflightsAllTargets(t *testing.T) { | |||||
| 374 | _, err = prepareEditTargets(rootReal, map[string][]TextEdit{ | 400 | _, err = prepareEditTargets(rootReal, map[string][]TextEdit{ | ||
| 375 | URIForPath(inside): {{Range: Range{}, NewText: "inside"}}, | 401 | URIForPath(inside): {{Range: Range{}, NewText: "inside"}}, | ||
| 376 | URIForPath(outside): {{Range: Range{}, NewText: "outside"}}, | 402 | URIForPath(outside): {{Range: Range{}, NewText: "outside"}}, | ||
| 377 | - | }) | 403 | + | }, nil) |
| 378 | if err == nil { | 404 | if err == nil { | ||
| 379 | t.Fatal("workspace edit accepted an unsafe target after a safe target") | 405 | t.Fatal("workspace edit accepted an unsafe target after a safe target") | ||
| 380 | } | 406 | } | ||
M packages/agent/lsp/manager.go +31 -6
| @@ -23,6 +23,7 @@ type ManagerOptions struct { | |||
| 23 | 23 | LinterTimeout time.Duration | |
| 24 | 24 | Settings map[string]any | |
| 25 | 25 | OnLog func(server, message string) | |
| 26 | + | CheckWritePath func(path string) error | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | // Response is one raw request result tagged with the server which produced | |
| @@ -163,7 +164,9 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo | |||
| 163 | 164 | // A server's LSP root may be an ancestor of the session cwd. | |
| 164 | 165 | // Keep server-initiated edits inside the session workspace rather | |
| 165 | 166 | // than trusting that broader project root. | |
| 166 | - | ApplyEdit: func(edit WorkspaceEdit) error { return ApplyWorkspaceEdit(ws.cwd, edit) }, | |
| 167 | + | ApplyEdit: func(edit WorkspaceEdit) error { | |
| 168 | + | return applyWorkspaceEdit(ws.cwd, edit, m.options.CheckWritePath) | |
| 169 | + | }, | |
| 167 | 170 | } | |
| 168 | 171 | startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout) | |
| 169 | 172 | client, err := NewClientWithContext(startCtx, spec, root, opts) | |
| @@ -494,11 +497,24 @@ func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, para | |||
| 494 | 497 | ||
| 495 | 498 | // ApplyEdit applies a workspace edit under the selected workspace root. | |
| 496 | 499 | func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error { | |
| 500 | + | return m.ApplyEdits(cwd, []WorkspaceEdit{edit}) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // ApplyEdits preflights and applies multiple workspace edits as one operation. | |
| 504 | + | // This prevents a later unsafe edit from leaving earlier edits applied. | |
| 505 | + | func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error { | |
| 497 | 506 | ws, err := m.workspace(cwd) | |
| 498 | 507 | if err != nil { | |
| 499 | 508 | return err | |
| 500 | 509 | } | |
| 501 | - | return ApplyWorkspaceEdit(ws.cwd, edit) | |
| 510 | + | merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)} | |
| 511 | + | for _, edit := range edits { | |
| 512 | + | for uri, changes := range edit.Changes { | |
| 513 | + | merged.Changes[uri] = append(merged.Changes[uri], changes...) | |
| 514 | + | } | |
| 515 | + | merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...) | |
| 516 | + | } | |
| 517 | + | return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath) | |
| 502 | 518 | } | |
| 503 | 519 | ||
| 504 | 520 | // Status describes configured and cached servers without starting missing | |
| @@ -519,16 +535,25 @@ func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) { | |||
| 519 | 535 | if err != nil { | |
| 520 | 536 | return nil, err | |
| 521 | 537 | } | |
| 538 | + | specs := m.applicable(ws, path) | |
| 539 | + | roots := make(map[string]string, len(specs)) | |
| 540 | + | resolveErrors := make(map[string]error, len(specs)) | |
| 541 | + | for _, spec := range specs { | |
| 542 | + | roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers)) | |
| 543 | + | if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil { | |
| 544 | + | resolveErrors[spec.ID] = resolveErr | |
| 545 | + | } | |
| 546 | + | } | |
| 522 | 547 | m.mu.Lock() | |
| 523 | 548 | defer m.mu.Unlock() | |
| 524 | - | out := make([]ServerStatus, 0) | |
| 525 | - | for _, spec := range m.applicable(ws, path) { | |
| 526 | - | 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]} | |
| 549 | + | out := make([]ServerStatus, 0, len(specs)) | |
| 550 | + | for _, spec := range specs { | |
| 551 | + | status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]} | |
| 527 | 552 | if status.State == "" { | |
| 528 | 553 | status.State = "stopped" | |
| 529 | 554 | } | |
| 530 | 555 | if status.State == "stopped" { | |
| 531 | - | if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil { | |
| 556 | + | if resolveErr := resolveErrors[spec.ID]; resolveErr != nil { | |
| 532 | 557 | status.State = "missing" | |
| 533 | 558 | status.Error = resolveErr.Error() | |
| 534 | 559 | } | |
| @@ -23,6 +23,7 @@ type ManagerOptions struct { | |||||
| 23 | LinterTimeout time.Duration | 23 | LinterTimeout time.Duration | ||
| 24 | Settings map[string]any | 24 | Settings map[string]any | ||
| 25 | OnLog func(server, message string) | 25 | OnLog func(server, message string) | ||
| 26 | + | CheckWritePath func(path string) error | |||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 28 | // Response is one raw request result tagged with the server which produced | 29 | // Response is one raw request result tagged with the server which produced | ||
| @@ -163,7 +164,9 @@ func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerCo | |||||
| 163 | // A server's LSP root may be an ancestor of the session cwd. | 164 | // A server's LSP root may be an ancestor of the session cwd. | ||
| 164 | // Keep server-initiated edits inside the session workspace rather | 165 | // Keep server-initiated edits inside the session workspace rather | ||
| 165 | // than trusting that broader project root. | 166 | // than trusting that broader project root. | ||
| 166 | - | ApplyEdit: func(edit WorkspaceEdit) error { return ApplyWorkspaceEdit(ws.cwd, edit) }, | 167 | + | ApplyEdit: func(edit WorkspaceEdit) error { |
| 168 | + | return applyWorkspaceEdit(ws.cwd, edit, m.options.CheckWritePath) | |||
| 169 | + | }, | |||
| 167 | } | 170 | } | ||
| 168 | startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout) | 171 | startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout) | ||
| 169 | client, err := NewClientWithContext(startCtx, spec, root, opts) | 172 | client, err := NewClientWithContext(startCtx, spec, root, opts) | ||
| @@ -494,11 +497,24 @@ func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, para | |||||
| 494 | 497 | ||||
| 495 | // ApplyEdit applies a workspace edit under the selected workspace root. | 498 | // ApplyEdit applies a workspace edit under the selected workspace root. | ||
| 496 | func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error { | 499 | func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error { | ||
| 500 | + | return m.ApplyEdits(cwd, []WorkspaceEdit{edit}) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // ApplyEdits preflights and applies multiple workspace edits as one operation. | |||
| 504 | + | // This prevents a later unsafe edit from leaving earlier edits applied. | |||
| 505 | + | func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error { | |||
| 497 | ws, err := m.workspace(cwd) | 506 | ws, err := m.workspace(cwd) | ||
| 498 | if err != nil { | 507 | if err != nil { | ||
| 499 | return err | 508 | return err | ||
| 500 | } | 509 | } | ||
| 501 | - | return ApplyWorkspaceEdit(ws.cwd, edit) | 510 | + | merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)} |
| 511 | + | for _, edit := range edits { | |||
| 512 | + | for uri, changes := range edit.Changes { | |||
| 513 | + | merged.Changes[uri] = append(merged.Changes[uri], changes...) | |||
| 514 | + | } | |||
| 515 | + | merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...) | |||
| 516 | + | } | |||
| 517 | + | return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath) | |||
| 502 | } | 518 | } | ||
| 503 | 519 | ||||
| 504 | // Status describes configured and cached servers without starting missing | 520 | // Status describes configured and cached servers without starting missing | ||
| @@ -519,16 +535,25 @@ func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) { | |||||
| 519 | if err != nil { | 535 | if err != nil { | ||
| 520 | return nil, err | 536 | return nil, err | ||
| 521 | } | 537 | } | ||
| 538 | + | specs := m.applicable(ws, path) | |||
| 539 | + | roots := make(map[string]string, len(specs)) | |||
| 540 | + | resolveErrors := make(map[string]error, len(specs)) | |||
| 541 | + | for _, spec := range specs { | |||
| 542 | + | roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers)) | |||
| 543 | + | if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil { | |||
| 544 | + | resolveErrors[spec.ID] = resolveErr | |||
| 545 | + | } | |||
| 546 | + | } | |||
| 522 | m.mu.Lock() | 547 | m.mu.Lock() | ||
| 523 | defer m.mu.Unlock() | 548 | defer m.mu.Unlock() | ||
| 524 | - | out := make([]ServerStatus, 0) | 549 | + | out := make([]ServerStatus, 0, len(specs)) |
| 525 | - | for _, spec := range m.applicable(ws, path) { | 550 | + | for _, spec := range specs { |
| 526 | - | 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]} | 551 | + | status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]} |
| 527 | if status.State == "" { | 552 | if status.State == "" { | ||
| 528 | status.State = "stopped" | 553 | status.State = "stopped" | ||
| 529 | } | 554 | } | ||
| 530 | if status.State == "stopped" { | 555 | if status.State == "stopped" { | ||
| 531 | - | if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil { | 556 | + | if resolveErr := resolveErrors[spec.ID]; resolveErr != nil { |
| 532 | status.State = "missing" | 557 | status.State = "missing" | ||
| 533 | status.Error = resolveErr.Error() | 558 | status.Error = resolveErr.Error() | ||
| 534 | } | 559 | } | ||
M packages/agent/lsp/parsers.go +1 -1
| @@ -190,7 +190,7 @@ func parseGeneric(provider, cwd, output string) []Diagnostic { | |||
| 190 | 190 | columnNo, _ := strconv.Atoi(match[3]) | |
| 191 | 191 | message := strings.TrimSpace(match[5]) | |
| 192 | 192 | if message == "" { | |
| 193 | - | message = strings.TrimSpace(match[4]) | |
| 193 | + | message = line | |
| 194 | 194 | } | |
| 195 | 195 | severity := normalizeSeverity(match[4]) | |
| 196 | 196 | out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, match[1]), Server: provider, Source: provider, Severity: severity, Message: message, Range: oneBasedRange(lineNo, columnNo, lineNo, columnNo)}) | |
| @@ -190,7 +190,7 @@ func parseGeneric(provider, cwd, output string) []Diagnostic { | |||||
| 190 | columnNo, _ := strconv.Atoi(match[3]) | 190 | columnNo, _ := strconv.Atoi(match[3]) | ||
| 191 | message := strings.TrimSpace(match[5]) | 191 | message := strings.TrimSpace(match[5]) | ||
| 192 | if message == "" { | 192 | if message == "" { | ||
| 193 | - | message = strings.TrimSpace(match[4]) | 193 | + | message = line |
| 194 | } | 194 | } | ||
| 195 | severity := normalizeSeverity(match[4]) | 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)}) | 196 | out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, match[1]), Server: provider, Source: provider, Severity: severity, Message: message, Range: oneBasedRange(lineNo, columnNo, lineNo, columnNo)}) | ||
M packages/agent/lsp/protocol.go +1 -1
| @@ -150,7 +150,7 @@ func uriToPath(uri string) (string, error) { | |||
| 150 | 150 | if path == "" { | |
| 151 | 151 | return "", fmt.Errorf("document URI has no path") | |
| 152 | 152 | } | |
| 153 | - | if len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/... | |
| 153 | + | if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/... | |
| 154 | 154 | path = path[1:] | |
| 155 | 155 | } | |
| 156 | 156 | return filepath.Clean(filepath.FromSlash(path)), nil | |
| @@ -150,7 +150,7 @@ func uriToPath(uri string) (string, error) { | |||||
| 150 | if path == "" { | 150 | if path == "" { | ||
| 151 | return "", fmt.Errorf("document URI has no path") | 151 | return "", fmt.Errorf("document URI has no path") | ||
| 152 | } | 152 | } | ||
| 153 | - | if len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/... | 153 | + | if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/... |
| 154 | path = path[1:] | 154 | path = path[1:] | ||
| 155 | } | 155 | } | ||
| 156 | return filepath.Clean(filepath.FromSlash(path)), nil | 156 | return filepath.Clean(filepath.FromSlash(path)), nil | ||
M packages/agent/rpc.go +3 -1
| @@ -115,7 +115,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 115 | 115 | resolved.MergeExtensionTools(adapter) | |
| 116 | 116 | oldTools := ag.Tools | |
| 117 | 117 | ag.SetTools(resolved.ToolRegistry) | |
| 118 | - | _ = tools.CloseLSPManagers(oldTools) | |
| 118 | + | if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil { | |
| 119 | + | fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr) | |
| 120 | + | } | |
| 119 | 121 | }) | |
| 120 | 122 | ||
| 121 | 123 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| @@ -115,7 +115,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 115 | resolved.MergeExtensionTools(adapter) | 115 | resolved.MergeExtensionTools(adapter) | ||
| 116 | oldTools := ag.Tools | 116 | oldTools := ag.Tools | ||
| 117 | ag.SetTools(resolved.ToolRegistry) | 117 | ag.SetTools(resolved.ToolRegistry) | ||
| 118 | - | _ = tools.CloseLSPManagers(oldTools) | 118 | + | if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil { |
| 119 | + | fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr) | |||
| 120 | + | } | |||
| 119 | }) | 121 | }) | ||
| 120 | 122 | ||||
| 121 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | 123 | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | ||
M packages/agent/sdk/sdk.go +17 -2
| @@ -96,8 +96,9 @@ type Runtime struct { | |||
| 96 | 96 | model string | |
| 97 | 97 | cwd string | |
| 98 | 98 | ||
| 99 | - | // activeCancel is set while a Prompt is streaming. | |
| 99 | + | // activeCancel is set while a Prompt or Compact is running. | |
| 100 | 100 | activeCancel context.CancelFunc | |
| 101 | + | activeDone chan struct{} | |
| 101 | 102 | ||
| 102 | 103 | // closed signals that Close has been called. | |
| 103 | 104 | closed bool | |
| @@ -218,7 +219,9 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch | |||
| 218 | 219 | return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)") | |
| 219 | 220 | } | |
| 220 | 221 | subCtx, cancel := context.WithCancel(ctx) | |
| 222 | + | done := make(chan struct{}) | |
| 221 | 223 | r.activeCancel = cancel | |
| 224 | + | r.activeDone = done | |
| 222 | 225 | r.mu.Unlock() | |
| 223 | 226 | ||
| 224 | 227 | imgBlocks := make([]provider.ImageBlock, 0, len(images)) | |
| @@ -232,6 +235,8 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch | |||
| 232 | 235 | defer func() { | |
| 233 | 236 | r.mu.Lock() | |
| 234 | 237 | r.activeCancel = nil | |
| 238 | + | r.activeDone = nil | |
| 239 | + | close(done) | |
| 235 | 240 | r.mu.Unlock() | |
| 236 | 241 | }() | |
| 237 | 242 | err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) { | |
| @@ -267,11 +272,15 @@ func (r *Runtime) Compact(ctx context.Context, customInstructions string) (Compa | |||
| 267 | 272 | return CompactResult{}, ErrBusy | |
| 268 | 273 | } | |
| 269 | 274 | subCtx, cancel := context.WithCancel(ctx) | |
| 275 | + | done := make(chan struct{}) | |
| 270 | 276 | r.activeCancel = cancel | |
| 277 | + | r.activeDone = done | |
| 271 | 278 | r.mu.Unlock() | |
| 272 | 279 | defer func() { | |
| 273 | 280 | r.mu.Lock() | |
| 274 | 281 | r.activeCancel = nil | |
| 282 | + | r.activeDone = nil | |
| 283 | + | close(done) | |
| 275 | 284 | r.mu.Unlock() | |
| 276 | 285 | }() | |
| 277 | 286 | ||
| @@ -340,11 +349,17 @@ func (r *Runtime) Close() error { | |||
| 340 | 349 | } | |
| 341 | 350 | r.closed = true | |
| 342 | 351 | cancel := r.activeCancel | |
| 343 | - | ag := r.agent | |
| 352 | + | done := r.activeDone | |
| 344 | 353 | r.mu.Unlock() | |
| 345 | 354 | if cancel != nil { | |
| 346 | 355 | cancel() | |
| 347 | 356 | } | |
| 357 | + | if done != nil { | |
| 358 | + | <-done | |
| 359 | + | } | |
| 360 | + | r.mu.Lock() | |
| 361 | + | ag := r.agent | |
| 362 | + | r.mu.Unlock() | |
| 348 | 363 | if ag != nil { | |
| 349 | 364 | return agenttools.CloseLSPManagers(ag.Tools) | |
| 350 | 365 | } | |
| @@ -96,8 +96,9 @@ type Runtime struct { | |||||
| 96 | model string | 96 | model string | ||
| 97 | cwd string | 97 | cwd string | ||
| 98 | 98 | ||||
| 99 | - | // activeCancel is set while a Prompt is streaming. | 99 | + | // activeCancel is set while a Prompt or Compact is running. |
| 100 | activeCancel context.CancelFunc | 100 | activeCancel context.CancelFunc | ||
| 101 | + | activeDone chan struct{} | |||
| 101 | 102 | ||||
| 102 | // closed signals that Close has been called. | 103 | // closed signals that Close has been called. | ||
| 103 | closed bool | 104 | closed bool | ||
| @@ -218,7 +219,9 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch | |||||
| 218 | return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)") | 219 | return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)") | ||
| 219 | } | 220 | } | ||
| 220 | subCtx, cancel := context.WithCancel(ctx) | 221 | subCtx, cancel := context.WithCancel(ctx) | ||
| 222 | + | done := make(chan struct{}) | |||
| 221 | r.activeCancel = cancel | 223 | r.activeCancel = cancel | ||
| 224 | + | r.activeDone = done | |||
| 222 | r.mu.Unlock() | 225 | r.mu.Unlock() | ||
| 223 | 226 | ||||
| 224 | imgBlocks := make([]provider.ImageBlock, 0, len(images)) | 227 | imgBlocks := make([]provider.ImageBlock, 0, len(images)) | ||
| @@ -232,6 +235,8 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch | |||||
| 232 | defer func() { | 235 | defer func() { | ||
| 233 | r.mu.Lock() | 236 | r.mu.Lock() | ||
| 234 | r.activeCancel = nil | 237 | r.activeCancel = nil | ||
| 238 | + | r.activeDone = nil | |||
| 239 | + | close(done) | |||
| 235 | r.mu.Unlock() | 240 | r.mu.Unlock() | ||
| 236 | }() | 241 | }() | ||
| 237 | err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) { | 242 | err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) { | ||
| @@ -267,11 +272,15 @@ func (r *Runtime) Compact(ctx context.Context, customInstructions string) (Compa | |||||
| 267 | return CompactResult{}, ErrBusy | 272 | return CompactResult{}, ErrBusy | ||
| 268 | } | 273 | } | ||
| 269 | subCtx, cancel := context.WithCancel(ctx) | 274 | subCtx, cancel := context.WithCancel(ctx) | ||
| 275 | + | done := make(chan struct{}) | |||
| 270 | r.activeCancel = cancel | 276 | r.activeCancel = cancel | ||
| 277 | + | r.activeDone = done | |||
| 271 | r.mu.Unlock() | 278 | r.mu.Unlock() | ||
| 272 | defer func() { | 279 | defer func() { | ||
| 273 | r.mu.Lock() | 280 | r.mu.Lock() | ||
| 274 | r.activeCancel = nil | 281 | r.activeCancel = nil | ||
| 282 | + | r.activeDone = nil | |||
| 283 | + | close(done) | |||
| 275 | r.mu.Unlock() | 284 | r.mu.Unlock() | ||
| 276 | }() | 285 | }() | ||
| 277 | 286 | ||||
| @@ -340,11 +349,17 @@ func (r *Runtime) Close() error { | |||||
| 340 | } | 349 | } | ||
| 341 | r.closed = true | 350 | r.closed = true | ||
| 342 | cancel := r.activeCancel | 351 | cancel := r.activeCancel | ||
| 343 | - | ag := r.agent | 352 | + | done := r.activeDone |
| 344 | r.mu.Unlock() | 353 | r.mu.Unlock() | ||
| 345 | if cancel != nil { | 354 | if cancel != nil { | ||
| 346 | cancel() | 355 | cancel() | ||
| 347 | } | 356 | } | ||
| 357 | + | if done != nil { | |||
| 358 | + | <-done | |||
| 359 | + | } | |||
| 360 | + | r.mu.Lock() | |||
| 361 | + | ag := r.agent | |||
| 362 | + | r.mu.Unlock() | |||
| 348 | if ag != nil { | 363 | if ag != nil { | ||
| 349 | return agenttools.CloseLSPManagers(ag.Tools) | 364 | return agenttools.CloseLSPManagers(ag.Tools) | ||
| 350 | } | 365 | } | ||
M packages/agent/settings_store_test.go +6 -1
| @@ -149,10 +149,15 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||
| 149 | 149 | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | |
| 150 | 150 | t.Fatal("explicitly disabled LSP settings were ignored") | |
| 151 | 151 | } | |
| 152 | + | yesSub := true | |
| 153 | + | cfg.SubagentLSPEnabled = &yesSub | |
| 152 | 154 | write, edit := true, true | |
| 153 | 155 | cfg.LSPDiagnosticsOnWrite = &write | |
| 154 | 156 | cfg.LSPDiagnosticsOnEdit = &edit | |
| 155 | - | if cfg.LSPDiagnosticsOnWriteEnabled() || cfg.LSPDiagnosticsOnEditEnabled() { | |
| 157 | + | if cfg.LSPDiagnosticsOnWriteEnabled(false) || cfg.LSPDiagnosticsOnEditEnabled(false) { | |
| 156 | 158 | t.Fatal("diagnostics remained enabled with main-session LSP disabled") | |
| 157 | 159 | } | |
| 160 | + | if !cfg.LSPDiagnosticsOnWriteEnabled(true) || !cfg.LSPDiagnosticsOnEditEnabled(true) { | |
| 161 | + | t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings") | |
| 162 | + | } | |
| 158 | 163 | } | |
| @@ -149,10 +149,15 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||||
| 149 | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | 149 | if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) { | ||
| 150 | t.Fatal("explicitly disabled LSP settings were ignored") | 150 | t.Fatal("explicitly disabled LSP settings were ignored") | ||
| 151 | } | 151 | } | ||
| 152 | + | yesSub := true | |||
| 153 | + | cfg.SubagentLSPEnabled = &yesSub | |||
| 152 | write, edit := true, true | 154 | write, edit := true, true | ||
| 153 | cfg.LSPDiagnosticsOnWrite = &write | 155 | cfg.LSPDiagnosticsOnWrite = &write | ||
| 154 | cfg.LSPDiagnosticsOnEdit = &edit | 156 | cfg.LSPDiagnosticsOnEdit = &edit | ||
| 155 | - | if cfg.LSPDiagnosticsOnWriteEnabled() || cfg.LSPDiagnosticsOnEditEnabled() { | 157 | + | if cfg.LSPDiagnosticsOnWriteEnabled(false) || cfg.LSPDiagnosticsOnEditEnabled(false) { |
| 156 | t.Fatal("diagnostics remained enabled with main-session LSP disabled") | 158 | t.Fatal("diagnostics remained enabled with main-session LSP disabled") | ||
| 157 | } | 159 | } | ||
| 160 | + | if !cfg.LSPDiagnosticsOnWriteEnabled(true) || !cfg.LSPDiagnosticsOnEditEnabled(true) { | |||
| 161 | + | t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings") | |||
| 162 | + | } | |||
| 158 | } | 163 | } | ||
M packages/agent/swarm_agent.go +39 -10
| @@ -59,7 +59,13 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 59 | 59 | defer stopExt() | |
| 60 | 60 | ||
| 61 | 61 | ag := r.NewAgent() | |
| 62 | - | defer closeAgentLSP(ag) | |
| 62 | + | initialAg := ag | |
| 63 | + | defer func() { | |
| 64 | + | closeAgentLSP(ag) | |
| 65 | + | if ag != initialAg { | |
| 66 | + | closeAgentLSP(initialAg) | |
| 67 | + | } | |
| 68 | + | }() | |
| 63 | 69 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 64 | 70 | sess, err := openOrCreateSession(args, r, ag, version) | |
| 65 | 71 | if err != nil { | |
| @@ -115,8 +121,9 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 115 | 121 | turnCtx context.Context = ctx | |
| 116 | 122 | cancelFn context.CancelFunc | |
| 117 | 123 | busyTurn bool | |
| 124 | + | stopping bool | |
| 118 | 125 | turnNo int | |
| 119 | - | shutdown = make(chan struct{}) | |
| 126 | + | turns sync.WaitGroup | |
| 120 | 127 | ) | |
| 121 | 128 | ||
| 122 | 129 | runOne := func(prompt string) { | |
| @@ -158,11 +165,35 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 158 | 165 | mu.Unlock() | |
| 159 | 166 | } | |
| 160 | 167 | ||
| 168 | + | startTurn := func(prompt string) { | |
| 169 | + | mu.Lock() | |
| 170 | + | if stopping { | |
| 171 | + | mu.Unlock() | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | turns.Add(1) | |
| 175 | + | mu.Unlock() | |
| 176 | + | go func() { | |
| 177 | + | defer turns.Done() | |
| 178 | + | runOne(prompt) | |
| 179 | + | }() | |
| 180 | + | } | |
| 181 | + | stopAndWait := func(reason string) { | |
| 182 | + | mu.Lock() | |
| 183 | + | stopping = true | |
| 184 | + | if cancelFn != nil { | |
| 185 | + | cancelFn() | |
| 186 | + | } | |
| 187 | + | mu.Unlock() | |
| 188 | + | turns.Wait() | |
| 189 | + | em.emit("agent_stopped", map[string]any{"reason": reason}) | |
| 190 | + | } | |
| 191 | + | ||
| 161 | 192 | // Initial task: run before processing the inbox so the agent | |
| 162 | 193 | // "starts working" the moment it boots, matching what users | |
| 163 | 194 | // expect from `/swarm new <task>`. | |
| 164 | 195 | if args.Prompt != "" { | |
| 165 | - | go runOne(args.Prompt) | |
| 196 | + | startTurn(args.Prompt) | |
| 166 | 197 | } | |
| 167 | 198 | ||
| 168 | 199 | // Inbox loop: one supervisor message at a time. We don't spawn | |
| @@ -172,19 +203,17 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 172 | 203 | for { | |
| 173 | 204 | select { | |
| 174 | 205 | case <-ctx.Done(): | |
| 175 | - | em.emit("agent_stopped", map[string]any{"reason": "cancelled"}) | |
| 206 | + | stopAndWait("cancelled") | |
| 176 | 207 | return ctx.Err() | |
| 177 | - | case <-shutdown: | |
| 178 | - | em.emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |
| 179 | - | return nil | |
| 180 | 208 | case msg, ok := <-ln.Lines(): | |
| 181 | 209 | if !ok { | |
| 182 | - | em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"}) | |
| 210 | + | stopAndWait("inbox-closed") | |
| 183 | 211 | return nil | |
| 184 | 212 | } | |
| 185 | 213 | switch { | |
| 186 | 214 | case msg == "shutdown": | |
| 187 | - | close(shutdown) | |
| 215 | + | stopAndWait("shutdown") | |
| 216 | + | return nil | |
| 188 | 217 | case msg == "cancel": | |
| 189 | 218 | mu.Lock() | |
| 190 | 219 | if cancelFn != nil { | |
| @@ -193,7 +222,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 193 | 222 | mu.Unlock() | |
| 194 | 223 | case strings.HasPrefix(msg, "user "): | |
| 195 | 224 | prompt := strings.TrimPrefix(msg, "user ") | |
| 196 | - | go runOne(prompt) | |
| 225 | + | startTurn(prompt) | |
| 197 | 226 | default: | |
| 198 | 227 | em.emit("error", map[string]any{ | |
| 199 | 228 | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | |
| @@ -59,7 +59,13 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||||
| 59 | defer stopExt() | 59 | defer stopExt() | ||
| 60 | 60 | ||||
| 61 | ag := r.NewAgent() | 61 | ag := r.NewAgent() | ||
| 62 | - | defer closeAgentLSP(ag) | 62 | + | initialAg := ag |
| 63 | + | defer func() { | |||
| 64 | + | closeAgentLSP(ag) | |||
| 65 | + | if ag != initialAg { | |||
| 66 | + | closeAgentLSP(initialAg) | |||
| 67 | + | } | |||
| 68 | + | }() | |||
| 63 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 69 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 64 | sess, err := openOrCreateSession(args, r, ag, version) | 70 | sess, err := openOrCreateSession(args, r, ag, version) | ||
| 65 | if err != nil { | 71 | if err != nil { | ||
| @@ -115,8 +121,9 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||||
| 115 | turnCtx context.Context = ctx | 121 | turnCtx context.Context = ctx | ||
| 116 | cancelFn context.CancelFunc | 122 | cancelFn context.CancelFunc | ||
| 117 | busyTurn bool | 123 | busyTurn bool | ||
| 124 | + | stopping bool | |||
| 118 | turnNo int | 125 | turnNo int | ||
| 119 | - | shutdown = make(chan struct{}) | 126 | + | turns sync.WaitGroup |
| 120 | ) | 127 | ) | ||
| 121 | 128 | ||||
| 122 | runOne := func(prompt string) { | 129 | runOne := func(prompt string) { | ||
| @@ -158,11 +165,35 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||||
| 158 | mu.Unlock() | 165 | mu.Unlock() | ||
| 159 | } | 166 | } | ||
| 160 | 167 | ||||
| 168 | + | startTurn := func(prompt string) { | |||
| 169 | + | mu.Lock() | |||
| 170 | + | if stopping { | |||
| 171 | + | mu.Unlock() | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | turns.Add(1) | |||
| 175 | + | mu.Unlock() | |||
| 176 | + | go func() { | |||
| 177 | + | defer turns.Done() | |||
| 178 | + | runOne(prompt) | |||
| 179 | + | }() | |||
| 180 | + | } | |||
| 181 | + | stopAndWait := func(reason string) { | |||
| 182 | + | mu.Lock() | |||
| 183 | + | stopping = true | |||
| 184 | + | if cancelFn != nil { | |||
| 185 | + | cancelFn() | |||
| 186 | + | } | |||
| 187 | + | mu.Unlock() | |||
| 188 | + | turns.Wait() | |||
| 189 | + | em.emit("agent_stopped", map[string]any{"reason": reason}) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 161 | // Initial task: run before processing the inbox so the agent | 192 | // Initial task: run before processing the inbox so the agent | ||
| 162 | // "starts working" the moment it boots, matching what users | 193 | // "starts working" the moment it boots, matching what users | ||
| 163 | // expect from `/swarm new <task>`. | 194 | // expect from `/swarm new <task>`. | ||
| 164 | if args.Prompt != "" { | 195 | if args.Prompt != "" { | ||
| 165 | - | go runOne(args.Prompt) | 196 | + | startTurn(args.Prompt) |
| 166 | } | 197 | } | ||
| 167 | 198 | ||||
| 168 | // Inbox loop: one supervisor message at a time. We don't spawn | 199 | // Inbox loop: one supervisor message at a time. We don't spawn | ||
| @@ -172,19 +203,17 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||||
| 172 | for { | 203 | for { | ||
| 173 | select { | 204 | select { | ||
| 174 | case <-ctx.Done(): | 205 | case <-ctx.Done(): | ||
| 175 | - | em.emit("agent_stopped", map[string]any{"reason": "cancelled"}) | 206 | + | stopAndWait("cancelled") |
| 176 | return ctx.Err() | 207 | return ctx.Err() | ||
| 177 | - | case <-shutdown: | |||
| 178 | - | em.emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |||
| 179 | - | return nil | |||
| 180 | case msg, ok := <-ln.Lines(): | 208 | case msg, ok := <-ln.Lines(): | ||
| 181 | if !ok { | 209 | if !ok { | ||
| 182 | - | em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"}) | 210 | + | stopAndWait("inbox-closed") |
| 183 | return nil | 211 | return nil | ||
| 184 | } | 212 | } | ||
| 185 | switch { | 213 | switch { | ||
| 186 | case msg == "shutdown": | 214 | case msg == "shutdown": | ||
| 187 | - | close(shutdown) | 215 | + | stopAndWait("shutdown") |
| 216 | + | return nil | |||
| 188 | case msg == "cancel": | 217 | case msg == "cancel": | ||
| 189 | mu.Lock() | 218 | mu.Lock() | ||
| 190 | if cancelFn != nil { | 219 | if cancelFn != nil { | ||
| @@ -193,7 +222,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||||
| 193 | mu.Unlock() | 222 | mu.Unlock() | ||
| 194 | case strings.HasPrefix(msg, "user "): | 223 | case strings.HasPrefix(msg, "user "): | ||
| 195 | prompt := strings.TrimPrefix(msg, "user ") | 224 | prompt := strings.TrimPrefix(msg, "user ") | ||
| 196 | - | go runOne(prompt) | 225 | + | startTurn(prompt) |
| 197 | default: | 226 | default: | ||
| 198 | em.emit("error", map[string]any{ | 227 | em.emit("error", map[string]any{ | ||
| 199 | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | 228 | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | ||
M packages/agent/tools/lsp.go +18 -15
| @@ -173,7 +173,7 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a | |||
| 173 | 173 | } | |
| 174 | 174 | position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1} | |
| 175 | 175 | method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action] | |
| 176 | - | var params any | |
| 176 | + | var params map[string]any | |
| 177 | 177 | if args.Action == "symbols" { | |
| 178 | 178 | if args.Query != "" { | |
| 179 | 179 | method, params = "workspace/symbol", map[string]any{"query": args.Query} | |
| @@ -183,7 +183,7 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a | |||
| 183 | 183 | } else { | |
| 184 | 184 | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position} | |
| 185 | 185 | if args.Action == "references" { | |
| 186 | - | params.(map[string]any)["context"] = map[string]any{"includeDeclaration": true} | |
| 186 | + | params["context"] = map[string]any{"includeDeclaration": true} | |
| 187 | 187 | } | |
| 188 | 188 | if args.Action == "code_actions" { | |
| 189 | 189 | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}} | |
| @@ -192,7 +192,7 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a | |||
| 192 | 192 | if strings.TrimSpace(args.NewName) == "" { | |
| 193 | 193 | return "", nil, fmt.Errorf("new_name is required for rename") | |
| 194 | 194 | } | |
| 195 | - | params.(map[string]any)["newName"] = args.NewName | |
| 195 | + | params["newName"] = args.NewName | |
| 196 | 196 | } | |
| 197 | 197 | } | |
| 198 | 198 | responses := t.Manager.RequestAll(ctx, cwd, path, method, params) | |
| @@ -234,6 +234,7 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply | |||
| 234 | 234 | var b strings.Builder | |
| 235 | 235 | applied := 0 | |
| 236 | 236 | var firstErr error | |
| 237 | + | pendingEdits := make([]lsp.WorkspaceEdit, 0) | |
| 237 | 238 | for _, response := range responses { | |
| 238 | 239 | if response.Error != nil { | |
| 239 | 240 | fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error) | |
| @@ -244,17 +245,17 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply | |||
| 244 | 245 | } | |
| 245 | 246 | fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result)) | |
| 246 | 247 | if apply && (action == "rename" || action == "code_actions") { | |
| 247 | - | edits := workspaceEdits(response.Result, action) | |
| 248 | - | for _, edit := range edits { | |
| 249 | - | if err := manager.ApplyEdit(cwd, edit); err != nil { | |
| 250 | - | if firstErr == nil { | |
| 251 | - | firstErr = err | |
| 252 | - | } | |
| 253 | - | fmt.Fprintf(&b, "apply: error: %s\n", err) | |
| 254 | - | } else { | |
| 255 | - | applied++ | |
| 256 | - | } | |
| 248 | + | pendingEdits = append(pendingEdits, workspaceEdits(response.Result, action)...) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | if len(pendingEdits) > 0 { | |
| 252 | + | if err := manager.ApplyEdits(cwd, pendingEdits); err != nil { | |
| 253 | + | if firstErr == nil { | |
| 254 | + | firstErr = err | |
| 257 | 255 | } | |
| 256 | + | fmt.Fprintf(&b, "apply: error: %s\n", err) | |
| 257 | + | } else { | |
| 258 | + | applied = len(pendingEdits) | |
| 258 | 259 | } | |
| 259 | 260 | } | |
| 260 | 261 | if b.Len() == 0 { | |
| @@ -340,9 +341,11 @@ func boundLSPText(value string) string { | |||
| 340 | 341 | if len(value) <= maxLSPToolOutput { | |
| 341 | 342 | return value | |
| 342 | 343 | } | |
| 343 | - | cut := value[:maxLSPToolOutput] | |
| 344 | + | suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | |
| 345 | + | available := maxLSPToolOutput - len(suffix) | |
| 346 | + | cut := value[:available] | |
| 344 | 347 | for len(cut) > 0 && !utf8.ValidString(cut) { | |
| 345 | 348 | cut = cut[:len(cut)-1] | |
| 346 | 349 | } | |
| 347 | - | return cut + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | |
| 350 | + | return cut + suffix | |
| 348 | 351 | } | |
| @@ -173,7 +173,7 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a | |||||
| 173 | } | 173 | } | ||
| 174 | position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1} | 174 | position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1} | ||
| 175 | method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action] | 175 | method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action] | ||
| 176 | - | var params any | 176 | + | var params map[string]any |
| 177 | if args.Action == "symbols" { | 177 | if args.Action == "symbols" { | ||
| 178 | if args.Query != "" { | 178 | if args.Query != "" { | ||
| 179 | method, params = "workspace/symbol", map[string]any{"query": args.Query} | 179 | method, params = "workspace/symbol", map[string]any{"query": args.Query} | ||
| @@ -183,7 +183,7 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a | |||||
| 183 | } else { | 183 | } else { | ||
| 184 | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position} | 184 | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position} | ||
| 185 | if args.Action == "references" { | 185 | if args.Action == "references" { | ||
| 186 | - | params.(map[string]any)["context"] = map[string]any{"includeDeclaration": true} | 186 | + | params["context"] = map[string]any{"includeDeclaration": true} |
| 187 | } | 187 | } | ||
| 188 | if args.Action == "code_actions" { | 188 | if args.Action == "code_actions" { | ||
| 189 | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}} | 189 | params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}} | ||
| @@ -192,7 +192,7 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a | |||||
| 192 | if strings.TrimSpace(args.NewName) == "" { | 192 | if strings.TrimSpace(args.NewName) == "" { | ||
| 193 | return "", nil, fmt.Errorf("new_name is required for rename") | 193 | return "", nil, fmt.Errorf("new_name is required for rename") | ||
| 194 | } | 194 | } | ||
| 195 | - | params.(map[string]any)["newName"] = args.NewName | 195 | + | params["newName"] = args.NewName |
| 196 | } | 196 | } | ||
| 197 | } | 197 | } | ||
| 198 | responses := t.Manager.RequestAll(ctx, cwd, path, method, params) | 198 | responses := t.Manager.RequestAll(ctx, cwd, path, method, params) | ||
| @@ -234,6 +234,7 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply | |||||
| 234 | var b strings.Builder | 234 | var b strings.Builder | ||
| 235 | applied := 0 | 235 | applied := 0 | ||
| 236 | var firstErr error | 236 | var firstErr error | ||
| 237 | + | pendingEdits := make([]lsp.WorkspaceEdit, 0) | |||
| 237 | for _, response := range responses { | 238 | for _, response := range responses { | ||
| 238 | if response.Error != nil { | 239 | if response.Error != nil { | ||
| 239 | fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error) | 240 | fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error) | ||
| @@ -244,17 +245,17 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply | |||||
| 244 | } | 245 | } | ||
| 245 | fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result)) | 246 | fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result)) | ||
| 246 | if apply && (action == "rename" || action == "code_actions") { | 247 | if apply && (action == "rename" || action == "code_actions") { | ||
| 247 | - | edits := workspaceEdits(response.Result, action) | 248 | + | pendingEdits = append(pendingEdits, workspaceEdits(response.Result, action)...) |
| 248 | - | for _, edit := range edits { | 249 | + | } |
| 249 | - | if err := manager.ApplyEdit(cwd, edit); err != nil { | 250 | + | } |
| 250 | - | if firstErr == nil { | 251 | + | if len(pendingEdits) > 0 { |
| 251 | - | firstErr = err | 252 | + | if err := manager.ApplyEdits(cwd, pendingEdits); err != nil { |
| 252 | - | } | 253 | + | if firstErr == nil { |
| 253 | - | fmt.Fprintf(&b, "apply: error: %s\n", err) | 254 | + | firstErr = err |
| 254 | - | } else { | |||
| 255 | - | applied++ | |||
| 256 | - | } | |||
| 257 | } | 255 | } | ||
| 256 | + | fmt.Fprintf(&b, "apply: error: %s\n", err) | |||
| 257 | + | } else { | |||
| 258 | + | applied = len(pendingEdits) | |||
| 258 | } | 259 | } | ||
| 259 | } | 260 | } | ||
| 260 | if b.Len() == 0 { | 261 | if b.Len() == 0 { | ||
| @@ -340,9 +341,11 @@ func boundLSPText(value string) string { | |||||
| 340 | if len(value) <= maxLSPToolOutput { | 341 | if len(value) <= maxLSPToolOutput { | ||
| 341 | return value | 342 | return value | ||
| 342 | } | 343 | } | ||
| 343 | - | cut := value[:maxLSPToolOutput] | 344 | + | suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) |
| 345 | + | available := maxLSPToolOutput - len(suffix) | |||
| 346 | + | cut := value[:available] | |||
| 344 | for len(cut) > 0 && !utf8.ValidString(cut) { | 347 | for len(cut) > 0 && !utf8.ValidString(cut) { | ||
| 345 | cut = cut[:len(cut)-1] | 348 | cut = cut[:len(cut)-1] | ||
| 346 | } | 349 | } | ||
| 347 | - | return cut + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput) | 350 | + | return cut + suffix |
| 348 | } | 351 | } | ||
M packages/agent/tools/lsp_diagnostics.go +12 -0
| @@ -2,6 +2,7 @@ package tools | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -58,6 +59,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp. | |||
| 58 | 59 | defer cancel() | |
| 59 | 60 | diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path) | |
| 60 | 61 | if err != nil { | |
| 62 | + | if details, ok := result.Details.(map[string]any); ok { | |
| 63 | + | status := "unavailable" | |
| 64 | + | switch { | |
| 65 | + | case errors.Is(err, context.DeadlineExceeded): | |
| 66 | + | status = "timeout" | |
| 67 | + | case errors.Is(err, context.Canceled): | |
| 68 | + | status = "canceled" | |
| 69 | + | } | |
| 70 | + | details["diagnostics_status"] = status | |
| 71 | + | details["diagnostics_error"] = err.Error() | |
| 72 | + | } | |
| 61 | 73 | return | |
| 62 | 74 | } | |
| 63 | 75 | diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics) | |
| @@ -2,6 +2,7 @@ package tools | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -58,6 +59,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp. | |||||
| 58 | defer cancel() | 59 | defer cancel() | ||
| 59 | diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path) | 60 | diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path) | ||
| 60 | if err != nil { | 61 | if err != nil { | ||
| 62 | + | if details, ok := result.Details.(map[string]any); ok { | |||
| 63 | + | status := "unavailable" | |||
| 64 | + | switch { | |||
| 65 | + | case errors.Is(err, context.DeadlineExceeded): | |||
| 66 | + | status = "timeout" | |||
| 67 | + | case errors.Is(err, context.Canceled): | |||
| 68 | + | status = "canceled" | |||
| 69 | + | } | |||
| 70 | + | details["diagnostics_status"] = status | |||
| 71 | + | details["diagnostics_error"] = err.Error() | |||
| 72 | + | } | |||
| 61 | return | 73 | return | ||
| 62 | } | 74 | } | ||
| 63 | diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics) | 75 | diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics) | ||
M packages/agent/tools/lsp_test.go +1 -1
| @@ -64,7 +64,7 @@ func TestLSPToolGlobRespectsJailForSymlinkMatches(t *testing.T) { | |||
| 64 | 64 | ||
| 65 | 65 | func TestBoundLSPText(t *testing.T) { | |
| 66 | 66 | value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10)) | |
| 67 | - | if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") { | |
| 67 | + | if len(value) > maxLSPToolOutput || !strings.Contains(value, "truncated") { | |
| 68 | 68 | t.Fatalf("bound output = %d bytes", len(value)) | |
| 69 | 69 | } | |
| 70 | 70 | unicodeValue := boundLSPText(strings.Repeat("界", maxLSPToolOutput/3+10)) | |
| @@ -64,7 +64,7 @@ func TestLSPToolGlobRespectsJailForSymlinkMatches(t *testing.T) { | |||||
| 64 | 64 | ||||
| 65 | func TestBoundLSPText(t *testing.T) { | 65 | func TestBoundLSPText(t *testing.T) { | ||
| 66 | value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10)) | 66 | value := boundLSPText(strings.Repeat("x", maxLSPToolOutput+10)) | ||
| 67 | - | if len(value) <= maxLSPToolOutput || !strings.Contains(value, "truncated") { | 67 | + | if len(value) > maxLSPToolOutput || !strings.Contains(value, "truncated") { |
| 68 | t.Fatalf("bound output = %d bytes", len(value)) | 68 | t.Fatalf("bound output = %d bytes", len(value)) | ||
| 69 | } | 69 | } | ||
| 70 | unicodeValue := boundLSPText(strings.Repeat("界", maxLSPToolOutput/3+10)) | 70 | unicodeValue := boundLSPText(strings.Repeat("界", maxLSPToolOutput/3+10)) | ||
M packages/agent/tools/swarm_spawn.go +1 -1
| @@ -65,7 +65,7 @@ const swarmSpawnSchema = `{ | |||
| 65 | 65 | "properties": { | |
| 66 | 66 | "task": { | |
| 67 | 67 | "type": "string", | |
| 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." | |
| 68 | + | "description": "The full task description for the sub-agent. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it shares this working directory and starts with NO context from this conversation." | |
| 69 | 69 | }, | |
| 70 | 70 | "agent": { | |
| 71 | 71 | "type": "string", | |
| @@ -65,7 +65,7 @@ const swarmSpawnSchema = `{ | |||||
| 65 | "properties": { | 65 | "properties": { | ||
| 66 | "task": { | 66 | "task": { | ||
| 67 | "type": "string", | 67 | "type": "string", | ||
| 68 | - | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same built-in tools as the main agent (including lsp when enabled) and shares this working directory, but starts with NO context from this conversation." | 68 | + | "description": "The full task description for the sub-agent. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it shares this working directory and starts with NO context from this conversation." |
| 69 | }, | 69 | }, | ||
| 70 | "agent": { | 70 | "agent": { | ||
| 71 | "type": "string", | 71 | "type": "string", | ||