@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
@@ -110,6 +110,8 @@ describe("createTools", () => {
110
110
  getEditFuzzyMatch: () => true,
111
111
  getGitToolEnabled: () => false,
112
112
  getBashInterceptorEnabled: () => true,
113
+ getBashInterceptorSimpleLsEnabled: () => true,
114
+ getBashInterceptorRules: () => [],
113
115
  },
114
116
  });
115
117
  const tools = await createTools(session);
@@ -128,6 +130,8 @@ describe("createTools", () => {
128
130
  getEditFuzzyMatch: () => true,
129
131
  getGitToolEnabled: () => true,
130
132
  getBashInterceptorEnabled: () => true,
133
+ getBashInterceptorSimpleLsEnabled: () => true,
134
+ getBashInterceptorRules: () => [],
131
135
  },
132
136
  });
133
137
  const tools = await createTools(session);
@@ -183,6 +187,6 @@ describe("createTools", () => {
183
187
  });
184
188
 
185
189
  it("HIDDEN_TOOLS contains review tools", () => {
186
- expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "report_finding", "submit_review"]);
190
+ expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "report_finding"]);
187
191
  });
188
192
  });
@@ -17,14 +17,14 @@ export {
17
17
  getLspStatus,
18
18
  type LspServerStatus,
19
19
  type LspToolDetails,
20
+ type LspWarmupOptions,
20
21
  type LspWarmupResult,
21
- lspTool,
22
22
  warmupLspServers,
23
23
  } from "./lsp/index";
24
24
  export { createNotebookTool, type NotebookToolDetails } from "./notebook";
25
25
  export { createOutputTool, type OutputToolDetails } from "./output";
26
26
  export { createReadTool, type ReadToolDetails } from "./read";
27
- export { reportFindingTool, submitReviewTool } from "./review";
27
+ export { reportFindingTool, type SubmitReviewDetails } from "./review";
28
28
  export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook";
29
29
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
30
30
  export type { TruncationResult } from "./truncate";
@@ -53,6 +53,7 @@ export { createWriteTool, type WriteToolDetails } from "./write";
53
53
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
54
54
  import type { Rule } from "../../capability/rule";
55
55
  import type { EventBus } from "../event-bus";
56
+ import type { BashInterceptorRule } from "../settings-manager";
56
57
  import { createAskTool } from "./ask";
57
58
  import { createBashTool } from "./bash";
58
59
  import { createCompleteTool } from "./complete";
@@ -65,7 +66,7 @@ import { createLspTool } from "./lsp/index";
65
66
  import { createNotebookTool } from "./notebook";
66
67
  import { createOutputTool } from "./output";
67
68
  import { createReadTool } from "./read";
68
- import { reportFindingTool, submitReviewTool } from "./review";
69
+ import { reportFindingTool } from "./review";
69
70
  import { createRulebookTool } from "./rulebook";
70
71
  import { createTaskTool } from "./task/index";
71
72
  import { createWebFetchTool } from "./web-fetch";
@@ -93,6 +94,8 @@ export interface ToolSession {
93
94
  getSessionFile: () => string | null;
94
95
  /** Get session spawns */
95
96
  getSessionSpawns: () => string | null;
97
+ /** Get resolved model string if explicitly set for this session */
98
+ getModelString?: () => string | undefined;
96
99
  /** Settings manager (optional) */
97
100
  settings?: {
98
101
  getImageAutoResize(): boolean;
@@ -102,6 +105,8 @@ export interface ToolSession {
102
105
  getEditFuzzyMatch(): boolean;
103
106
  getGitToolEnabled(): boolean;
104
107
  getBashInterceptorEnabled(): boolean;
108
+ getBashInterceptorSimpleLsEnabled(): boolean;
109
+ getBashInterceptorRules(): BashInterceptorRule[];
105
110
  };
106
111
  }
107
112
 
@@ -129,7 +134,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
129
134
  export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
130
135
  complete: createCompleteTool,
131
136
  report_finding: () => reportFindingTool,
132
- submit_review: () => submitReviewTool,
133
137
  };
134
138
 
135
139
  export type ToolName = keyof typeof BUILTIN_TOOLS;
@@ -1,4 +1,3 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
1
  import nodePath from "node:path";
3
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -53,23 +52,25 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
53
52
  const dirPath = resolveToCwd(path || ".", session.cwd);
54
53
  const effectiveLimit = limit ?? DEFAULT_LIMIT;
55
54
 
56
- // Check if path exists
57
- if (!existsSync(dirPath)) {
55
+ // Check if path exists and is a directory
56
+ let dirStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
57
+ try {
58
+ dirStat = await Bun.file(dirPath).stat();
59
+ } catch {
58
60
  throw new Error(`Path not found: ${dirPath}`);
59
61
  }
60
62
 
61
- // Check if path is a directory
62
- const stat = statSync(dirPath);
63
- if (!stat.isDirectory()) {
63
+ if (!dirStat.isDirectory()) {
64
64
  throw new Error(`Not a directory: ${dirPath}`);
65
65
  }
66
66
 
67
67
  // Read directory entries
68
68
  let entries: string[];
69
69
  try {
70
- entries = readdirSync(dirPath);
71
- } catch (e: any) {
72
- throw new Error(`Cannot read directory: ${e.message}`);
70
+ entries = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: dirPath, dot: true, onlyFiles: false }));
71
+ } catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ throw new Error(`Cannot read directory: ${message}`);
73
74
  }
74
75
 
75
76
  // Sort alphabetically (case-insensitive)
@@ -82,6 +83,7 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
82
83
  let fileCount = 0;
83
84
 
84
85
  for (const entry of entries) {
86
+ signal?.throwIfAborted();
85
87
  if (results.length >= effectiveLimit) {
86
88
  entryLimitReached = true;
87
89
  break;
@@ -92,7 +94,7 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
92
94
  let age = "";
93
95
 
94
96
  try {
95
- const entryStat = statSync(fullPath);
97
+ const entryStat = await Bun.file(fullPath).stat();
96
98
  if (entryStat.isDirectory()) {
97
99
  suffix = "/";
98
100
  dirCount += 1;
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import { logger } from "../../logger";
2
3
  import { applyWorkspaceEdit } from "./edits";
3
4
  import type {
4
5
  Diagnostic,
@@ -266,6 +267,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
266
267
  if (message.method === "textDocument/publishDiagnostics" && message.params) {
267
268
  const params = message.params as { uri: string; diagnostics: Diagnostic[] };
268
269
  client.diagnostics.set(params.uri, params.diagnostics);
270
+ client.diagnosticsVersion += 1;
269
271
  }
270
272
  }
271
273
 
@@ -363,7 +365,7 @@ async function sendResponse(
363
365
  try {
364
366
  await writeMessage(client.process.stdin as import("bun").FileSink, response);
365
367
  } catch (err) {
366
- console.error(`[LSP] Failed to respond to ${method}: ${err}`);
368
+ logger.error("LSP failed to respond.", { method, error: String(err) });
367
369
  }
368
370
  }
369
371
 
@@ -371,10 +373,16 @@ async function sendResponse(
371
373
  // Client Management
372
374
  // =============================================================================
373
375
 
376
+ /** Timeout for warmup initialize requests (5 seconds) */
377
+ export const WARMUP_TIMEOUT_MS = 5000;
378
+
374
379
  /**
375
380
  * Get or create an LSP client for the given server configuration and working directory.
381
+ * @param config - Server configuration
382
+ * @param cwd - Working directory
383
+ * @param initTimeoutMs - Optional timeout for the initialize request (defaults to 30s)
376
384
  */
377
- export async function getOrCreateClient(config: ServerConfig, cwd: string): Promise<LspClient> {
385
+ export async function getOrCreateClient(config: ServerConfig, cwd: string, initTimeoutMs?: number): Promise<LspClient> {
378
386
  const key = `${config.command}:${cwd}`;
379
387
 
380
388
  // Check if client already exists
@@ -408,6 +416,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom
408
416
  config,
409
417
  requestId: 0,
410
418
  diagnostics: new Map(),
419
+ diagnosticsVersion: 0,
411
420
  openFiles: new Map(),
412
421
  pendingRequests: new Map(),
413
422
  messageBuffer: new Uint8Array(0),
@@ -427,14 +436,20 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom
427
436
 
428
437
  try {
429
438
  // Send initialize request
430
- const initResult = (await sendRequest(client, "initialize", {
431
- processId: process.pid,
432
- rootUri: fileToUri(cwd),
433
- rootPath: cwd,
434
- capabilities: CLIENT_CAPABILITIES,
435
- initializationOptions: config.initOptions ?? {},
436
- workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
437
- })) as { capabilities?: unknown };
439
+ const initResult = (await sendRequest(
440
+ client,
441
+ "initialize",
442
+ {
443
+ processId: process.pid,
444
+ rootUri: fileToUri(cwd),
445
+ rootPath: cwd,
446
+ capabilities: CLIENT_CAPABILITIES,
447
+ initializationOptions: config.initOptions ?? {},
448
+ workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
449
+ },
450
+ undefined, // signal
451
+ initTimeoutMs,
452
+ )) as { capabilities?: unknown };
438
453
 
439
454
  if (!initResult) {
440
455
  throw new Error("Failed to initialize LSP: no response");
@@ -516,9 +531,15 @@ export async function ensureFileOpen(client: LspClient, filePath: string): Promi
516
531
  * Sync in-memory content to the LSP client without reading from disk.
517
532
  * Use this to provide instant feedback during edits before the file is saved.
518
533
  */
519
- export async function syncContent(client: LspClient, filePath: string, content: string): Promise<void> {
534
+ export async function syncContent(
535
+ client: LspClient,
536
+ filePath: string,
537
+ content: string,
538
+ signal?: AbortSignal,
539
+ ): Promise<void> {
520
540
  const uri = fileToUri(filePath);
521
541
  const lockKey = `${client.name}:${uri}`;
542
+ signal?.throwIfAborted();
522
543
 
523
544
  const existingLock = fileOperationLocks.get(lockKey);
524
545
  if (existingLock) {
@@ -534,6 +555,7 @@ export async function syncContent(client: LspClient, filePath: string, content:
534
555
  if (!info) {
535
556
  // Open file with provided content instead of reading from disk
536
557
  const languageId = detectLanguageId(filePath);
558
+ signal?.throwIfAborted();
537
559
  await sendNotification(client, "textDocument/didOpen", {
538
560
  textDocument: {
539
561
  uri,
@@ -548,6 +570,7 @@ export async function syncContent(client: LspClient, filePath: string, content:
548
570
  }
549
571
 
550
572
  const version = ++info.version;
573
+ signal?.throwIfAborted();
551
574
  await sendNotification(client, "textDocument/didChange", {
552
575
  textDocument: { uri, version },
553
576
  contentChanges: [{ text: content }],
@@ -567,11 +590,12 @@ export async function syncContent(client: LspClient, filePath: string, content:
567
590
  * Notify LSP that a file was saved.
568
591
  * Assumes content was already synced via syncContent - just sends didSave.
569
592
  */
570
- export async function notifySaved(client: LspClient, filePath: string): Promise<void> {
593
+ export async function notifySaved(client: LspClient, filePath: string, signal?: AbortSignal): Promise<void> {
571
594
  const uri = fileToUri(filePath);
572
595
  const info = client.openFiles.get(uri);
573
596
  if (!info) return; // File not open, nothing to notify
574
597
 
598
+ signal?.throwIfAborted();
575
599
  await sendNotification(client, "textDocument/didSave", {
576
600
  textDocument: { uri },
577
601
  });
@@ -650,12 +674,25 @@ export function shutdownClient(key: string): void {
650
674
  // LSP Protocol Methods
651
675
  // =============================================================================
652
676
 
677
+ /** Default timeout for LSP requests (30 seconds) */
678
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
679
+
653
680
  /**
654
681
  * Send an LSP request and wait for response.
655
682
  */
656
- export async function sendRequest(client: LspClient, method: string, params: unknown): Promise<unknown> {
683
+ export async function sendRequest(
684
+ client: LspClient,
685
+ method: string,
686
+ params: unknown,
687
+ signal?: AbortSignal,
688
+ timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
689
+ ): Promise<unknown> {
657
690
  // Atomically increment and capture request ID
658
691
  const id = ++client.requestId;
692
+ if (signal?.aborted) {
693
+ const reason = signal.reason instanceof Error ? signal.reason : new Error("Operation aborted");
694
+ return Promise.reject(reason);
695
+ }
659
696
 
660
697
  const request: LspJsonRpcRequest = {
661
698
  jsonrpc: "2.0",
@@ -667,22 +704,49 @@ export async function sendRequest(client: LspClient, method: string, params: unk
667
704
  client.lastActivity = Date.now();
668
705
 
669
706
  return new Promise((resolve, reject) => {
707
+ let timeout: ReturnType<typeof setTimeout> | undefined;
708
+ const cleanup = () => {
709
+ if (signal) {
710
+ signal.removeEventListener("abort", abortHandler);
711
+ }
712
+ };
713
+ const abortHandler = () => {
714
+ if (client.pendingRequests.has(id)) {
715
+ client.pendingRequests.delete(id);
716
+ }
717
+ if (timeout) clearTimeout(timeout);
718
+ cleanup();
719
+ const reason = signal?.reason instanceof Error ? signal.reason : new Error("Operation aborted");
720
+ reject(reason);
721
+ };
722
+
670
723
  // Set timeout
671
- const timeout = setTimeout(() => {
724
+ timeout = setTimeout(() => {
672
725
  if (client.pendingRequests.has(id)) {
673
726
  client.pendingRequests.delete(id);
674
- reject(new Error(`LSP request ${method} timed out`));
727
+ const err = new Error(`LSP request ${method} timed out`);
728
+ cleanup();
729
+ reject(err);
675
730
  }
676
- }, 30000);
731
+ }, timeoutMs);
732
+ if (signal) {
733
+ signal.addEventListener("abort", abortHandler, { once: true });
734
+ if (signal.aborted) {
735
+ abortHandler();
736
+ return;
737
+ }
738
+ }
677
739
 
678
740
  // Register pending request with timeout wrapper
679
741
  client.pendingRequests.set(id, {
680
742
  resolve: (result) => {
681
- clearTimeout(timeout);
743
+ if (timeout) clearTimeout(timeout);
744
+ cleanup();
682
745
  resolve(result);
683
746
  },
684
747
  reject: (err) => {
685
- clearTimeout(timeout);
748
+ if (timeout) clearTimeout(timeout);
749
+ cleanup();
686
750
  reject(err);
687
751
  },
688
752
  method,
@@ -690,8 +754,9 @@ export async function sendRequest(client: LspClient, method: string, params: unk
690
754
 
691
755
  // Write request
692
756
  writeMessage(client.process.stdin as import("bun").FileSink, request).catch((err) => {
693
- clearTimeout(timeout);
757
+ if (timeout) clearTimeout(timeout);
694
758
  client.pendingRequests.delete(id);
759
+ cleanup();
695
760
  reject(err);
696
761
  });
697
762
  });