@oh-my-pi/pi-coding-agent 3.30.0 → 3.32.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +367 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/sdk.ts +10 -2
  9. package/src/core/session-manager.ts +158 -246
  10. package/src/core/session-storage.ts +379 -0
  11. package/src/core/settings-manager.ts +155 -4
  12. package/src/core/slash-commands.ts +39 -13
  13. package/src/core/system-prompt.ts +62 -64
  14. package/src/core/tools/ask.ts +5 -4
  15. package/src/core/tools/bash-interceptor.ts +26 -61
  16. package/src/core/tools/bash.ts +13 -8
  17. package/src/core/tools/edit-diff.ts +11 -4
  18. package/src/core/tools/edit.ts +7 -13
  19. package/src/core/tools/find.ts +111 -50
  20. package/src/core/tools/gemini-image.ts +128 -147
  21. package/src/core/tools/grep.ts +397 -415
  22. package/src/core/tools/index.test.ts +5 -1
  23. package/src/core/tools/index.ts +8 -4
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +84 -19
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +72 -35
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +150 -74
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/commands.ts +4 -0
  37. package/src/core/tools/task/executor.ts +94 -83
  38. package/src/core/tools/task/index.ts +130 -92
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +15 -6
  42. package/src/core/tools/task/worker.ts +124 -89
  43. package/src/core/tools/web-fetch.ts +112 -377
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  49. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  50. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  51. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  52. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  53. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  54. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  57. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  59. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  60. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  61. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  62. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  63. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  64. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  71. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  72. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  73. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  74. package/src/core/tools/web-scrapers/index.ts +250 -0
  75. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  76. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  79. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  82. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  83. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  84. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  86. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  87. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  90. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  93. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  96. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  99. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  102. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  103. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  104. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  105. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  106. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  107. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  111. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  113. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  114. package/src/core/tools/web-scrapers/utils.ts +162 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  116. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  117. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  118. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  119. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  120. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  121. package/src/core/tools/write.ts +21 -18
  122. package/src/core/voice.ts +3 -2
  123. package/src/lib/worktree/collapse.ts +2 -1
  124. package/src/lib/worktree/git.ts +2 -18
  125. package/src/main.ts +59 -3
  126. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  127. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  128. package/src/modes/interactive/components/hook-editor.ts +2 -1
  129. package/src/modes/interactive/components/model-selector.ts +19 -4
  130. package/src/modes/interactive/interactive-mode.ts +41 -63
  131. package/src/modes/interactive/theme/theme.ts +58 -58
  132. package/src/modes/rpc/rpc-mode.ts +10 -9
  133. package/src/prompts/review-request.md +27 -0
  134. package/src/prompts/reviewer.md +64 -68
  135. package/src/prompts/tools/output.md +22 -3
  136. package/src/prompts/tools/task.md +32 -33
  137. package/src/utils/clipboard.ts +2 -1
  138. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  139. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  140. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  156. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  157. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  158. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { Dirent } from "node:fs";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
5
5
  import type { BunFile } from "bun";
@@ -19,6 +19,7 @@ import {
19
19
  sendRequest,
20
20
  setIdleTimeout,
21
21
  syncContent,
22
+ WARMUP_TIMEOUT_MS,
22
23
  } from "./client";
23
24
  import { getLinterClient } from "./clients";
24
25
  import { getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
@@ -72,23 +73,36 @@ export interface LspWarmupResult {
72
73
  }>;
73
74
  }
74
75
 
76
+ /** Options for warming up LSP servers */
77
+ export interface LspWarmupOptions {
78
+ /** Called when starting to connect to servers */
79
+ onConnecting?: (serverNames: string[]) => void;
80
+ }
81
+
75
82
  /**
76
83
  * Warm up LSP servers for a directory by connecting to all detected servers.
77
84
  * This should be called at startup to avoid cold-start delays.
78
85
  *
79
86
  * @param cwd - Working directory to detect and start servers for
87
+ * @param options - Optional callbacks for progress reporting
80
88
  * @returns Status of each server that was started
81
89
  */
82
- export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
90
+ export async function warmupLspServers(cwd: string, options?: LspWarmupOptions): Promise<LspWarmupResult> {
83
91
  const config = await loadConfig(cwd);
84
92
  setIdleTimeout(config.idleTimeoutMs);
85
93
  const servers: LspWarmupResult["servers"] = [];
86
94
  const lspServers = getLspServers(config);
87
95
 
88
- // Start all detected servers in parallel
96
+ // Notify caller which servers we're connecting to
97
+ if (lspServers.length > 0 && options?.onConnecting) {
98
+ options.onConnecting(lspServers.map(([name]) => name));
99
+ }
100
+
101
+ // Start all detected servers in parallel with a short timeout
102
+ // Servers that don't respond quickly will be initialized lazily on first use
89
103
  const results = await Promise.allSettled(
90
104
  lspServers.map(async ([name, serverConfig]) => {
91
- const client = await getOrCreateClient(serverConfig, cwd);
105
+ const client = await getOrCreateClient(serverConfig, cwd, WARMUP_TIMEOUT_MS);
92
106
  return { name, client, fileTypes: serverConfig.fileTypes };
93
107
  }),
94
108
  );
@@ -136,14 +150,18 @@ async function syncFileContent(
136
150
  content: string,
137
151
  cwd: string,
138
152
  servers: Array<[string, ServerConfig]>,
153
+ signal?: AbortSignal,
139
154
  ): Promise<void> {
155
+ signal?.throwIfAborted();
140
156
  await Promise.allSettled(
141
157
  servers.map(async ([_serverName, serverConfig]) => {
158
+ signal?.throwIfAborted();
142
159
  if (serverConfig.createClient) {
143
160
  return;
144
161
  }
145
162
  const client = await getOrCreateClient(serverConfig, cwd);
146
- await syncContent(client, absolutePath, content);
163
+ signal?.throwIfAborted();
164
+ await syncContent(client, absolutePath, content, signal);
147
165
  }),
148
166
  );
149
167
  }
@@ -160,14 +178,17 @@ async function notifyFileSaved(
160
178
  absolutePath: string,
161
179
  cwd: string,
162
180
  servers: Array<[string, ServerConfig]>,
181
+ signal?: AbortSignal,
163
182
  ): Promise<void> {
183
+ signal?.throwIfAborted();
164
184
  await Promise.allSettled(
165
185
  servers.map(async ([_serverName, serverConfig]) => {
186
+ signal?.throwIfAborted();
166
187
  if (serverConfig.createClient) {
167
188
  return;
168
189
  }
169
190
  const client = await getOrCreateClient(serverConfig, cwd);
170
- await notifySaved(client, absolutePath);
191
+ await notifySaved(client, absolutePath, signal);
171
192
  }),
172
193
  );
173
194
  }
@@ -227,13 +248,19 @@ function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: n
227
248
  const normalized = extensions.map((ext) => ext.toLowerCase());
228
249
  const search = (dir: string, depth: number): string | null => {
229
250
  if (depth > maxDepth) return null;
230
- let entries: Dirent[];
251
+ const entries: Dirent[] = [];
231
252
  try {
232
- entries = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false })).map((name) => ({
233
- name,
234
- isFile: () => !existsSync(path.join(dir, name)) || Bun.file(path.join(dir, name)).type !== "directory",
235
- isDirectory: () => existsSync(path.join(dir, name)) && Bun.file(path.join(dir, name)).type === "directory",
236
- })) as Dirent[];
253
+ const names = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false }));
254
+ for (const name of names) {
255
+ const fullPath = path.join(dir, name);
256
+ let isDir = false;
257
+ try {
258
+ isDir = statSync(fullPath).isDirectory();
259
+ } catch {
260
+ continue;
261
+ }
262
+ entries.push({ name, isFile: () => !isDir, isDirectory: () => isDir } as Dirent);
263
+ }
237
264
  } catch {
238
265
  return null;
239
266
  }
@@ -298,9 +325,15 @@ function getServerForWorkspaceAction(config: LspConfig, action: string): [string
298
325
  return null;
299
326
  }
300
327
 
301
- async function waitForDiagnostics(client: LspClient, uri: string, timeoutMs = 3000): Promise<Diagnostic[]> {
328
+ async function waitForDiagnostics(
329
+ client: LspClient,
330
+ uri: string,
331
+ timeoutMs = 3000,
332
+ signal?: AbortSignal,
333
+ ): Promise<Diagnostic[]> {
302
334
  const start = Date.now();
303
335
  while (Date.now() - start < timeoutMs) {
336
+ signal?.throwIfAborted();
304
337
  const diagnostics = client.diagnostics.get(uri);
305
338
  if (diagnostics !== undefined) return diagnostics;
306
339
  await sleep(100);
@@ -440,6 +473,7 @@ async function getDiagnosticsForFile(
440
473
  absolutePath: string,
441
474
  cwd: string,
442
475
  servers: Array<[string, ServerConfig]>,
476
+ signal?: AbortSignal,
443
477
  ): Promise<FileDiagnosticsResult | undefined> {
444
478
  if (servers.length === 0) {
445
479
  return undefined;
@@ -453,6 +487,7 @@ async function getDiagnosticsForFile(
453
487
  // Wait for diagnostics from all servers in parallel
454
488
  const results = await Promise.allSettled(
455
489
  servers.map(async ([serverName, serverConfig]) => {
490
+ signal?.throwIfAborted();
456
491
  // Use custom linter client if configured
457
492
  if (serverConfig.createClient) {
458
493
  const linterClient = getLinterClient(serverName, serverConfig, cwd);
@@ -462,8 +497,9 @@ async function getDiagnosticsForFile(
462
497
 
463
498
  // Default: use LSP
464
499
  const client = await getOrCreateClient(serverConfig, cwd);
500
+ signal?.throwIfAborted();
465
501
  // Content already synced + didSave sent, just wait for diagnostics
466
- const diagnostics = await waitForDiagnostics(client, uri);
502
+ const diagnostics = await waitForDiagnostics(client, uri, 3000, signal);
467
503
  return { serverName, diagnostics };
468
504
  }),
469
505
  );
@@ -539,6 +575,7 @@ async function formatContent(
539
575
  content: string,
540
576
  cwd: string,
541
577
  servers: Array<[string, ServerConfig]>,
578
+ signal?: AbortSignal,
542
579
  ): Promise<string> {
543
580
  if (servers.length === 0) {
544
581
  return content;
@@ -548,6 +585,7 @@ async function formatContent(
548
585
 
549
586
  for (const [serverName, serverConfig] of servers) {
550
587
  try {
588
+ signal?.throwIfAborted();
551
589
  // Use custom linter client if configured
552
590
  if (serverConfig.createClient) {
553
591
  const linterClient = getLinterClient(serverName, serverConfig, cwd);
@@ -556,6 +594,7 @@ async function formatContent(
556
594
 
557
595
  // Default: use LSP
558
596
  const client = await getOrCreateClient(serverConfig, cwd);
597
+ signal?.throwIfAborted();
559
598
 
560
599
  const caps = client.serverCapabilities;
561
600
  if (!caps?.documentFormattingProvider) {
@@ -563,10 +602,15 @@ async function formatContent(
563
602
  }
564
603
 
565
604
  // Request formatting (content already synced)
566
- const edits = (await sendRequest(client, "textDocument/formatting", {
567
- textDocument: { uri },
568
- options: DEFAULT_FORMAT_OPTIONS,
569
- })) as TextEdit[] | null;
605
+ const edits = (await sendRequest(
606
+ client,
607
+ "textDocument/formatting",
608
+ {
609
+ textDocument: { uri },
610
+ options: DEFAULT_FORMAT_OPTIONS,
611
+ },
612
+ signal,
613
+ )) as TextEdit[] | null;
570
614
 
571
615
  if (!edits || edits.length === 0) {
572
616
  return content;
@@ -633,28 +677,29 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
633
677
  let formatter: FileFormatResult | undefined;
634
678
  let diagnostics: FileDiagnosticsResult | undefined;
635
679
  try {
636
- signal ??= AbortSignal.timeout(10_000);
637
- await untilAborted(signal, async () => {
680
+ const timeoutSignal = AbortSignal.timeout(10_000);
681
+ const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
682
+ await untilAborted(operationSignal, async () => {
638
683
  if (useCustomFormatter) {
639
684
  // Custom linters (e.g. Biome CLI) require on-disk input.
640
685
  await writeContent(content);
641
- finalContent = await formatContent(dst, content, cwd, customLinterServers);
686
+ finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
642
687
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
643
688
  await writeContent(finalContent);
644
- await syncFileContent(dst, finalContent, cwd, lspServers);
689
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
645
690
  } else {
646
691
  // 1. Sync original content to LSP servers
647
- await syncFileContent(dst, content, cwd, lspServers);
692
+ await syncFileContent(dst, content, cwd, lspServers, operationSignal);
648
693
 
649
694
  // 2. Format in-memory via LSP
650
695
  if (enableFormat) {
651
- finalContent = await formatContent(dst, content, cwd, lspServers);
696
+ finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
652
697
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
653
698
  }
654
699
 
655
700
  // 3. If formatted, sync formatted content to LSP servers
656
701
  if (finalContent !== content) {
657
- await syncFileContent(dst, finalContent, cwd, lspServers);
702
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
658
703
  }
659
704
 
660
705
  // 4. Write to disk
@@ -662,11 +707,11 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
662
707
  }
663
708
 
664
709
  // 5. Notify saved to LSP servers
665
- await notifyFileSaved(dst, cwd, lspServers);
710
+ await notifyFileSaved(dst, cwd, lspServers, operationSignal);
666
711
 
667
712
  // 6. Get diagnostics from all servers
668
713
  if (enableDiagnostics) {
669
- diagnostics = await getDiagnosticsForFile(dst, cwd, servers);
714
+ diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal);
670
715
  }
671
716
  });
672
717
  } catch {
@@ -1362,11 +1407,3 @@ export function createLspTool(session: ToolSession): AgentTool<typeof lspSchema,
1362
1407
  },
1363
1408
  };
1364
1409
  }
1365
-
1366
- export const lspTool = createLspTool({
1367
- cwd: process.cwd(),
1368
- hasUI: false,
1369
- rulebookRules: [],
1370
- getSessionFile: () => null,
1371
- getSessionSpawns: () => null,
1372
- });
@@ -1,13 +1,6 @@
1
1
  import { sendNotification, sendRequest } from "./client";
2
2
  import type { Diagnostic, ExpandMacroResult, LspClient, RelatedTest, Runnable, WorkspaceEdit } from "./types";
3
- import { fileToUri } from "./utils";
4
-
5
- /**
6
- * Wait for specified milliseconds.
7
- */
8
- async function sleep(ms: number): Promise<void> {
9
- return new Promise((resolve) => setTimeout(resolve, ms));
10
- }
3
+ import { fileToUri, sleep } from "./utils";
11
4
 
12
5
  /**
13
6
  * Run flycheck (cargo check) and collect diagnostics.
@@ -19,10 +12,56 @@ async function sleep(ms: number): Promise<void> {
19
12
  */
20
13
  export async function flycheck(client: LspClient, file?: string): Promise<Diagnostic[]> {
21
14
  const textDocument = file ? { uri: fileToUri(file) } : null;
15
+
16
+ const countDiagnostics = (diagnostics: Map<string, Diagnostic[]>): number => {
17
+ let count = 0;
18
+ for (const diags of diagnostics.values()) {
19
+ count += diags.length;
20
+ }
21
+ return count;
22
+ };
23
+
24
+ // Capture current diagnostic version before triggering flycheck
25
+ const initialDiagnosticsVersion = client.diagnosticsVersion;
26
+ const initialDiagnosticsCount = countDiagnostics(client.diagnostics);
27
+
22
28
  await sendNotification(client, "rust-analyzer/runFlycheck", { textDocument });
23
29
 
24
- // Wait for diagnostics to accumulate (2 seconds as per reference)
25
- await sleep(2000);
30
+ // Bounded polling: wait for diagnostics to stabilize or timeout
31
+ // Poll every 100ms for up to 8 seconds (80 iterations)
32
+ const pollIntervalMs = 100;
33
+ const maxPollIterations = 80;
34
+ const stabilityThreshold = 3; // Consider stable after 3 iterations without change
35
+ const minStableDurationMs = 2000; // Avoid early exit when diagnostics are re-published unchanged.
36
+ const startTime = Date.now();
37
+ let lastDiagnosticsVersion = initialDiagnosticsVersion;
38
+ let lastDiagnosticsCount = initialDiagnosticsCount;
39
+ let stableIterations = 0;
40
+
41
+ for (let i = 0; i < maxPollIterations; i++) {
42
+ await sleep(pollIntervalMs);
43
+
44
+ const currentDiagnosticsVersion = client.diagnosticsVersion;
45
+ const currentDiagnosticsCount = countDiagnostics(client.diagnostics);
46
+
47
+ // Check if diagnostics have stabilized
48
+ if (currentDiagnosticsVersion === lastDiagnosticsVersion && currentDiagnosticsCount === lastDiagnosticsCount) {
49
+ stableIterations++;
50
+ const elapsedMs = Date.now() - startTime;
51
+ const countChangedFromStart = currentDiagnosticsCount !== initialDiagnosticsCount;
52
+ if (
53
+ currentDiagnosticsVersion !== initialDiagnosticsVersion &&
54
+ stableIterations >= stabilityThreshold &&
55
+ (countChangedFromStart || elapsedMs >= minStableDurationMs)
56
+ ) {
57
+ break;
58
+ }
59
+ } else {
60
+ stableIterations = 0;
61
+ lastDiagnosticsVersion = currentDiagnosticsVersion;
62
+ lastDiagnosticsCount = currentDiagnosticsCount;
63
+ }
64
+ }
26
65
 
27
66
  // Collect all diagnostics from client
28
67
  const allDiags: Diagnostic[] = [];
@@ -403,6 +403,7 @@ export interface LspClient {
403
403
  process: Subprocess;
404
404
  requestId: number;
405
405
  diagnostics: Map<string, Diagnostic[]>;
406
+ diagnosticsVersion: number;
406
407
  openFiles: Map<string, OpenFile>;
407
408
  pendingRequests: Map<number, PendingRequest>;
408
409
  messageBuffer: Uint8Array;
@@ -492,7 +492,7 @@ export function extractHoverText(
492
492
  * Sleep for the specified number of milliseconds.
493
493
  */
494
494
  export function sleep(ms: number): Promise<void> {
495
- return new Promise((resolve) => setTimeout(resolve, ms));
495
+ return Bun.sleep(ms);
496
496
  }
497
497
 
498
498
  /**