detached bcf2713

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
130130
131131 ## Safety and lifecycle
132132
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
135140 rename and code actions are restricted to the session workspace, reject
136141 resource operations, validate UTF-16 LSP positions, and write through a
137142 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 lifecycle131 ## Safety and lifecycle
132 132
133-Language servers and linters run as local child processes. The `lsp` tool never133+Language servers and linters run as local child processes. Project and
134-executes configured commands through a shell. Workspace edits returned by134+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, reject140 rename and code actions are restricted to the session workspace, reject
136 resource operations, validate UTF-16 LSP positions, and write through a141 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:
4040 |---|---|
4141 | `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. |
4242 | `description` | Short description shown to the main agent. |
43-| `tools` | Comma-separated or list-form tool names. zot 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. |
4444 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
4545 | `provider` | Optional separate provider ID for a model without a provider prefix. |
4646 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
4747 | `systemPromptMode` | `append` (default) or `replace`. |
4848 | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |
49-| `inheritSkills` | Set to `false` to omit skill discovery from the child. |
49+| `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |
5050
5151 Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot.
5252
@@ -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) {
624624 }
625625 subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != ""
626626 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))
628628
629629 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
630630
@@ -1055,7 +1055,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
10551055 }
10561056 var manager *lsp.Manager
10571057 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)
10591063 }
10601064 all := map[string]core.Tool{
10611065 "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.Manager1056 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 {
658658 defer stopExt()
659659
660660 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+ }()
662668 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
663669 sess, err := openOrCreateSession(args, r, ag, version)
664670 if err != nil {
@@ -713,7 +719,13 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
713719 defer stopExt()
714720
715721 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+ }()
717729 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
718730 sess, err := openOrCreateSession(args, r, ag, version)
719731 if err != nil {
@@ -807,7 +819,13 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
807819 defer stopExt()
808820
809821 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+ }()
811829 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
812830 sess, err := openOrCreateSession(args, r, ag, version)
813831 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 {
226226
227227 // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics
228228 // 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)
231231 }
232232
233233 // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics
234234 // 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
237237 }
238238
239239 // 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 diagnostics227 // 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 diagnostics233 // 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.LSPDiagnosticsOnEdit236+ 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 {
5858 stdout io.ReadCloser
5959 stderr io.ReadCloser
6060
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
7071
7172 options ClientOptions
7273 capMu sync.RWMutex
@@ -259,15 +260,13 @@ func (c *Client) DidOpen(path, languageID, text string) error {
259260 if err != nil {
260261 return err
261262 }
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+ })
268269 }
269- c.docs[uri] = version
270- c.docsMu.Unlock()
271270 return c.Notify("textDocument/didOpen", map[string]any{
272271 "textDocument": map[string]any{"uri": uri, "languageId": languageID, "version": version, "text": text},
273272 })
@@ -280,19 +279,25 @@ func (c *Client) DidChange(path, text string) error {
280279 if err != nil {
281280 return err
282281 }
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) {
283290 c.docsMu.Lock()
291+ defer c.docsMu.Unlock()
284292 version := c.docs[uri]
293+ alreadyOpen := version != 0
285294 if version == 0 {
286295 version = 1
287296 } else {
288297 version++
289298 }
290299 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
296301 }
297302
298303 func (c *Client) DidSave(path, text string) error {
@@ -338,12 +343,10 @@ func (c *Client) readLoop() {
338343 continue
339344 }
340345 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" {
347350 c.handleServerMessage(envelope)
348351 } else {
349352 go c.handleServerMessage(envelope)
@@ -387,7 +390,9 @@ func (c *Client) handleServerMessage(message rpcEnvelope) {
387390 _ = c.respond(message.ID, map[string]any{"applied": false, "failureReason": err.Error()}, nil)
388391 return
389392 }
393+ c.applyEditMu.Lock()
390394 err := c.applyEdit(params.Edit)
395+ c.applyEditMu.Unlock()
391396 result := map[string]any{"applied": err == nil}
392397 if err != nil {
393398 result["failureReason"] = err.Error()
@@ -449,7 +454,7 @@ func (c *Client) applyEdit(edit WorkspaceEdit) error {
449454 }
450455
451456 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}
453458 if rpcErr != nil {
454459 message["error"] = rpcErr
455460 } else {
@@ -538,10 +543,18 @@ func (c *Client) Close() error {
538543 _ = c.stdin.Close()
539544 c.writeMu.Unlock()
540545 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
543557 }
544- waitErr = c.cmd.Wait()
545558 if killed {
546559 // A process terminated by this cleanup path normally returns an
547560 // ExitError; preserve unrelated wait failures such as pipe
@@ -603,7 +616,7 @@ func ResolveCommand(cwd, command string) (string, error) {
603616 // executable lives at the repository root. Search each workspace
604617 // ancestor before falling back to PATH.
605618 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"} {
607620 candidates = append(candidates, filepath.Join(dir, local, command))
608621 }
609622 next := filepath.Dir(dir)
@@ -58,15 +58,16 @@ type Client struct {
58 stdout io.ReadCloser58 stdout io.ReadCloser
59 stderr io.ReadCloser59 stderr io.ReadCloser
60 60
61- writeMu sync.Mutex61+ writeMu sync.Mutex
62- mu sync.Mutex62+ applyEditMu sync.Mutex
63- pending map[int64]chan rpcResponse63+ mu sync.Mutex
64- nextID int6464+ pending map[int64]chan rpcResponse
65- closed bool65+ nextID int64
66- closeOnce sync.Once66+ closed bool
67- done chan struct{}67+ closeOnce sync.Once
68- doneOnce sync.Once68+ done chan struct{}
69- readErr atomic.Value // string; one concrete type keeps atomic.Value safe69+ doneOnce sync.Once
70+ readErr atomic.Value // string; one concrete type keeps atomic.Value safe
70 71
71 options ClientOptions72 options ClientOptions
72 capMu sync.RWMutex73 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 err261 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 = 1266+ "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 err280 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 = 1295 version = 1
287 } else {296 } else {
288 version++297 version++
289 }298 }
290 c.docs[uri] = version299 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 continue343 continue
339 }344 }
340 if envelope.Method != "" {345 if envelope.Method != "" {
341- // Diagnostics are ordered state: process publications on the346+ // Notifications carry ordered state, so handle them on the
342- // reader goroutine so a rapid clear/update sequence cannot be347+ // reader goroutine. Server requests may block while they wait
343- // reordered by notification handlers. Server requests may348+ // for an edit response, so those remain asynchronous.
344- // block while they wait for an edit response, so those remain349+ 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 return391 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"] = rpcErr459 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 := false545 killed := false
541- if c.cmd.Process != nil && c.cmd.ProcessState == nil {546+ exited := make(chan error, 1)
542- killed = c.cmd.Process.Kill() == nil547+ 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 an559 // A process terminated by this cleanup path normally returns an
547 // ExitError; preserve unrelated wait failures such as pipe560 // 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 workspace616 // 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 {
5757 {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"},
5858 {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"},
5959 {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"},
6161 {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"},
6262 }
6363 }
@@ -219,7 +219,10 @@ func hasMatchingFile(cwd string, types []string) bool {
219219 normalizedTypes := normalizeFileTypes(types)
220220 var found bool
221221 _ = 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 {
223226 return filepath.SkipAll
224227 }
225228 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 bool220 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.SkipAll226 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 {
151151 displayed++
152152 }
153153 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 != "" {
155155 lines = append(lines, "- "+secondaryLine)
156156 emittedSecondary[diagnostic.Path] = true
157- displayed++
157+ displayed += secondaryCount
158158 }
159159 }
160160 }
@@ -235,7 +235,7 @@ func moduleSecondary(diagnostics []Diagnostic) map[string]bool {
235235 return secondary
236236 }
237237
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) {
239239 count := 0
240240 modules := make(map[string]bool)
241241 for _, diagnostic := range diagnostics {
@@ -247,7 +247,7 @@ func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map
247247 }
248248 }
249249 if count == 0 {
250- return ""
250+ return "", 0
251251 }
252252 shownPath := path
253253 if root != "" {
@@ -260,7 +260,7 @@ func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map
260260 moduleNames = append(moduleNames, name)
261261 }
262262 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
264264 }
265265
266266 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] = true156 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 secondary235 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 := 0239 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 := path252 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 @@
11 package lsp
22
33 import (
4+ "bytes"
45 "encoding/json"
56 "fmt"
67 "os"
@@ -15,6 +16,10 @@ const maxWorkspaceEditFileBytes = 8 * 1024 * 1024
1516 // rename, delete, non-file URIs, symlink escapes, malformed ranges, and files
1617 // larger than the bounded safety limit.
1718 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 {
1823 root, err := filepath.Abs(root)
1924 if err != nil {
2025 return err
@@ -43,13 +48,29 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error {
4348 }
4449 all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...)
4550 }
46- targets, err := prepareEditTargets(rootReal, all)
51+ targets, err := prepareEditTargets(rootReal, all, checkWritePath)
4752 if err != nil {
4853 return err
4954 }
55+ type plannedEdit struct {
56+ location editTarget
57+ output []byte
58+ mode os.FileMode
59+ }
60+ plans := make([]plannedEdit, 0, len(targets))
5061 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)
5374 }
5475 }
5576 return nil
@@ -61,7 +82,7 @@ type editTarget struct {
6182 changes []TextEdit
6283 }
6384
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) {
6586 targets := make([]editTarget, 0, len(all))
6687 for uri, changes := range all {
6788 path, err := uriToPath(uri)
@@ -78,6 +99,11 @@ func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarge
7899 if err := safeWorkspacePath(rootReal, target); err != nil {
79100 return nil, err
80101 }
102+ if checkWritePath != nil {
103+ if err := checkWritePath(target); err != nil {
104+ return nil, err
105+ }
106+ }
81107 targets = append(targets, editTarget{path: path, target: target, changes: changes})
82108 }
83109 return targets, nil
@@ -89,12 +115,12 @@ func safeWorkspacePath(root, path string) error {
89115 return err
90116 }
91117 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
94120 } else {
95121 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))
98124 }
99125 }
100126 rel, err := filepath.Rel(root, candidate)
@@ -110,33 +136,30 @@ type byteEdit struct {
110136 text string
111137 }
112138
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) {
117140 info, err := os.Stat(path)
118141 if err != nil {
119- return err
142+ return nil, 0, err
120143 }
121144 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)
123146 }
124147 data, err := os.ReadFile(path)
125148 if err != nil {
126- return err
149+ return nil, 0, err
127150 }
128151 converted := make([]byteEdit, 0, len(edits))
129152 for _, edit := range edits {
130153 start, err := positionOffset(data, edit.Range.Start)
131154 if err != nil {
132- return err
155+ return nil, 0, err
133156 }
134157 end, err := positionOffset(data, edit.Range.End)
135158 if err != nil {
136- return err
159+ return nil, 0, err
137160 }
138161 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")
140163 }
141164 converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText})
142165 }
@@ -148,20 +171,29 @@ func applyTextEdits(path string, edits []TextEdit) error {
148171 })
149172 for i := 1; i < len(converted); i++ {
150173 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")
152175 }
153176 }
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
157184 }
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 {
158190 tmp, err := os.CreateTemp(filepath.Dir(path), ".zot-lsp-edit-*")
159191 if err != nil {
160192 return err
161193 }
162194 tmpName := tmp.Name()
163195 defer func() { _ = os.Remove(tmpName) }()
164- if err := tmp.Chmod(info.Mode().Perm()); err != nil {
196+ if err := tmp.Chmod(mode); err != nil {
165197 _ = tmp.Close()
166198 return err
167199 }
@@ -1,6 +1,7 @@
1 package lsp1 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 files16 // 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 err25 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 err53 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 nil76 return nil
@@ -61,7 +82,7 @@ type editTarget struct {
61 changes []TextEdit82 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, err100 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, nil109 return targets, nil
@@ -89,12 +115,12 @@ func safeWorkspacePath(root, path string) error {
89 return err115 return err
90 }116 }
91 candidate := abs117 candidate := abs
92- if real, err := filepath.EvalSymlinks(abs); err == nil {118+ if resolved, err := filepath.EvalSymlinks(abs); err == nil {
93- candidate = real119+ 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 string136 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 err142+ 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 err149+ 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 err155+ 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 err159+ 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 err192 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 err198 return err
167 }199 }
M packages/agent/lsp/framing.go +23 -7
@@ -10,7 +10,11 @@ import (
1010 "strings"
1111 )
1212
13-const maxMessageBytes = 32 * 1024 * 1024
13+const (
14+ maxMessageBytes = 32 * 1024 * 1024
15+ maxHeaderBytes = 8 * 1024
16+ maxHeaderLines = 64
17+)
1418
1519 // WriteMessage writes one LSP Content-Length framed JSON-RPC message.
1620 func WriteMessage(w io.Writer, payload []byte) error {
@@ -34,18 +38,28 @@ func WriteMessage(w io.Writer, payload []byte) error {
3438 // headers (including Content-Type) and either CRLF or LF header endings.
3539 func ReadMessage(r *bufio.Reader) ([]byte, error) {
3640 length := -1
41+ headerBytes := 0
42+ headerLines := 0
3743 for {
38- line, err := r.ReadString('\n')
44+ line, err := r.ReadSlice('\n')
3945 if err != nil {
46+ if err == bufio.ErrBufferFull {
47+ return nil, errors.New("LSP headers are too large")
48+ }
4049 return nil, err
4150 }
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 == "" {
4458 break
4559 }
46- key, value, ok := strings.Cut(line, ":")
60+ key, value, ok := strings.Cut(lineText, ":")
4761 if !ok {
48- return nil, fmt.Errorf("malformed LSP header %q", line)
62+ return nil, fmt.Errorf("malformed LSP header %q", lineText)
4963 }
5064 if strings.EqualFold(strings.TrimSpace(key), "content-length") {
5165 n, err := strconv.Atoi(strings.TrimSpace(value))
@@ -72,6 +86,8 @@ func ReadMessage(r *bufio.Reader) ([]byte, error) {
7286 // message. It returns the exact wire representation.
7387 func Frame(payload []byte) []byte {
7488 var b bytes.Buffer
75- _ = WriteMessage(&b, payload)
89+ if err := WriteMessage(&b, payload); err != nil {
90+ panic(err)
91+ }
7692 return b.Bytes()
7793 }
@@ -10,7 +10,11 @@ import (
10 "strings"10 "strings"
11 )11 )
12 12
13-const maxMessageBytes = 32 * 1024 * 102413+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 := -140 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, err49 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 break58 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.Buffer88 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 (
99 "os"
1010 "path/filepath"
1111 "runtime"
12+ "strconv"
1213 "strings"
1314 "testing"
1415 "time"
@@ -103,6 +104,12 @@ func TestFileURIPathRoundTrip(t *testing.T) {
103104 if filepath.Clean(got) != filepath.Clean(abs) {
104105 t.Fatalf("round-trip path = %q, want %q", got, abs)
105106 }
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+ }
106113 if runtime.GOOS == "windows" {
107114 unc := `\\server\share\main.go`
108115 uncURI, err := pathToURI(unc)
@@ -119,6 +126,25 @@ func TestFileURIPathRoundTrip(t *testing.T) {
119126 }
120127 }
121128
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+
122148 func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
123149 payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
124150 wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
@@ -190,7 +216,7 @@ func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) {
190216 root := t.TempDir()
191217 zotHome := t.TempDir()
192218 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 {
194220 t.Fatal(err)
195221 }
196222 configFile := `{
@@ -221,7 +247,7 @@ func TestLoadConfigAcceptsPiLSPBridgeProviders(t *testing.T) {
221247 func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
222248 root := t.TempDir()
223249 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 {
225251 t.Fatal(err)
226252 }
227253 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) {
374400 _, err = prepareEditTargets(rootReal, map[string][]TextEdit{
375401 URIForPath(inside): {{Range: Range{}, NewText: "inside"}},
376402 URIForPath(outside): {{Range: Range{}, NewText: "outside"}},
377- })
403+ }, nil)
378404 if err == nil {
379405 t.Fatal("workspace edit accepted an unsafe target after a safe target")
380406 }
@@ -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 {
2323 LinterTimeout time.Duration
2424 Settings map[string]any
2525 OnLog func(server, message string)
26+ CheckWritePath func(path string) error
2627 }
2728
2829 // 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
163164 // A server's LSP root may be an ancestor of the session cwd.
164165 // Keep server-initiated edits inside the session workspace rather
165166 // 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+ },
167170 }
168171 startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
169172 client, err := NewClientWithContext(startCtx, spec, root, opts)
@@ -494,11 +497,24 @@ func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, para
494497
495498 // ApplyEdit applies a workspace edit under the selected workspace root.
496499 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 {
497506 ws, err := m.workspace(cwd)
498507 if err != nil {
499508 return err
500509 }
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)
502518 }
503519
504520 // Status describes configured and cached servers without starting missing
@@ -519,16 +535,25 @@ func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
519535 if err != nil {
520536 return nil, err
521537 }
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+ }
522547 m.mu.Lock()
523548 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]}
527552 if status.State == "" {
528553 status.State = "stopped"
529554 }
530555 if status.State == "stopped" {
531- if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
556+ if resolveErr := resolveErrors[spec.ID]; resolveErr != nil {
532557 status.State = "missing"
533558 status.Error = resolveErr.Error()
534559 }
@@ -23,6 +23,7 @@ type ManagerOptions struct {
23 LinterTimeout time.Duration23 LinterTimeout time.Duration
24 Settings map[string]any24 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 produced29 // 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 rather165 // 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 err508 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 missing520 // 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, err536 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 {
190190 columnNo, _ := strconv.Atoi(match[3])
191191 message := strings.TrimSpace(match[5])
192192 if message == "" {
193- message = strings.TrimSpace(match[4])
193+ message = line
194194 }
195195 severity := normalizeSeverity(match[4])
196196 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) {
150150 if path == "" {
151151 return "", fmt.Errorf("document URI has no path")
152152 }
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:/...
154154 path = path[1:]
155155 }
156156 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)), nil156 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 {
115115 resolved.MergeExtensionTools(adapter)
116116 oldTools := ag.Tools
117117 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+ }
119121 })
120122
121123 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.Tools116 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 {
9696 model string
9797 cwd string
9898
99- // activeCancel is set while a Prompt is streaming.
99+ // activeCancel is set while a Prompt or Compact is running.
100100 activeCancel context.CancelFunc
101+ activeDone chan struct{}
101102
102103 // closed signals that Close has been called.
103104 closed bool
@@ -218,7 +219,9 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch
218219 return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)")
219220 }
220221 subCtx, cancel := context.WithCancel(ctx)
222+ done := make(chan struct{})
221223 r.activeCancel = cancel
224+ r.activeDone = done
222225 r.mu.Unlock()
223226
224227 imgBlocks := make([]provider.ImageBlock, 0, len(images))
@@ -232,6 +235,8 @@ func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-ch
232235 defer func() {
233236 r.mu.Lock()
234237 r.activeCancel = nil
238+ r.activeDone = nil
239+ close(done)
235240 r.mu.Unlock()
236241 }()
237242 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
267272 return CompactResult{}, ErrBusy
268273 }
269274 subCtx, cancel := context.WithCancel(ctx)
275+ done := make(chan struct{})
270276 r.activeCancel = cancel
277+ r.activeDone = done
271278 r.mu.Unlock()
272279 defer func() {
273280 r.mu.Lock()
274281 r.activeCancel = nil
282+ r.activeDone = nil
283+ close(done)
275284 r.mu.Unlock()
276285 }()
277286
@@ -340,11 +349,17 @@ func (r *Runtime) Close() error {
340349 }
341350 r.closed = true
342351 cancel := r.activeCancel
343- ag := r.agent
352+ done := r.activeDone
344353 r.mu.Unlock()
345354 if cancel != nil {
346355 cancel()
347356 }
357+ if done != nil {
358+ <-done
359+ }
360+ r.mu.Lock()
361+ ag := r.agent
362+ r.mu.Unlock()
348363 if ag != nil {
349364 return agenttools.CloseLSPManagers(ag.Tools)
350365 }
@@ -96,8 +96,9 @@ type Runtime struct {
96 model string96 model string
97 cwd string97 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.CancelFunc100 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 bool104 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 = cancel223 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 = nil237 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{}, ErrBusy272 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 = cancel276 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 = nil281 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 = true350 r.closed = true
342 cancel := r.activeCancel351 cancel := r.activeCancel
343- ag := r.agent352+ 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) {
149149 if cfg.LSPEnabledFor(false) || cfg.LSPEnabledFor(true) {
150150 t.Fatal("explicitly disabled LSP settings were ignored")
151151 }
152+ yesSub := true
153+ cfg.SubagentLSPEnabled = &yesSub
152154 write, edit := true, true
153155 cfg.LSPDiagnosticsOnWrite = &write
154156 cfg.LSPDiagnosticsOnEdit = &edit
155- if cfg.LSPDiagnosticsOnWriteEnabled() || cfg.LSPDiagnosticsOnEditEnabled() {
157+ if cfg.LSPDiagnosticsOnWriteEnabled(false) || cfg.LSPDiagnosticsOnEditEnabled(false) {
156158 t.Fatal("diagnostics remained enabled with main-session LSP disabled")
157159 }
160+ if !cfg.LSPDiagnosticsOnWriteEnabled(true) || !cfg.LSPDiagnosticsOnEditEnabled(true) {
161+ t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings")
162+ }
158163 }
@@ -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, true154 write, edit := true, true
153 cfg.LSPDiagnosticsOnWrite = &write155 cfg.LSPDiagnosticsOnWrite = &write
154 cfg.LSPDiagnosticsOnEdit = &edit156 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 {
5959 defer stopExt()
6060
6161 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+ }()
6369 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
6470 sess, err := openOrCreateSession(args, r, ag, version)
6571 if err != nil {
@@ -115,8 +121,9 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
115121 turnCtx context.Context = ctx
116122 cancelFn context.CancelFunc
117123 busyTurn bool
124+ stopping bool
118125 turnNo int
119- shutdown = make(chan struct{})
126+ turns sync.WaitGroup
120127 )
121128
122129 runOne := func(prompt string) {
@@ -158,11 +165,35 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
158165 mu.Unlock()
159166 }
160167
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+
161192 // Initial task: run before processing the inbox so the agent
162193 // "starts working" the moment it boots, matching what users
163194 // expect from `/swarm new <task>`.
164195 if args.Prompt != "" {
165- go runOne(args.Prompt)
196+ startTurn(args.Prompt)
166197 }
167198
168199 // 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 {
172203 for {
173204 select {
174205 case <-ctx.Done():
175- em.emit("agent_stopped", map[string]any{"reason": "cancelled"})
206+ stopAndWait("cancelled")
176207 return ctx.Err()
177- case <-shutdown:
178- em.emit("agent_stopped", map[string]any{"reason": "shutdown"})
179- return nil
180208 case msg, ok := <-ln.Lines():
181209 if !ok {
182- em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"})
210+ stopAndWait("inbox-closed")
183211 return nil
184212 }
185213 switch {
186214 case msg == "shutdown":
187- close(shutdown)
215+ stopAndWait("shutdown")
216+ return nil
188217 case msg == "cancel":
189218 mu.Lock()
190219 if cancelFn != nil {
@@ -193,7 +222,7 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
193222 mu.Unlock()
194223 case strings.HasPrefix(msg, "user "):
195224 prompt := strings.TrimPrefix(msg, "user ")
196- go runOne(prompt)
225+ startTurn(prompt)
197226 default:
198227 em.emit("error", map[string]any{
199228 "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 = ctx121 turnCtx context.Context = ctx
116 cancelFn context.CancelFunc122 cancelFn context.CancelFunc
117 busyTurn bool123 busyTurn bool
124+ stopping bool
118 turnNo int125 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 agent192 // Initial task: run before processing the inbox so the agent
162 // "starts working" the moment it boots, matching what users193 // "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 spawn199 // 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 nil211 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
173173 }
174174 position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1}
175175 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
177177 if args.Action == "symbols" {
178178 if args.Query != "" {
179179 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
183183 } else {
184184 params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position}
185185 if args.Action == "references" {
186- params.(map[string]any)["context"] = map[string]any{"includeDeclaration": true}
186+ params["context"] = map[string]any{"includeDeclaration": true}
187187 }
188188 if args.Action == "code_actions" {
189189 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
192192 if strings.TrimSpace(args.NewName) == "" {
193193 return "", nil, fmt.Errorf("new_name is required for rename")
194194 }
195- params.(map[string]any)["newName"] = args.NewName
195+ params["newName"] = args.NewName
196196 }
197197 }
198198 responses := t.Manager.RequestAll(ctx, cwd, path, method, params)
@@ -234,6 +234,7 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply
234234 var b strings.Builder
235235 applied := 0
236236 var firstErr error
237+ pendingEdits := make([]lsp.WorkspaceEdit, 0)
237238 for _, response := range responses {
238239 if response.Error != nil {
239240 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
244245 }
245246 fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
246247 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
257255 }
256+ fmt.Fprintf(&b, "apply: error: %s\n", err)
257+ } else {
258+ applied = len(pendingEdits)
258259 }
259260 }
260261 if b.Len() == 0 {
@@ -340,9 +341,11 @@ func boundLSPText(value string) string {
340341 if len(value) <= maxLSPToolOutput {
341342 return value
342343 }
343- cut := value[:maxLSPToolOutput]
344+ suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput)
345+ available := maxLSPToolOutput - len(suffix)
346+ cut := value[:available]
344347 for len(cut) > 0 && !utf8.ValidString(cut) {
345348 cut = cut[:len(cut)-1]
346349 }
347- return cut + fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput)
350+ return cut + suffix
348351 }
@@ -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 any176+ 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.NewName195+ 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.Builder234 var b strings.Builder
235 applied := 0235 applied := 0
236 var firstErr error236 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 = err252+ 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 value342 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
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67 "time"
78
@@ -58,6 +59,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.
5859 defer cancel()
5960 diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
6061 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+ }
6173 return
6274 }
6375 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 return73 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) {
6464
6565 func TestBoundLSPText(t *testing.T) {
6666 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") {
6868 t.Fatalf("bound output = %d bytes", len(value))
6969 }
7070 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 = `{
6565 "properties": {
6666 "task": {
6767 "type": "string",
68- "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same 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."
6969 },
7070 "agent": {
7171 "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",

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close