@oh-my-pi/pi-coding-agent 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [6.2.0] - 2026-01-19
6
+ ### Changed
7
+
8
+ - Improved LSP batching to coalesce formatting and diagnostics for parallel edits
9
+ - Updated edit and write tools to support batched LSP operations
10
+
11
+ ### Fixed
12
+
13
+ - Coalesced LSP formatting/diagnostics for parallel edits so only the final write triggers LSP across touched files
14
+
15
+ ## [6.1.0] - 2026-01-19
16
+
17
+ ### Added
18
+
19
+ - Added lspmux integration for LSP server multiplexing to reduce startup time and memory usage
20
+ - Added LSP tool proxy support for subagent workers
21
+ - Updated LSP status command to show lspmux connection state
22
+ - Added maxdepth and mindepth parameters to find function for depth-controlled file search
23
+ - Added counter function to count occurrences and sort by frequency
24
+ - Added basenames function to extract base names from paths
25
+
26
+ ### Changed
27
+
28
+ - Simplified rust-analyzer default configuration by removing custom initOptions and settings
29
+
5
30
  ## [6.0.0] - 2026-01-19
6
31
 
7
32
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "6.0.0",
3
+ "version": "6.2.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,10 +40,10 @@
40
40
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/pi-agent-core": "6.0.0",
44
- "@oh-my-pi/pi-ai": "6.0.0",
45
- "@oh-my-pi/pi-git-tool": "6.0.0",
46
- "@oh-my-pi/pi-tui": "6.0.0",
43
+ "@oh-my-pi/pi-agent-core": "6.2.0",
44
+ "@oh-my-pi/pi-ai": "6.2.0",
45
+ "@oh-my-pi/pi-git-tool": "6.2.0",
46
+ "@oh-my-pi/pi-tui": "6.2.0",
47
47
  "@openai/agents": "^0.3.7",
48
48
  "@sinclair/typebox": "^0.34.46",
49
49
  "ajv": "^8.17.1",
@@ -191,14 +191,26 @@ if "__omp_prelude_loaded__" not in globals():
191
191
  limit: int = 1000,
192
192
  hidden: bool = False,
193
193
  sort_by_mtime: bool = False,
194
+ maxdepth: int | None = None,
195
+ mindepth: int | None = None,
194
196
  ) -> list[Path]:
195
- """Recursive glob find. Respects .gitignore."""
196
- p = Path(path)
197
+ """Recursive glob find. Respects .gitignore.
198
+
199
+ maxdepth/mindepth are relative to path (0 = path itself, 1 = direct children).
200
+ """
201
+ p = Path(path).resolve()
202
+ base_depth = len(p.parts)
197
203
  ignore_patterns = _load_gitignore_patterns(p)
198
204
  matches: list[Path] = []
199
205
  for m in p.rglob(pattern):
200
206
  if len(matches) >= limit:
201
207
  break
208
+ # Check depth constraints
209
+ rel_depth = len(m.resolve().parts) - base_depth
210
+ if maxdepth is not None and rel_depth > maxdepth:
211
+ continue
212
+ if mindepth is not None and rel_depth < mindepth:
213
+ continue
202
214
  # Skip hidden files unless requested
203
215
  if not hidden and any(part.startswith(".") for part in m.parts):
204
216
  continue
@@ -485,6 +497,30 @@ if "__omp_prelude_loaded__" not in globals():
485
497
  return groups
486
498
  return "\n".join(line for _, line in groups)
487
499
 
500
+ @_category("Text")
501
+ def counter(
502
+ items: str | list,
503
+ *,
504
+ limit: int | None = None,
505
+ reverse: bool = True,
506
+ ) -> list[tuple[int, str]]:
507
+ """Count occurrences and sort by frequency. Like sort | uniq -c | sort -rn.
508
+
509
+ items: text (splits into lines) or list of strings
510
+ reverse: True for descending (most common first), False for ascending
511
+ Returns: [(count, item), ...] sorted by count
512
+ """
513
+ from collections import Counter
514
+ if isinstance(items, str):
515
+ items = items.splitlines()
516
+ counts = Counter(items)
517
+ sorted_items = sorted(counts.items(), key=lambda x: (x[1], x[0]), reverse=reverse)
518
+ if limit is not None:
519
+ sorted_items = sorted_items[:limit]
520
+ result = [(count, item) for item, count in sorted_items]
521
+ _emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
522
+ return result
523
+
488
524
  @_category("Text")
489
525
  def cols(text: str, *indices: int, sep: str | None = None) -> str:
490
526
  """Extract columns from text (0-indexed). Like cut."""
@@ -497,6 +533,13 @@ if "__omp_prelude_loaded__" not in globals():
497
533
  _emit_status("cols", lines=len(result_lines), columns=list(indices))
498
534
  return out
499
535
 
536
+ @_category("Navigation")
537
+ def basenames(paths: list[str | Path]) -> list[str]:
538
+ """Extract basename from each path. Like: sed 's|.*/||'."""
539
+ names = [Path(p).name for p in paths]
540
+ _emit_status("basenames", count=len(names), sample=names[:10])
541
+ return names
542
+
500
543
  @_category("Navigation")
501
544
  def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
502
545
  """Return directory tree."""
@@ -1,4 +1,4 @@
1
- import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentToolContext, ToolCallContext } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CustomToolContext } from "../custom-tools/types";
3
3
  import type { ExtensionUIContext } from "../extensions/types";
4
4
 
@@ -7,11 +7,12 @@ declare module "@oh-my-pi/pi-agent-core" {
7
7
  ui?: ExtensionUIContext;
8
8
  hasUI?: boolean;
9
9
  toolNames?: string[];
10
+ toolCall?: ToolCallContext;
10
11
  }
11
12
  }
12
13
 
13
14
  export interface ToolContextStore {
14
- getContext(): AgentToolContext;
15
+ getContext(toolCall?: ToolCallContext): AgentToolContext;
15
16
  setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
16
17
  setToolNames(names: string[]): void;
17
18
  }
@@ -22,11 +23,12 @@ export function createToolContextStore(getBaseContext: () => CustomToolContext):
22
23
  let toolNames: string[] = [];
23
24
 
24
25
  return {
25
- getContext: () => ({
26
+ getContext: (toolCall) => ({
26
27
  ...getBaseContext(),
27
28
  ui: uiContext,
28
29
  hasUI,
29
30
  toolNames,
31
+ toolCall,
30
32
  }),
31
33
  setUIContext: (context, uiAvailable) => {
32
34
  uiContext = context;
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolContext, ToolCallContext } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { Type } from "@sinclair/typebox";
@@ -41,6 +41,22 @@ export interface EditToolDetails {
41
41
  diagnostics?: FileDiagnosticsResult;
42
42
  }
43
43
 
44
+ const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
45
+
46
+ function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined {
47
+ if (!toolCall) {
48
+ return undefined;
49
+ }
50
+ const hasOtherWrites = toolCall.toolCalls.some(
51
+ (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
52
+ );
53
+ if (!hasOtherWrites) {
54
+ return undefined;
55
+ }
56
+ const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some((call) => LSP_BATCH_TOOLS.has(call.name));
57
+ return { id: toolCall.batchId, flush: !hasLaterWrites };
58
+ }
59
+
44
60
  export function createEditTool(session: ToolSession): AgentTool<typeof editSchema> {
45
61
  const allowFuzzy = session.settings?.getEditFuzzyMatch() ?? true;
46
62
  const enableLsp = session.enableLsp ?? true;
@@ -58,6 +74,8 @@ export function createEditTool(session: ToolSession): AgentTool<typeof editSchem
58
74
  _toolCallId: string,
59
75
  { path, oldText, newText, all }: { path: string; oldText: string; newText: string; all?: boolean },
60
76
  signal?: AbortSignal,
77
+ _onUpdate?: unknown,
78
+ context?: AgentToolContext,
61
79
  ) => {
62
80
  // Reject .ipynb files - use NotebookEdit tool instead
63
81
  if (path.endsWith(".ipynb")) {
@@ -163,7 +181,8 @@ export function createEditTool(session: ToolSession): AgentTool<typeof editSchem
163
181
  }
164
182
 
165
183
  const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
166
- const diagnostics = await writethrough(absolutePath, finalContent, signal, file);
184
+ const batchRequest = getLspBatchRequest(context?.toolCall);
185
+ const diagnostics = await writethrough(absolutePath, finalContent, signal, file, batchRequest);
167
186
 
168
187
  const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
169
188
 
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { logger } from "../../logger";
3
3
  import { applyWorkspaceEdit } from "./edits";
4
+ import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
4
5
  import type {
5
6
  Diagnostic,
6
7
  LspClient,
@@ -400,13 +401,20 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
400
401
 
401
402
  // Create new client with lock
402
403
  const clientPromise = (async () => {
403
- const args = config.args ?? [];
404
- const command = config.resolvedCommand ?? config.command;
404
+ const baseCommand = config.resolvedCommand ?? config.command;
405
+ const baseArgs = config.args ?? [];
406
+
407
+ // Wrap with lspmux if available and supported
408
+ const { command, args, env } = isLspmuxSupported(baseCommand)
409
+ ? await getLspmuxCommand(baseCommand, baseArgs)
410
+ : { command: baseCommand, args: baseArgs };
411
+
405
412
  const proc = Bun.spawn([command, ...args], {
406
413
  cwd,
407
414
  stdin: "pipe",
408
415
  stdout: "pipe",
409
416
  stderr: "pipe",
417
+ env: env ? { ...process.env, ...env } : undefined,
410
418
  });
411
419
 
412
420
  const client: LspClient = {
@@ -4,17 +4,8 @@
4
4
  "args": [],
5
5
  "fileTypes": [".rs"],
6
6
  "rootMarkers": ["Cargo.toml", "rust-analyzer.toml"],
7
- "initOptions": {
8
- "checkOnSave": { "command": "clippy" },
9
- "cargo": { "allFeatures": true },
10
- "procMacro": { "enable": true }
11
- },
12
- "settings": {
13
- "rust-analyzer": {
14
- "diagnostics": { "enable": true },
15
- "inlayHints": { "enable": true }
16
- }
17
- },
7
+ "initOptions": {},
8
+ "settings": {},
18
9
  "capabilities": {
19
10
  "flycheck": true,
20
11
  "ssr": true,
@@ -25,6 +25,7 @@ import {
25
25
  import { getLinterClient } from "./clients";
26
26
  import { getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
27
27
  import { applyTextEditsToString, applyWorkspaceEdit } from "./edits";
28
+ import { detectLspmux } from "./lspmux";
28
29
  import { renderCall, renderResult } from "./render";
29
30
  import * as rustAnalyzer from "./rust-analyzer";
30
31
  import {
@@ -666,6 +667,7 @@ export type WritethroughCallback = (
666
667
  content: string,
667
668
  signal?: AbortSignal,
668
669
  file?: BunFile,
670
+ batch?: LspWritethroughBatchRequest,
669
671
  ) => Promise<FileDiagnosticsResult | undefined>;
670
672
 
671
673
  /** No-op writethrough callback */
@@ -683,83 +685,241 @@ export async function writethroughNoop(
683
685
  return undefined;
684
686
  }
685
687
 
686
- /** Create a writethrough callback for LSP aware write operations */
687
- export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
688
- const { enableFormat = false, enableDiagnostics = false } = options ?? {};
689
- if (!enableFormat && !enableDiagnostics) {
690
- return writethroughNoop;
688
+ interface PendingWritethrough {
689
+ dst: string;
690
+ content: string;
691
+ file?: BunFile;
692
+ }
693
+
694
+ interface LspWritethroughBatchRequest {
695
+ id: string;
696
+ flush: boolean;
697
+ }
698
+
699
+ interface LspWritethroughBatchState {
700
+ entries: Map<string, PendingWritethrough>;
701
+ options: Required<WritethroughOptions>;
702
+ }
703
+
704
+ const writethroughBatches = new Map<string, LspWritethroughBatchState>();
705
+
706
+ function getOrCreateWritethroughBatch(id: string, options: Required<WritethroughOptions>): LspWritethroughBatchState {
707
+ const existing = writethroughBatches.get(id);
708
+ if (existing) {
709
+ existing.options.enableFormat ||= options.enableFormat;
710
+ existing.options.enableDiagnostics ||= options.enableDiagnostics;
711
+ return existing;
712
+ }
713
+ const batch: LspWritethroughBatchState = {
714
+ entries: new Map<string, PendingWritethrough>(),
715
+ options: { ...options },
716
+ };
717
+ writethroughBatches.set(id, batch);
718
+ return batch;
719
+ }
720
+
721
+ function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
722
+ const counts = { error: 0, warning: 0, info: 0, hint: 0 };
723
+ for (const message of messages) {
724
+ const match = message.match(/\[(error|warning|info|hint)\]/i);
725
+ if (!match) continue;
726
+ const key = match[1].toLowerCase() as keyof typeof counts;
727
+ counts[key] += 1;
691
728
  }
692
- return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => {
693
- const config = await getConfig(cwd);
694
- const servers = getServersForFile(config, dst);
695
- if (servers.length === 0) {
696
- return writethroughNoop(dst, content, signal, file);
729
+
730
+ const parts: string[] = [];
731
+ if (counts.error > 0) parts.push(`${counts.error} error(s)`);
732
+ if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
733
+ if (counts.info > 0) parts.push(`${counts.info} info(s)`);
734
+ if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
735
+
736
+ return {
737
+ summary: parts.length > 0 ? parts.join(", ") : "no issues",
738
+ errored: counts.error > 0,
739
+ };
740
+ }
741
+
742
+ function mergeDiagnostics(
743
+ results: Array<FileDiagnosticsResult | undefined>,
744
+ options: Required<WritethroughOptions>,
745
+ ): FileDiagnosticsResult | undefined {
746
+ const messages: string[] = [];
747
+ const servers = new Set<string>();
748
+ let hasResults = false;
749
+ let hasFormatter = false;
750
+ let formatted = false;
751
+
752
+ for (const result of results) {
753
+ if (!result) continue;
754
+ hasResults = true;
755
+ if (result.server) {
756
+ for (const server of result.server.split(",")) {
757
+ const trimmed = server.trim();
758
+ if (trimmed) {
759
+ servers.add(trimmed);
760
+ }
761
+ }
762
+ }
763
+ if (result.messages.length > 0) {
764
+ messages.push(...result.messages);
697
765
  }
698
- const { lspServers, customLinterServers } = splitServers(servers);
766
+ if (result.formatter !== undefined) {
767
+ hasFormatter = true;
768
+ if (result.formatter === FileFormatResult.FORMATTED) {
769
+ formatted = true;
770
+ }
771
+ }
772
+ }
699
773
 
700
- let finalContent = content;
701
- const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
702
- const getWritePromise = once(() => writeContent(finalContent));
703
- const useCustomFormatter = enableFormat && customLinterServers.length > 0;
774
+ if (!hasResults && !hasFormatter) {
775
+ return undefined;
776
+ }
704
777
 
705
- // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
706
- const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
778
+ let summary = options.enableDiagnostics ? "no issues" : "OK";
779
+ let errored = false;
780
+ if (messages.length > 0) {
781
+ const summaryInfo = summarizeDiagnosticMessages(messages);
782
+ summary = summaryInfo.summary;
783
+ errored = summaryInfo.errored;
784
+ }
785
+ const formatter = hasFormatter ? (formatted ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED) : undefined;
707
786
 
708
- let formatter: FileFormatResult | undefined;
709
- let diagnostics: FileDiagnosticsResult | undefined;
710
- try {
711
- const timeoutSignal = AbortSignal.timeout(10_000);
712
- const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
713
- await untilAborted(operationSignal, async () => {
714
- if (useCustomFormatter) {
715
- // Custom linters (e.g. Biome CLI) require on-disk input.
716
- await writeContent(content);
717
- finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
787
+ return {
788
+ server: servers.size > 0 ? Array.from(servers).join(", ") : undefined,
789
+ messages,
790
+ summary,
791
+ errored,
792
+ formatter,
793
+ };
794
+ }
795
+
796
+ async function runLspWritethrough(
797
+ dst: string,
798
+ content: string,
799
+ cwd: string,
800
+ options: Required<WritethroughOptions>,
801
+ signal?: AbortSignal,
802
+ file?: BunFile,
803
+ ): Promise<FileDiagnosticsResult | undefined> {
804
+ const { enableFormat, enableDiagnostics } = options;
805
+ const config = await getConfig(cwd);
806
+ const servers = getServersForFile(config, dst);
807
+ if (servers.length === 0) {
808
+ return writethroughNoop(dst, content, signal, file);
809
+ }
810
+ const { lspServers, customLinterServers } = splitServers(servers);
811
+
812
+ let finalContent = content;
813
+ const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
814
+ const getWritePromise = once(() => writeContent(finalContent));
815
+ const useCustomFormatter = enableFormat && customLinterServers.length > 0;
816
+
817
+ // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
818
+ const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
819
+
820
+ let formatter: FileFormatResult | undefined;
821
+ let diagnostics: FileDiagnosticsResult | undefined;
822
+ try {
823
+ const timeoutSignal = AbortSignal.timeout(10_000);
824
+ const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
825
+ await untilAborted(operationSignal, async () => {
826
+ if (useCustomFormatter) {
827
+ // Custom linters (e.g. Biome CLI) require on-disk input.
828
+ await writeContent(content);
829
+ finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
830
+ formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
831
+ await writeContent(finalContent);
832
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
833
+ } else {
834
+ // 1. Sync original content to LSP servers
835
+ await syncFileContent(dst, content, cwd, lspServers, operationSignal);
836
+
837
+ // 2. Format in-memory via LSP
838
+ if (enableFormat) {
839
+ finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
718
840
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
719
- await writeContent(finalContent);
841
+ }
842
+
843
+ // 3. If formatted, sync formatted content to LSP servers
844
+ if (finalContent !== content) {
720
845
  await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
721
- } else {
722
- // 1. Sync original content to LSP servers
723
- await syncFileContent(dst, content, cwd, lspServers, operationSignal);
724
-
725
- // 2. Format in-memory via LSP
726
- if (enableFormat) {
727
- finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
728
- formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
729
- }
846
+ }
730
847
 
731
- // 3. If formatted, sync formatted content to LSP servers
732
- if (finalContent !== content) {
733
- await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
734
- }
848
+ // 4. Write to disk
849
+ await getWritePromise();
850
+ }
735
851
 
736
- // 4. Write to disk
737
- await getWritePromise();
738
- }
852
+ // 5. Notify saved to LSP servers
853
+ await notifyFileSaved(dst, cwd, lspServers, operationSignal);
739
854
 
740
- // 5. Notify saved to LSP servers
741
- await notifyFileSaved(dst, cwd, lspServers, operationSignal);
855
+ // 6. Get diagnostics from all servers (wait for fresh results)
856
+ if (enableDiagnostics) {
857
+ diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal, minVersions);
858
+ }
859
+ });
860
+ } catch {
861
+ await getWritePromise();
862
+ }
742
863
 
743
- // 6. Get diagnostics from all servers (wait for fresh results)
744
- if (enableDiagnostics) {
745
- diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal, minVersions);
746
- }
747
- });
748
- } catch {
749
- await getWritePromise();
864
+ if (formatter !== undefined) {
865
+ diagnostics ??= {
866
+ server: servers.map(([name]) => name).join(", "),
867
+ messages: [],
868
+ summary: "OK",
869
+ errored: false,
870
+ };
871
+ diagnostics.formatter = formatter;
872
+ }
873
+
874
+ return diagnostics;
875
+ }
876
+
877
+ async function flushWritethroughBatch(
878
+ batch: PendingWritethrough[],
879
+ cwd: string,
880
+ options: Required<WritethroughOptions>,
881
+ signal?: AbortSignal,
882
+ ): Promise<FileDiagnosticsResult | undefined> {
883
+ if (batch.length === 0) {
884
+ return undefined;
885
+ }
886
+ const results: Array<FileDiagnosticsResult | undefined> = [];
887
+ for (const entry of batch) {
888
+ results.push(await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file));
889
+ }
890
+ return mergeDiagnostics(results, options);
891
+ }
892
+
893
+ /** Create a writethrough callback for LSP aware write operations */
894
+ export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
895
+ const resolvedOptions: Required<WritethroughOptions> = {
896
+ enableFormat: options?.enableFormat ?? false,
897
+ enableDiagnostics: options?.enableDiagnostics ?? false,
898
+ };
899
+ if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) {
900
+ return writethroughNoop;
901
+ }
902
+ return async (
903
+ dst: string,
904
+ content: string,
905
+ signal?: AbortSignal,
906
+ file?: BunFile,
907
+ batch?: LspWritethroughBatchRequest,
908
+ ) => {
909
+ if (!batch) {
910
+ return runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file);
750
911
  }
751
912
 
752
- if (formatter !== undefined) {
753
- diagnostics ??= {
754
- server: servers.map(([name]) => name).join(", "),
755
- messages: [],
756
- summary: "OK",
757
- errored: false,
758
- };
759
- diagnostics.formatter = formatter;
913
+ const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
914
+ state.entries.set(dst, { dst, content, file });
915
+
916
+ if (!batch.flush) {
917
+ await writethroughNoop(dst, content, signal, file);
918
+ return undefined;
760
919
  }
761
920
 
762
- return diagnostics;
921
+ writethroughBatches.delete(batch.id);
922
+ return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
763
923
  };
764
924
  }
765
925
 
@@ -798,10 +958,19 @@ export function createLspTool(session: ToolSession): AgentTool<typeof lspSchema,
798
958
  // Status action doesn't need a file
799
959
  if (action === "status") {
800
960
  const servers = Object.keys(config.servers);
801
- const output =
961
+ const lspmuxState = await detectLspmux();
962
+ const lspmuxStatus = lspmuxState.available
963
+ ? lspmuxState.running
964
+ ? "lspmux: active (multiplexing enabled)"
965
+ : "lspmux: installed but server not running"
966
+ : "";
967
+
968
+ const serverStatus =
802
969
  servers.length > 0
803
970
  ? `Active language servers: ${servers.join(", ")}`
804
971
  : "No language servers configured for this project";
972
+
973
+ const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
805
974
  return {
806
975
  content: [{ type: "text", text: output }],
807
976
  details: { action, success: true },
@@ -0,0 +1,249 @@
1
+ import { homedir, platform } from "node:os";
2
+ import { join } from "node:path";
3
+ import { TOML } from "bun";
4
+ import { logger } from "../../logger";
5
+
6
+ /**
7
+ * lspmux integration for LSP server multiplexing.
8
+ *
9
+ * When lspmux is available and running, this module wraps supported LSP server
10
+ * commands to use lspmux client mode, enabling server instance sharing across
11
+ * multiple editor windows.
12
+ *
13
+ * Integration is transparent: if lspmux is unavailable, falls back to direct spawning.
14
+ */
15
+
16
+ // =============================================================================
17
+ // Types
18
+ // =============================================================================
19
+
20
+ interface LspmuxConfig {
21
+ instance_timeout?: number;
22
+ gc_interval?: number;
23
+ listen?: [string, number] | string;
24
+ connect?: [string, number] | string;
25
+ log_filters?: string;
26
+ pass_environment?: string[];
27
+ }
28
+
29
+ interface LspmuxState {
30
+ available: boolean;
31
+ running: boolean;
32
+ binaryPath: string | null;
33
+ config: LspmuxConfig | null;
34
+ }
35
+
36
+ // =============================================================================
37
+ // Constants
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Servers that benefit from lspmux multiplexing.
42
+ *
43
+ * lspmux can multiplex any LSP server, but it's most beneficial for servers
44
+ * with high startup cost or significant memory usage.
45
+ */
46
+ const DEFAULT_SUPPORTED_SERVERS = new Set([
47
+ "rust-analyzer",
48
+ // Other servers can be added after testing with lspmux
49
+ ]);
50
+
51
+ /** Timeout for liveness check (ms) */
52
+ const LIVENESS_TIMEOUT_MS = 1000;
53
+
54
+ /** Cache duration for lspmux state (5 minutes) */
55
+ const STATE_CACHE_TTL_MS = 5 * 60 * 1000;
56
+
57
+ // =============================================================================
58
+ // Config Path
59
+ // =============================================================================
60
+
61
+ /**
62
+ * Get the lspmux config path based on platform.
63
+ * Matches Rust's `dirs::config_dir()` behavior.
64
+ */
65
+ function getConfigPath(): string {
66
+ const home = homedir();
67
+ switch (platform()) {
68
+ case "win32":
69
+ return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "lspmux", "config.toml");
70
+ case "darwin":
71
+ return join(home, "Library", "Application Support", "lspmux", "config.toml");
72
+ default:
73
+ return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "lspmux", "config.toml");
74
+ }
75
+ }
76
+
77
+ // =============================================================================
78
+ // State Management
79
+ // =============================================================================
80
+
81
+ let cachedState: LspmuxState | null = null;
82
+ let cacheTimestamp = 0;
83
+
84
+ /**
85
+ * Parse lspmux config.toml file.
86
+ */
87
+ async function parseConfig(): Promise<LspmuxConfig | null> {
88
+ try {
89
+ const file = Bun.file(getConfigPath());
90
+ if (!(await file.exists())) {
91
+ return null;
92
+ }
93
+ return TOML.parse(await file.text()) as LspmuxConfig;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Check if lspmux server is running via `lspmux status`.
101
+ */
102
+ async function checkServerRunning(binaryPath: string): Promise<boolean> {
103
+ try {
104
+ const proc = Bun.spawn([binaryPath, "status"], {
105
+ stdout: "pipe",
106
+ stderr: "pipe",
107
+ });
108
+
109
+ const exited = await Promise.race([
110
+ proc.exited,
111
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), LIVENESS_TIMEOUT_MS)),
112
+ ]);
113
+
114
+ if (exited === null) {
115
+ proc.kill();
116
+ return false;
117
+ }
118
+
119
+ return exited === 0;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Detect lspmux availability and state.
127
+ * Results are cached for STATE_CACHE_TTL_MS.
128
+ *
129
+ * Set OMP_DISABLE_LSPMUX=1 to disable.
130
+ */
131
+ export async function detectLspmux(): Promise<LspmuxState> {
132
+ const now = Date.now();
133
+ if (cachedState && now - cacheTimestamp < STATE_CACHE_TTL_MS) {
134
+ return cachedState;
135
+ }
136
+
137
+ if (process.env.OMP_DISABLE_LSPMUX === "1") {
138
+ cachedState = { available: false, running: false, binaryPath: null, config: null };
139
+ cacheTimestamp = now;
140
+ return cachedState;
141
+ }
142
+
143
+ const binaryPath = Bun.which("lspmux");
144
+ if (!binaryPath) {
145
+ cachedState = { available: false, running: false, binaryPath: null, config: null };
146
+ cacheTimestamp = now;
147
+ return cachedState;
148
+ }
149
+
150
+ const [config, running] = await Promise.all([parseConfig(), checkServerRunning(binaryPath)]);
151
+
152
+ cachedState = { available: true, running, binaryPath, config };
153
+ cacheTimestamp = now;
154
+
155
+ if (running) {
156
+ logger.debug("lspmux detected and running", { binaryPath });
157
+ }
158
+
159
+ return cachedState;
160
+ }
161
+
162
+ /**
163
+ * Invalidate the cached lspmux state.
164
+ * Call this if you know the server state has changed.
165
+ */
166
+ export function invalidateLspmuxCache(): void {
167
+ cachedState = null;
168
+ cacheTimestamp = 0;
169
+ }
170
+
171
+ // =============================================================================
172
+ // Command Wrapping
173
+ // =============================================================================
174
+
175
+ /**
176
+ * Check if a server command is supported by lspmux.
177
+ */
178
+ export function isLspmuxSupported(command: string): boolean {
179
+ // Extract base command name (handle full paths)
180
+ const baseName = command.split("/").pop() ?? command;
181
+ return DEFAULT_SUPPORTED_SERVERS.has(baseName);
182
+ }
183
+
184
+ export interface LspmuxWrappedCommand {
185
+ command: string;
186
+ args: string[];
187
+ env?: Record<string, string>;
188
+ }
189
+
190
+ /**
191
+ * Wrap a server command to use lspmux client mode.
192
+ *
193
+ * @param originalCommand - The original LSP server command (e.g., "rust-analyzer")
194
+ * @param originalArgs - Original command arguments
195
+ * @param state - lspmux state from detectLspmux()
196
+ * @returns Wrapped command, args, and env vars; or original if lspmux unavailable
197
+ */
198
+ export function wrapWithLspmux(
199
+ originalCommand: string,
200
+ originalArgs: string[] | undefined,
201
+ state: LspmuxState,
202
+ ): LspmuxWrappedCommand {
203
+ if (!state.available || !state.running || !state.binaryPath) {
204
+ return { command: originalCommand, args: originalArgs ?? [] };
205
+ }
206
+
207
+ if (!isLspmuxSupported(originalCommand)) {
208
+ return { command: originalCommand, args: originalArgs ?? [] };
209
+ }
210
+
211
+ const baseName = originalCommand.split("/").pop() ?? originalCommand;
212
+ const isDefaultRustAnalyzer = baseName === "rust-analyzer" && originalCommand === "rust-analyzer";
213
+ const hasArgs = originalArgs && originalArgs.length > 0;
214
+
215
+ // rust-analyzer from $PATH with no args - lspmux's default, simplest case
216
+ if (isDefaultRustAnalyzer && !hasArgs) {
217
+ return { command: state.binaryPath, args: [] };
218
+ }
219
+
220
+ // Use explicit `client` subcommand with LSPMUX_SERVER env var
221
+ // Use `--` to separate lspmux options from server args
222
+ const args = hasArgs ? ["client", "--", ...originalArgs] : ["client"];
223
+ return {
224
+ command: state.binaryPath,
225
+ args,
226
+ env: { LSPMUX_SERVER: originalCommand },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Get lspmux-wrapped command if available, otherwise return original.
232
+ * This is the main entry point for config.ts integration.
233
+ *
234
+ * @param command - Original LSP server command
235
+ * @param args - Original command arguments
236
+ * @returns Command and args to use (possibly wrapped with lspmux)
237
+ */
238
+ export async function getLspmuxCommand(command: string, args?: string[]): Promise<LspmuxWrappedCommand> {
239
+ const state = await detectLspmux();
240
+ return wrapWithLspmux(command, args, state);
241
+ }
242
+
243
+ /**
244
+ * Check if lspmux is currently active and usable.
245
+ */
246
+ export async function isLspmuxActive(): Promise<boolean> {
247
+ const state = await detectLspmux();
248
+ return state.available && state.running;
249
+ }
@@ -12,6 +12,8 @@ import type { MCPManager } from "../../mcp/manager";
12
12
  import type { ModelRegistry } from "../../model-registry";
13
13
  import { checkPythonKernelAvailability } from "../../python-kernel";
14
14
  import type { ToolSession } from "..";
15
+ import { createLspTool } from "../lsp/index";
16
+ import type { LspParams } from "../lsp/types";
15
17
  import { createPythonTool } from "../python";
16
18
  import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
17
19
  import { resolveModelPattern } from "./model-resolver";
@@ -27,6 +29,7 @@ import {
27
29
  TASK_SUBAGENT_PROGRESS_CHANNEL,
28
30
  } from "./types";
29
31
  import type {
32
+ LspToolCallRequest,
30
33
  MCPToolCallRequest,
31
34
  MCPToolMetadata,
32
35
  PythonToolCallCancel,
@@ -307,6 +310,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
307
310
  pythonProxyEnabled = availability.ok;
308
311
  }
309
312
 
313
+ const lspEnabled = enableLsp ?? true;
314
+ const lspToolRequested = lspEnabled && (toolNames === undefined || toolNames.includes("lsp"));
315
+
310
316
  let worker: Worker;
311
317
  try {
312
318
  worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
@@ -385,6 +391,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
385
391
  const pythonTool = pythonProxyEnabled ? createPythonTool(pythonToolSession) : null;
386
392
  const pythonCallControllers = new Map<string, AbortController>();
387
393
 
394
+ const lspToolSession: ToolSession = {
395
+ cwd,
396
+ hasUI: false,
397
+ enableLsp: lspEnabled,
398
+ getSessionFile: () => pythonSessionFile,
399
+ getSessionSpawns: () => spawnsEnv,
400
+ settings: options.settingsManager as ToolSession["settings"],
401
+ settingsManager: options.settingsManager,
402
+ };
403
+ const lspTool = lspToolRequested ? createLspTool(lspToolSession) : null;
404
+
388
405
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
389
406
  const accumulatedUsage = {
390
407
  input: 0,
@@ -678,12 +695,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
678
695
  outputSchema,
679
696
  sessionFile,
680
697
  spawnsEnv,
681
- enableLsp,
698
+ enableLsp: lspEnabled,
682
699
  serializedAuth: options.authStorage?.serialize(),
683
700
  serializedModels: options.modelRegistry?.serialize(),
684
701
  serializedSettings,
685
702
  mcpTools: options.mcpManager ? extractMCPToolMetadata(options.mcpManager) : undefined,
686
703
  pythonToolProxy: pythonProxyEnabled,
704
+ lspToolProxy: Boolean(lspTool),
687
705
  },
688
706
  };
689
707
 
@@ -792,6 +810,40 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
792
810
  }
793
811
  };
794
812
 
813
+ const handleLspCall = async (request: LspToolCallRequest) => {
814
+ if (!lspTool) {
815
+ worker.postMessage({
816
+ type: "lsp_tool_result",
817
+ callId: request.callId,
818
+ error: "LSP proxy not available",
819
+ });
820
+ return;
821
+ }
822
+ try {
823
+ const result = await withTimeout(
824
+ lspTool.execute(request.callId, request.params as LspParams, signal),
825
+ request.timeoutMs,
826
+ );
827
+ worker.postMessage({
828
+ type: "lsp_tool_result",
829
+ callId: request.callId,
830
+ result: { content: result.content ?? [], details: result.details },
831
+ });
832
+ } catch (error) {
833
+ const message =
834
+ request.timeoutMs !== undefined && error instanceof Error && error.message.includes("timed out")
835
+ ? `LSP tool call timed out after ${request.timeoutMs}ms`
836
+ : error instanceof Error
837
+ ? error.message
838
+ : String(error);
839
+ worker.postMessage({
840
+ type: "lsp_tool_result",
841
+ callId: request.callId,
842
+ error: message,
843
+ });
844
+ }
845
+ };
846
+
795
847
  const onMessage = (event: WorkerMessageEvent<SubagentWorkerResponse>) => {
796
848
  const message = event.data;
797
849
  if (!message || resolved) return;
@@ -807,6 +859,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
807
859
  handlePythonCancel(message as PythonToolCallCancel);
808
860
  return;
809
861
  }
862
+ if (message.type === "lsp_tool_call") {
863
+ handleLspCall(message as LspToolCallRequest);
864
+ return;
865
+ }
810
866
  if (message.type === "event") {
811
867
  try {
812
868
  processEvent(message.event);
@@ -64,6 +64,24 @@ export interface PythonToolCallCancel {
64
64
  reason?: string;
65
65
  }
66
66
 
67
+ export interface LspToolCallRequest {
68
+ type: "lsp_tool_call";
69
+ callId: string;
70
+ params: Record<string, unknown>;
71
+ timeoutMs?: number;
72
+ }
73
+
74
+ export interface LspToolCallResponse {
75
+ type: "lsp_tool_result";
76
+ callId: string;
77
+ result?: {
78
+ content: Array<{ type: string; text?: string; [key: string]: unknown }>;
79
+ details?: unknown;
80
+ isError?: boolean;
81
+ };
82
+ error?: string;
83
+ }
84
+
67
85
  export interface SubagentWorkerStartPayload {
68
86
  cwd: string;
69
87
  task: string;
@@ -80,6 +98,7 @@ export interface SubagentWorkerStartPayload {
80
98
  serializedSettings?: Settings;
81
99
  mcpTools?: MCPToolMetadata[];
82
100
  pythonToolProxy?: boolean;
101
+ lspToolProxy?: boolean;
83
102
  }
84
103
 
85
104
  export type SubagentWorkerRequest =
@@ -87,11 +106,13 @@ export type SubagentWorkerRequest =
87
106
  | { type: "abort" }
88
107
  | MCPToolCallResponse
89
108
  | PythonToolCallResponse
90
- | PythonToolCallCancel;
109
+ | PythonToolCallCancel
110
+ | LspToolCallResponse;
91
111
 
92
112
  export type SubagentWorkerResponse =
93
113
  | { type: "event"; event: AgentEvent }
94
114
  | { type: "done"; exitCode: number; durationMs: number; error?: string; aborted?: boolean }
95
115
  | MCPToolCallRequest
96
116
  | PythonToolCallRequest
97
- | PythonToolCallCancel;
117
+ | PythonToolCallCancel
118
+ | LspToolCallRequest;
@@ -16,18 +16,22 @@
16
16
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
17
  import type { Api, Model } from "@oh-my-pi/pi-ai";
18
18
  import type { TSchema } from "@sinclair/typebox";
19
+ import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
19
20
  import type { AgentSessionEvent } from "../../agent-session";
20
21
  import { AuthStorage } from "../../auth-storage";
21
22
  import type { CustomTool } from "../../custom-tools/types";
22
23
  import { logger } from "../../logger";
23
24
  import { ModelRegistry } from "../../model-registry";
24
25
  import { parseModelPattern, parseModelString } from "../../model-resolver";
26
+ import { renderPromptTemplate } from "../../prompt-templates";
25
27
  import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
26
28
  import { SessionManager } from "../../session-manager";
27
29
  import { SettingsManager } from "../../settings-manager";
28
30
  import { untilAborted } from "../../utils";
31
+ import { type LspToolDetails, lspSchema } from "../lsp/types";
29
32
  import { getPythonToolDescription, type PythonToolDetails, type PythonToolParams, pythonSchema } from "../python";
30
33
  import type {
34
+ LspToolCallResponse,
31
35
  MCPToolCallResponse,
32
36
  MCPToolMetadata,
33
37
  PythonToolCallResponse,
@@ -58,11 +62,19 @@ interface PendingPythonCall {
58
62
  timeoutId?: ReturnType<typeof setTimeout>;
59
63
  }
60
64
 
65
+ interface PendingLspCall {
66
+ resolve: (result: LspToolCallResponse["result"]) => void;
67
+ reject: (error: Error) => void;
68
+ timeoutId?: ReturnType<typeof setTimeout>;
69
+ }
70
+
61
71
  const pendingMCPCalls = new Map<string, PendingMCPCall>();
62
72
  const pendingPythonCalls = new Map<string, PendingPythonCall>();
73
+ const pendingLspCalls = new Map<string, PendingLspCall>();
63
74
  const MCP_CALL_TIMEOUT_MS = 60_000;
64
75
  let mcpCallIdCounter = 0;
65
76
  let pythonCallIdCounter = 0;
77
+ let lspCallIdCounter = 0;
66
78
 
67
79
  function generateMCPCallId(): string {
68
80
  return `mcp_${Date.now()}_${++mcpCallIdCounter}`;
@@ -72,6 +84,10 @@ function generatePythonCallId(): string {
72
84
  return `python_${Date.now()}_${++pythonCallIdCounter}`;
73
85
  }
74
86
 
87
+ function generateLspCallId(): string {
88
+ return `lsp_${Date.now()}_${++lspCallIdCounter}`;
89
+ }
90
+
75
91
  function callMCPToolViaParent(
76
92
  toolName: string,
77
93
  params: Record<string, unknown>,
@@ -193,6 +209,65 @@ function callPythonToolViaParent(
193
209
  });
194
210
  }
195
211
 
212
+ function callLspToolViaParent(
213
+ params: Record<string, unknown>,
214
+ signal?: AbortSignal,
215
+ timeoutMs?: number,
216
+ ): Promise<LspToolCallResponse["result"]> {
217
+ return new Promise((resolve, reject) => {
218
+ const callId = generateLspCallId();
219
+ if (signal?.aborted) {
220
+ reject(new Error("Aborted"));
221
+ return;
222
+ }
223
+
224
+ const timeoutId =
225
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
226
+ ? setTimeout(() => {
227
+ pendingLspCalls.delete(callId);
228
+ reject(new Error(`LSP call timed out after ${timeoutMs}ms`));
229
+ }, timeoutMs)
230
+ : undefined;
231
+
232
+ const cleanup = () => {
233
+ if (timeoutId) {
234
+ clearTimeout(timeoutId);
235
+ }
236
+ pendingLspCalls.delete(callId);
237
+ };
238
+
239
+ if (typeof signal?.addEventListener === "function") {
240
+ signal.addEventListener(
241
+ "abort",
242
+ () => {
243
+ cleanup();
244
+ reject(new Error("Aborted"));
245
+ },
246
+ { once: true },
247
+ );
248
+ }
249
+
250
+ pendingLspCalls.set(callId, {
251
+ resolve: (result) => {
252
+ cleanup();
253
+ resolve(result ?? { content: [] });
254
+ },
255
+ reject: (error) => {
256
+ cleanup();
257
+ reject(error);
258
+ },
259
+ timeoutId,
260
+ });
261
+
262
+ postMessageSafe({
263
+ type: "lsp_tool_call",
264
+ callId,
265
+ params,
266
+ timeoutMs,
267
+ } as SubagentWorkerResponse);
268
+ });
269
+ }
270
+
196
271
  function handleMCPToolResult(response: MCPToolCallResponse): void {
197
272
  const pending = pendingMCPCalls.get(response.callId);
198
273
  if (!pending) return;
@@ -213,12 +288,24 @@ function handlePythonToolResult(response: PythonToolCallResponse): void {
213
288
  }
214
289
  }
215
290
 
291
+ function handleLspToolResult(response: LspToolCallResponse): void {
292
+ const pending = pendingLspCalls.get(response.callId);
293
+ if (!pending) return;
294
+ if (response.error) {
295
+ pending.reject(new Error(response.error));
296
+ } else {
297
+ pending.resolve(response.result);
298
+ }
299
+ }
300
+
216
301
  function rejectPendingCalls(reason: string): void {
217
302
  const error = new Error(reason);
218
303
  const mcpCalls = Array.from(pendingMCPCalls.values());
219
304
  const pythonCalls = Array.from(pendingPythonCalls.values());
305
+ const lspCalls = Array.from(pendingLspCalls.values());
220
306
  pendingMCPCalls.clear();
221
307
  pendingPythonCalls.clear();
308
+ pendingLspCalls.clear();
222
309
  for (const pending of mcpCalls) {
223
310
  clearTimeout(pending.timeoutId);
224
311
  pending.reject(error);
@@ -227,6 +314,10 @@ function rejectPendingCalls(reason: string): void {
227
314
  clearTimeout(pending.timeoutId);
228
315
  pending.reject(error);
229
316
  }
317
+ for (const pending of lspCalls) {
318
+ clearTimeout(pending.timeoutId);
319
+ pending.reject(error);
320
+ }
230
321
  }
231
322
 
232
323
  function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
@@ -296,6 +387,40 @@ function createPythonProxyTool(): CustomTool<typeof pythonSchema> {
296
387
  };
297
388
  }
298
389
 
390
+ function createLspProxyTool(): CustomTool<typeof lspSchema> {
391
+ return {
392
+ name: "lsp",
393
+ label: "LSP",
394
+ description: renderPromptTemplate(lspDescription),
395
+ parameters: lspSchema,
396
+ execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
397
+ try {
398
+ const result = await callLspToolViaParent(params as Record<string, unknown>, signal);
399
+ return {
400
+ content:
401
+ result?.content?.map((c) =>
402
+ c.type === "text"
403
+ ? { type: "text" as const, text: c.text ?? "" }
404
+ : { type: "text" as const, text: JSON.stringify(c) },
405
+ ) ?? [],
406
+ details: result?.details as LspToolDetails | undefined,
407
+ };
408
+ } catch (error) {
409
+ const { action } = params;
410
+ return {
411
+ content: [
412
+ {
413
+ type: "text" as const,
414
+ text: `LSP error: ${error instanceof Error ? error.message : String(error)}`,
415
+ },
416
+ ],
417
+ details: { action, success: false } as LspToolDetails,
418
+ };
419
+ }
420
+ },
421
+ };
422
+ }
423
+
299
424
  interface WorkerMessageEvent<T> {
300
425
  data: T;
301
426
  }
@@ -423,12 +548,17 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
423
548
  checkAbort();
424
549
  }
425
550
 
426
- // Create MCP/python proxy tools if provided
551
+ // Create MCP/python/LSP proxy tools if provided
427
552
  const mcpProxyTools: CustomTool<TSchema>[] = payload.mcpTools?.map(createMCPProxyTool) ?? [];
428
553
  const pythonProxyTools: CustomTool<TSchema>[] = payload.pythonToolProxy
429
554
  ? [createPythonProxyTool() as unknown as CustomTool<TSchema>]
430
555
  : [];
431
- const proxyTools = [...mcpProxyTools, ...pythonProxyTools];
556
+ const lspProxyTools: CustomTool<TSchema>[] = payload.lspToolProxy
557
+ ? [createLspProxyTool() as unknown as CustomTool<TSchema>]
558
+ : [];
559
+ const proxyTools = [...mcpProxyTools, ...pythonProxyTools, ...lspProxyTools];
560
+ const enableLsp = payload.enableLsp ?? true;
561
+ const lspProxyEnabled = payload.lspToolProxy ?? false;
432
562
 
433
563
  // Resolve model override (equivalent to CLI's parseModelPattern with --model)
434
564
  const { model, thinkingLevel: modelThinkingLevel } = resolveModelOverride(payload.model, modelRegistry);
@@ -465,7 +595,7 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
465
595
  hasUI: false,
466
596
  // Pass spawn restrictions to nested tasks
467
597
  spawns: payload.spawnsEnv,
468
- enableLsp: payload.enableLsp ?? true,
598
+ enableLsp: enableLsp && !lspProxyEnabled,
469
599
  // Disable local MCP discovery if using proxy tools
470
600
  enableMCP: !payload.mcpTools,
471
601
  // Add proxy tools
@@ -703,7 +833,7 @@ self.addEventListener("messageerror", () => {
703
833
  reportFatal("Failed to deserialize parent message");
704
834
  });
705
835
 
706
- // Message handler - receives start/abort/mcp_tool_result commands from parent
836
+ // Message handler - receives start/abort/tool_result commands from parent
707
837
  globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorkerRequest>) => {
708
838
  const message = event.data;
709
839
  if (!message) return;
@@ -723,6 +853,11 @@ globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorker
723
853
  return;
724
854
  }
725
855
 
856
+ if (message.type === "lsp_tool_result") {
857
+ handleLspToolResult(message);
858
+ return;
859
+ }
860
+
726
861
  if (message.type === "start") {
727
862
  // Only allow one task per worker
728
863
  if (activeRun) return;
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolContext, ToolCallContext } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { Type } from "@sinclair/typebox";
@@ -22,6 +22,22 @@ export interface WriteToolDetails {
22
22
  diagnostics?: FileDiagnosticsResult;
23
23
  }
24
24
 
25
+ const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
26
+
27
+ function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined {
28
+ if (!toolCall) {
29
+ return undefined;
30
+ }
31
+ const hasOtherWrites = toolCall.toolCalls.some(
32
+ (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
33
+ );
34
+ if (!hasOtherWrites) {
35
+ return undefined;
36
+ }
37
+ const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some((call) => LSP_BATCH_TOOLS.has(call.name));
38
+ return { id: toolCall.batchId, flush: !hasLaterWrites };
39
+ }
40
+
25
41
  export function createWriteTool(session: ToolSession): AgentTool<typeof writeSchema, WriteToolDetails> {
26
42
  const enableLsp = session.enableLsp ?? true;
27
43
  const enableFormat = enableLsp ? (session.settings?.getLspFormatOnWrite() ?? true) : false;
@@ -38,11 +54,14 @@ export function createWriteTool(session: ToolSession): AgentTool<typeof writeSch
38
54
  _toolCallId: string,
39
55
  { path, content }: { path: string; content: string },
40
56
  signal?: AbortSignal,
57
+ _onUpdate?: unknown,
58
+ context?: AgentToolContext,
41
59
  ) => {
42
60
  return untilAborted(signal, async () => {
43
61
  const absolutePath = resolveToCwd(path, session.cwd);
62
+ const batchRequest = getLspBatchRequest(context?.toolCall);
44
63
 
45
- const diagnostics = await writethrough(absolutePath, content, signal);
64
+ const diagnostics = await writethrough(absolutePath, content, signal, undefined, batchRequest);
46
65
 
47
66
  let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
48
67
  if (!diagnostics) {