@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
package/src/lsp/client.ts CHANGED
@@ -946,18 +946,28 @@ export async function shutdownClient(key: string): Promise<void> {
946
946
  // LSP Protocol Methods
947
947
  // =============================================================================
948
948
 
949
- /** Default timeout for LSP requests (30 seconds) */
949
+ /** Default timeout for LSP requests when no abort signal is provided (30 seconds) */
950
950
  const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
951
951
 
952
952
  /**
953
953
  * Send an LSP request and wait for response.
954
+ *
955
+ * Timeout policy:
956
+ * - If `timeoutMs` is explicitly provided, that value is used.
957
+ * - Else, if `signal` is provided, no internal timer is installed (the caller
958
+ * owns the deadline via the signal — typically a wall-clock `AbortSignal.timeout`
959
+ * from the LSP tool). Installing a second hard-coded 30s timer here used to
960
+ * cause "timed out after 30000ms" errors even when the caller had requested
961
+ * `timeout: 60`.
962
+ * - Else (no signal, no explicit timeout), fall back to `DEFAULT_REQUEST_TIMEOUT_MS`
963
+ * to avoid leaking pending requests forever.
954
964
  */
955
965
  export async function sendRequest(
956
966
  client: LspClient,
957
967
  method: string,
958
968
  params: unknown,
959
969
  signal?: AbortSignal,
960
- timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
970
+ timeoutMs?: number,
961
971
  ): Promise<unknown> {
962
972
  // Atomically increment and capture request ID
963
973
  const id = ++client.requestId;
@@ -993,15 +1003,17 @@ export async function sendRequest(
993
1003
  reject(reason);
994
1004
  };
995
1005
 
996
- // Set timeout
997
- timeout = setTimeout(() => {
998
- if (client.pendingRequests.has(id)) {
999
- client.pendingRequests.delete(id);
1000
- const err = new Error(`LSP request ${method} timed out after ${timeoutMs}ms`);
1001
- cleanup();
1002
- reject(err);
1003
- }
1004
- }, timeoutMs);
1006
+ const effectiveTimeoutMs = timeoutMs ?? (signal ? undefined : DEFAULT_REQUEST_TIMEOUT_MS);
1007
+ if (effectiveTimeoutMs !== undefined) {
1008
+ timeout = setTimeout(() => {
1009
+ if (client.pendingRequests.has(id)) {
1010
+ client.pendingRequests.delete(id);
1011
+ const err = new Error(`LSP request ${method} timed out after ${effectiveTimeoutMs}ms`);
1012
+ cleanup();
1013
+ reject(err);
1014
+ }
1015
+ }, effectiveTimeoutMs);
1016
+ }
1005
1017
  if (signal) {
1006
1018
  signal.addEventListener("abort", abortHandler, { once: true });
1007
1019
  if (signal.aborted) {
package/src/lsp/config.ts CHANGED
@@ -450,13 +450,23 @@ export function loadConfig(cwd: string): LspConfig {
450
450
  */
451
451
  export function getServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
452
452
  const ext = path.extname(filePath).toLowerCase();
453
+ const extNoDot = ext.startsWith(".") ? ext.slice(1) : ext;
453
454
  const fileName = path.basename(filePath).toLowerCase();
454
455
  const matches: Array<[string, ServerConfig]> = [];
455
456
 
456
457
  for (const [name, serverConfig] of Object.entries(config.servers)) {
457
458
  const supportsFile = serverConfig.fileTypes.some(fileType => {
459
+ // Accept both `.ts` and `ts` forms in user config / fixtures so a
460
+ // missing dot in `fileTypes` doesn't silently exclude the server
461
+ // from extension-based routing (e.g. rename_file's relevance filter).
458
462
  const normalized = fileType.toLowerCase();
459
- return normalized === ext || normalized === fileName;
463
+ const normalizedNoDot = normalized.startsWith(".") ? normalized.slice(1) : normalized;
464
+ return (
465
+ normalized === ext ||
466
+ normalized === fileName ||
467
+ normalizedNoDot === extNoDot ||
468
+ normalizedNoDot === fileName
469
+ );
460
470
  });
461
471
 
462
472
  if (supportsFile) {
package/src/lsp/index.ts CHANGED
@@ -40,7 +40,6 @@ import {
40
40
  rangesOverlap,
41
41
  } from "./edits";
42
42
  import { detectLspmux } from "./lspmux";
43
- import { renderCall, renderResult } from "./render";
44
43
  import {
45
44
  type CodeAction,
46
45
  type CodeActionContext,
@@ -302,6 +301,22 @@ function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
302
301
  const DIAGNOSTIC_MESSAGE_LIMIT = 50;
303
302
  const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
304
303
  const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
304
+ const DIAGNOSTICS_POLL_MS = 100;
305
+ const DIAGNOSTICS_SETTLE_MS = 250;
306
+ /**
307
+ * How long the edit/write writethrough blocks inline waiting for fresh
308
+ * diagnostics before handing slow servers off to the deferred late-injection
309
+ * channel. Keeps the common fast-server case inline while letting an edit
310
+ * return promptly when a server (e.g. a large-monorepo tsserver) is slow to
311
+ * publish fresh diagnostics.
312
+ */
313
+ const INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 500;
314
+ /**
315
+ * Inner per-server diagnostics wait budget for the background/deferred fetch.
316
+ * Longer than the inline cap (and the old 3s default) so a slow server still
317
+ * delivers late instead of giving up before it ever publishes.
318
+ */
319
+ const DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS = 12_000;
305
320
  const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
306
321
  const WORKSPACE_SYMBOL_LIMIT = 200;
307
322
  const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
@@ -461,27 +476,15 @@ interface WaitForDiagnosticsOptions {
461
476
  signal?: AbortSignal;
462
477
  minVersion?: number;
463
478
  expectedDocumentVersion?: number;
464
- allowUnversioned?: boolean;
465
- }
466
-
467
- function getAcceptedDiagnostics(
468
- publishedDiagnostics: PublishedDiagnostics | undefined,
469
- expectedDocumentVersion?: number,
470
- allowUnversioned = true,
471
- ): Diagnostic[] | undefined {
472
- if (!publishedDiagnostics) {
473
- return undefined;
474
- }
475
- if (expectedDocumentVersion === undefined) {
476
- return publishedDiagnostics.diagnostics;
477
- }
478
- if (publishedDiagnostics.version === expectedDocumentVersion) {
479
- return publishedDiagnostics.diagnostics;
480
- }
481
- if (allowUnversioned && publishedDiagnostics.version == null) {
482
- return publishedDiagnostics.diagnostics;
483
- }
484
- return undefined;
479
+ /**
480
+ * Quiescence window (ms). typescript-language-server never echoes the document
481
+ * version (issue #983) and emits diagnostics from several sources at different
482
+ * times, so there is no single "complete, version-matched" publish to gate on.
483
+ * When the server does not exact-version-match, accept the latest publish only
484
+ * after no newer one has arrived for this long, letting an in-flight pre-edit
485
+ * publish be superseded by the fresh one.
486
+ */
487
+ settleMs?: number;
485
488
  }
486
489
 
487
490
  async function waitForDiagnostics(
@@ -489,26 +492,35 @@ async function waitForDiagnostics(
489
492
  uri: string,
490
493
  options: WaitForDiagnosticsOptions = {},
491
494
  ): Promise<Diagnostic[]> {
492
- const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, allowUnversioned = true } = options;
495
+ const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
493
496
  const start = Date.now();
497
+ let settledRef: PublishedDiagnostics | undefined;
498
+ let settledAt = 0;
494
499
  while (Date.now() - start < timeoutMs) {
495
500
  throwIfAborted(signal);
496
501
  const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
497
- const diagnostics = getAcceptedDiagnostics(
498
- client.diagnostics.get(uri),
499
- expectedDocumentVersion,
500
- allowUnversioned,
501
- );
502
- if (diagnostics !== undefined && versionOk) {
503
- return diagnostics;
502
+ const published = client.diagnostics.get(uri);
503
+ if (published && versionOk) {
504
+ // Server honored our exact document version → authoritative, accept now.
505
+ if (expectedDocumentVersion !== undefined && published.version === expectedDocumentVersion) {
506
+ return published.diagnostics;
507
+ }
508
+ // Unversioned/mismatched publish: wait for the stream to go quiet so an
509
+ // in-flight publish for the pre-edit content is superseded by the fresh one.
510
+ if (published !== settledRef) {
511
+ settledRef = published;
512
+ settledAt = Date.now();
513
+ } else if (Date.now() - settledAt >= settleMs) {
514
+ return published.diagnostics;
515
+ }
504
516
  }
505
- await Bun.sleep(100);
517
+ await Bun.sleep(DIAGNOSTICS_POLL_MS);
506
518
  }
507
519
  const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
508
520
  if (!versionOk) {
509
521
  return [];
510
522
  }
511
- return getAcceptedDiagnostics(client.diagnostics.get(uri), expectedDocumentVersion, allowUnversioned) ?? [];
523
+ return client.diagnostics.get(uri)?.diagnostics ?? [];
512
524
  }
513
525
 
514
526
  /** Project type detection result */
@@ -613,7 +625,8 @@ interface GetDiagnosticsForFileOptions {
613
625
  signal?: AbortSignal;
614
626
  minVersions?: ServerVersionMap;
615
627
  expectedDocumentVersions?: ServerVersionMap;
616
- allowUnversionedLspDiagnostics?: boolean;
628
+ /** Per-server wait budget (ms). Defaults to {@link SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS}. */
629
+ timeoutMs?: number;
617
630
  }
618
631
 
619
632
  /**
@@ -669,7 +682,7 @@ async function getDiagnosticsForFile(
669
682
  servers: Array<[string, ServerConfig]>,
670
683
  options: GetDiagnosticsForFileOptions = {},
671
684
  ): Promise<FileDiagnosticsResult | undefined> {
672
- const { signal, minVersions, expectedDocumentVersions, allowUnversionedLspDiagnostics = true } = options;
685
+ const { signal, minVersions, expectedDocumentVersions, timeoutMs } = options;
673
686
  if (servers.length === 0) {
674
687
  return undefined;
675
688
  }
@@ -701,11 +714,10 @@ async function getDiagnosticsForFile(
701
714
  const minVersion = minVersions?.get(serverName);
702
715
  const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
703
716
  const diagnostics = await waitForDiagnostics(client, uri, {
704
- timeoutMs: 3000,
717
+ timeoutMs: timeoutMs ?? SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
705
718
  signal,
706
719
  minVersion,
707
720
  expectedDocumentVersion,
708
- allowUnversioned: allowUnversionedLspDiagnostics,
709
721
  });
710
722
  return { serverName, diagnostics };
711
723
  }),
@@ -1007,6 +1019,7 @@ async function scheduleDeferredDiagnosticsFetch(args: {
1007
1019
  signal: combined,
1008
1020
  minVersions: args.minVersions,
1009
1021
  expectedDocumentVersions: args.expectedDocumentVersions,
1022
+ timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
1010
1023
  });
1011
1024
  if (args.signal.aborted || diagnostics === undefined) return;
1012
1025
  args.callback(diagnostics);
@@ -1015,6 +1028,70 @@ async function scheduleDeferredDiagnosticsFetch(args: {
1015
1028
  }
1016
1029
  }
1017
1030
 
1031
+ /**
1032
+ * Fetch post-write diagnostics without making the edit/write block on a slow
1033
+ * language server.
1034
+ *
1035
+ * Blocks inline only briefly ({@link INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS}) for a
1036
+ * fresh result. Freshness is enforced by the pre-edit `minVersions` baseline:
1037
+ * exact document-version matches return immediately, and unversioned/mismatched
1038
+ * publishes must settle with no newer publish before inline acceptance. If
1039
+ * nothing fresh arrives in the inline window and a deferred
1040
+ * channel is available, the in-flight fetch is handed off to deliver late via
1041
+ * `onDeferredDiagnostics`, and this returns `undefined` so the tool result
1042
+ * lands immediately. Without a deferred channel (direct/CI callers) it blocks
1043
+ * for the standard budget so the result is still returned inline.
1044
+ */
1045
+ async function fetchDiagnosticsWithDeferral(args: {
1046
+ dst: string;
1047
+ cwd: string;
1048
+ servers: Array<[string, ServerConfig]>;
1049
+ minVersions: ServerVersionMap | undefined;
1050
+ expectedDocumentVersions: ServerVersionMap | undefined;
1051
+ transformDiagnostics?: ResolvedWritethroughOptions["transformDiagnostics"];
1052
+ deferred?: { onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; signal: AbortSignal };
1053
+ signal?: AbortSignal;
1054
+ }): Promise<FileDiagnosticsResult | undefined> {
1055
+ const { dst, cwd, servers, minVersions, expectedDocumentVersions, transformDiagnostics, deferred, signal } = args;
1056
+ const apply = (d: FileDiagnosticsResult | undefined) =>
1057
+ d && transformDiagnostics ? transformDiagnostics(dst, d) : d;
1058
+
1059
+ if (!deferred) {
1060
+ // No late-injection channel: block for the standard budget and return inline.
1061
+ return apply(
1062
+ await getDiagnosticsForFile(dst, cwd, servers, {
1063
+ signal,
1064
+ minVersions,
1065
+ expectedDocumentVersions,
1066
+ }),
1067
+ );
1068
+ }
1069
+
1070
+ // One background fetch with a generous inner budget; await it only briefly inline.
1071
+ const fetchPromise = getDiagnosticsForFile(dst, cwd, servers, {
1072
+ signal: deferred.signal,
1073
+ minVersions,
1074
+ expectedDocumentVersions,
1075
+ timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
1076
+ });
1077
+ const INLINE_TIMEOUT = Symbol("inline-diagnostics-timeout");
1078
+ const raced = await Promise.race([
1079
+ fetchPromise,
1080
+ Bun.sleep(INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS).then(() => INLINE_TIMEOUT),
1081
+ ]);
1082
+ if (raced !== INLINE_TIMEOUT) {
1083
+ return apply(raced as FileDiagnosticsResult | undefined);
1084
+ }
1085
+ // Slow server: deliver late via the deferred channel; nothing inline. The
1086
+ // deferred sink (edit tool) applies its own dedup, so pass the raw result.
1087
+ void fetchPromise
1088
+ .then(diagnostics => {
1089
+ if (diagnostics && !deferred.signal.aborted) deferred.onDeferredDiagnostics(diagnostics);
1090
+ })
1091
+ .catch(() => {});
1092
+ return undefined;
1093
+ }
1094
+
1018
1095
  async function runLspWritethrough(
1019
1096
  dst: string,
1020
1097
  content: string,
@@ -1047,6 +1124,7 @@ async function runLspWritethrough(
1047
1124
  let formatter: FileFormatResult | undefined;
1048
1125
  let diagnostics: FileDiagnosticsResult | undefined;
1049
1126
  let timedOut = false;
1127
+ let synced = false;
1050
1128
  try {
1051
1129
  const timeoutSignal = AbortSignal.timeout(5_000);
1052
1130
  timeoutSignal.addEventListener(
@@ -1090,19 +1168,8 @@ async function runLspWritethrough(
1090
1168
 
1091
1169
  // 5. Notify saved to LSP servers
1092
1170
  await notifyFileSaved(dst, cwd, lspServers, operationSignal);
1093
-
1094
- // 6. Get diagnostics from all servers (wait for fresh results)
1095
- if (enableDiagnostics) {
1096
- const fetched = await getDiagnosticsForFile(dst, cwd, servers, {
1097
- signal: operationSignal,
1098
- minVersions,
1099
- expectedDocumentVersions,
1100
- allowUnversionedLspDiagnostics: false,
1101
- });
1102
- diagnostics =
1103
- fetched && options.transformDiagnostics ? options.transformDiagnostics(dst, fetched) : fetched;
1104
- }
1105
1171
  });
1172
+ synced = true;
1106
1173
  } catch {
1107
1174
  if (timedOut) {
1108
1175
  formatter = undefined;
@@ -1123,6 +1190,19 @@ async function runLspWritethrough(
1123
1190
  await getWritePromise();
1124
1191
  }
1125
1192
 
1193
+ if (synced && enableDiagnostics) {
1194
+ diagnostics = await fetchDiagnosticsWithDeferral({
1195
+ dst,
1196
+ cwd,
1197
+ servers,
1198
+ minVersions,
1199
+ expectedDocumentVersions,
1200
+ transformDiagnostics: options.transformDiagnostics,
1201
+ deferred,
1202
+ signal,
1203
+ });
1204
+ }
1205
+
1126
1206
  if (formatter !== undefined) {
1127
1207
  diagnostics ??= {
1128
1208
  server: servers.map(([name]) => name).join(", "),
@@ -1229,10 +1309,6 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1229
1309
  readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
1230
1310
  readonly description: string;
1231
1311
  readonly parameters = lspSchema;
1232
- readonly renderCall = renderCall;
1233
- readonly renderResult = renderResult;
1234
- readonly mergeCallAndResult = true;
1235
- readonly inline = true;
1236
1312
  readonly strict = true;
1237
1313
 
1238
1314
  constructor(private readonly session: ToolSession) {
@@ -1261,7 +1337,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1261
1337
 
1262
1338
  // Status action doesn't need a file
1263
1339
  if (action === "status") {
1264
- const servers = Object.keys(config.servers);
1340
+ const configuredNames = Object.keys(config.servers);
1265
1341
  const lspmuxState = await detectLspmux();
1266
1342
  const lspmuxStatus = lspmuxState.available
1267
1343
  ? lspmuxState.running
@@ -1269,14 +1345,40 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1269
1345
  : "lspmux: installed but server not running"
1270
1346
  : "";
1271
1347
 
1272
- const serverStatus =
1273
- servers.length > 0
1274
- ? `Active language servers: ${servers.join(", ")}`
1275
- : "No language servers configured for this project";
1348
+ // `Object.keys(config.servers)` reflects what is *configured & resolvable
1349
+ // on PATH* — it does NOT prove the server actually starts. A wrapper
1350
+ // binary that exits immediately (e.g. rustup without the rust-analyzer
1351
+ // component) still appears here. Distinguish "configured" from
1352
+ // "started" (have a live in-process client) so callers cannot mistake
1353
+ // presence-on-PATH for a working server.
1354
+ const startedClients = getActiveClients();
1355
+ const startedByConfigName = new Map<string, LspServerStatus>();
1356
+ // getActiveClients() reports `name = client.config.command` (the
1357
+ // unresolved binary name from defaults.json), so match against
1358
+ // `serverConfig.command`, not the resolved path.
1359
+ for (const [name, serverConfig] of Object.entries(config.servers)) {
1360
+ const matched = startedClients.find(c => c.name === serverConfig.command);
1361
+ if (matched) startedByConfigName.set(name, matched);
1362
+ }
1363
+
1364
+ const lines: string[] = [];
1365
+ if (configuredNames.length === 0) {
1366
+ lines.push("No language servers configured for this project");
1367
+ } else {
1368
+ const labelled = configuredNames.map(name => {
1369
+ const started = startedByConfigName.get(name);
1370
+ if (!started) return `${name} (configured, not started)`;
1371
+ return `${name} (${started.status})`;
1372
+ });
1373
+ lines.push(`Language servers: ${labelled.join(", ")}`);
1374
+ lines.push(
1375
+ " note: 'configured, not started' means the binary resolves on PATH but no request has spawned it yet; 'ready' means a client process is live for this cwd.",
1376
+ );
1377
+ }
1378
+ if (lspmuxStatus) lines.push(lspmuxStatus);
1276
1379
 
1277
- const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
1278
1380
  return {
1279
- content: [{ type: "text", text: output }],
1381
+ content: [{ type: "text", text: lines.join("\n") }],
1280
1382
  details: { action, success: true, request: params },
1281
1383
  };
1282
1384
  }
@@ -1505,7 +1607,26 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1505
1607
  }
1506
1608
 
1507
1609
  const lspParams = { files: pairs };
1508
- const servers = getLspServers(config);
1610
+ // Filter to servers whose fileTypes match either the source or any
1611
+ // destination path. Asking every configured server about a .md/.sql/.txt
1612
+ // rename used to stack up willRenameFiles requests against irrelevant
1613
+ // language servers and hit the wall-clock timeout. A server only has
1614
+ // something useful to say about a rename if it understands one of the
1615
+ // affected file extensions.
1616
+ const allLspServers = getLspServers(config);
1617
+ const relevantNames = new Set<string>();
1618
+ const collectRelevant = (filePath: string) => {
1619
+ for (const [name] of getLspServersForFile(config, filePath)) {
1620
+ relevantNames.add(name);
1621
+ }
1622
+ };
1623
+ collectRelevant(source);
1624
+ collectRelevant(dest);
1625
+ for (const pair of pairs) {
1626
+ collectRelevant(uriToFile(pair.oldUri));
1627
+ collectRelevant(uriToFile(pair.newUri));
1628
+ }
1629
+ const servers = allLspServers.filter(([name]) => relevantNames.has(name));
1509
1630
  const respondingServers = new Set<string>();
1510
1631
  const perServerEdits: Array<{ serverName: string; edit: WorkspaceEdit }> = [];
1511
1632
  const serverNotes: string[] = [];
@@ -1829,8 +1950,15 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1829
1950
  throw new ToolAbortError();
1830
1951
  }
1831
1952
  const msg = err instanceof Error ? err.message : String(err);
1953
+ // Echo a (truncated) preview of the params we sent so the caller can
1954
+ // tell parse / shape errors (e.g. nested args dropped, missing field)
1955
+ // apart from genuine server errors without spinning up another debug call.
1956
+ const previewRaw = JSON.stringify(requestParams ?? null);
1957
+ const preview = previewRaw.length > 400 ? `${previewRaw.slice(0, 397)}...` : previewRaw;
1832
1958
  return {
1833
- content: [{ type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}` }],
1959
+ content: [
1960
+ { type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}\n params: ${preview}` },
1961
+ ],
1834
1962
  details: { action, serverName: chosenName, success: false, request: params },
1835
1963
  };
1836
1964
  }