@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.13

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 (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli.js +2982 -2825
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +12 -6
  10. package/dist/types/config/models-config.d.ts +9 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/lsp/client.d.ts +9 -3
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
  16. package/dist/types/task/spawn-policy.d.ts +17 -0
  17. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +8 -2
  19. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  20. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  21. package/dist/types/tools/browser/registry.d.ts +2 -0
  22. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  24. package/dist/types/tools/eval.d.ts +3 -6
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +1 -1
  29. package/src/auto-thinking/classifier.ts +1 -1
  30. package/src/cli/bench-cli.ts +3 -12
  31. package/src/cli/dry-balance-cli.ts +1 -6
  32. package/src/cli/models-cli.ts +3 -81
  33. package/src/commands/models.ts +1 -2
  34. package/src/commit/model-selection.ts +4 -4
  35. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  36. package/src/config/model-discovery.ts +32 -6
  37. package/src/config/model-registry.ts +11 -225
  38. package/src/config/model-resolver.ts +23 -135
  39. package/src/config/models-config-schema.ts +13 -22
  40. package/src/config/prompt-templates.ts +20 -0
  41. package/src/config/settings-schema.ts +35 -1
  42. package/src/config/settings.ts +60 -24
  43. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  44. package/src/eval/agent-bridge.ts +7 -9
  45. package/src/eval/completion-bridge.ts +1 -1
  46. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  47. package/src/eval/py/runner.py +169 -18
  48. package/src/extensibility/extensions/model-api.ts +1 -3
  49. package/src/extensibility/extensions/runner.ts +62 -5
  50. package/src/lsp/client.ts +162 -45
  51. package/src/lsp/index.ts +31 -21
  52. package/src/main.ts +0 -1
  53. package/src/mcp/transports/http.ts +19 -17
  54. package/src/mcp/transports/stdio.test.ts +51 -1
  55. package/src/mcp/transports/stdio.ts +19 -9
  56. package/src/memories/index.ts +0 -1
  57. package/src/mnemopi/backend.ts +1 -1
  58. package/src/modes/components/model-selector.ts +32 -186
  59. package/src/modes/controllers/event-controller.ts +8 -39
  60. package/src/modes/controllers/selector-controller.ts +0 -1
  61. package/src/modes/controllers/tool-args-reveal.ts +0 -12
  62. package/src/prompts/system/subagent-system-prompt.md +2 -2
  63. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  64. package/src/prompts/tools/eval.md +2 -2
  65. package/src/prompts/tools/ssh.md +1 -0
  66. package/src/prompts/tools/task.md +1 -1
  67. package/src/sdk.ts +2 -9
  68. package/src/session/agent-session.ts +89 -8
  69. package/src/session/session-context.ts +2 -1
  70. package/src/session/session-manager.ts +2 -1
  71. package/src/session/unexpected-stop-classifier.ts +1 -1
  72. package/src/task/index.ts +23 -27
  73. package/src/task/spawn-policy.test.ts +63 -0
  74. package/src/task/spawn-policy.ts +58 -0
  75. package/src/task/types.ts +77 -6
  76. package/src/tools/__tests__/eval-description.test.ts +19 -0
  77. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  78. package/src/tools/browser/registry.ts +25 -0
  79. package/src/tools/browser/run-cancellation.ts +47 -0
  80. package/src/tools/browser/tab-supervisor.ts +120 -7
  81. package/src/tools/browser/tab-worker.ts +22 -10
  82. package/src/tools/browser.ts +1 -0
  83. package/src/tools/eval.ts +15 -10
  84. package/src/tools/inspect-image.ts +1 -1
  85. package/src/tools/ssh.ts +8 -1
  86. package/src/tools/write.ts +49 -9
  87. package/src/utils/commit-message-generator.ts +0 -1
  88. package/src/utils/image-vision-fallback.ts +2 -3
  89. package/src/utils/title-generator.ts +1 -1
package/src/lsp/client.ts CHANGED
@@ -180,22 +180,91 @@ const CLIENT_CAPABILITIES = {
180
180
  // LSP Message Protocol
181
181
  // =============================================================================
182
182
 
183
+ function abortReason(signal: AbortSignal): Error {
184
+ return signal.reason instanceof Error ? signal.reason : new ToolAbortError();
185
+ }
186
+
187
+ class LspFlushAbortError extends Error {
188
+ constructor(readonly reason: Error) {
189
+ super(reason.message);
190
+ this.name = "LspFlushAbortError";
191
+ }
192
+ }
193
+
183
194
  async function writeMessage(
184
195
  sink: Bun.FileSink,
185
196
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
197
+ signal?: AbortSignal,
186
198
  ): Promise<void> {
199
+ if (signal?.aborted) {
200
+ throw abortReason(signal);
201
+ }
187
202
  const content = JSON.stringify(message);
188
203
  sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
189
- await sink.flush();
204
+ const flush = Promise.resolve(sink.flush());
205
+ if (!signal) {
206
+ await flush;
207
+ return;
208
+ }
209
+ // The sink's flush blocks on the OS-level pipe drain: if the server is
210
+ // alive but stopped reading stdin, `await sink.flush()` never resolves.
211
+ // Race the flush against the caller's signal so a wedged server surfaces
212
+ // as the tool's normal timeout/cancel instead of a permanent hang.
213
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
214
+ const onAbort = () => {
215
+ signal.removeEventListener("abort", onAbort);
216
+ // The underlying flush stays pending in the background; suppress its
217
+ // eventual settlement so we do not surface an unhandled rejection.
218
+ flush.catch(() => {});
219
+ reject(new LspFlushAbortError(abortReason(signal)));
220
+ };
221
+ signal.addEventListener("abort", onAbort, { once: true });
222
+ flush.then(
223
+ () => {
224
+ signal.removeEventListener("abort", onAbort);
225
+ resolve();
226
+ },
227
+ (err: unknown) => {
228
+ signal.removeEventListener("abort", onAbort);
229
+ reject(err);
230
+ },
231
+ );
232
+ await promise;
233
+ }
234
+
235
+ /**
236
+ * Kill a client whose write queue is stuck (aborted flush left the sink's
237
+ * flush promise pending, so subsequent writes queue behind a wedge forever).
238
+ * Remove it from `clients` immediately so concurrent `getOrCreateClient`
239
+ * callers do not grab the corpse before `proc.exited` cleans up.
240
+ */
241
+ function teardownWedgedClient(client: LspClient): void {
242
+ if (clients.get(client.name) === client) clients.delete(client.name);
243
+ try {
244
+ client.proc.kill();
245
+ } catch {
246
+ // process already gone or unkillable — the exit handler will finish cleanup.
247
+ }
190
248
  }
191
249
 
192
250
  function queueWriteMessage(
193
251
  client: LspClient,
194
252
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
253
+ signal?: AbortSignal,
195
254
  ): Promise<void> {
196
- const write = client.writeQueue.catch(() => {}).then(() => writeMessage(client.proc.stdin, message));
197
- client.writeQueue = write.catch(() => {});
198
- return write;
255
+ const write = client.writeQueue.catch(() => {}).then(() => writeMessage(client.proc.stdin, message, signal));
256
+ const result = write.catch((err: unknown) => {
257
+ if (err instanceof LspFlushAbortError) {
258
+ // Only an abort that raced this write's in-flight flush leaves
259
+ // the sink pending. Pre-write aborts and queued caller timeouts
260
+ // must not kill a healthy shared client.
261
+ teardownWedgedClient(client);
262
+ throw err.reason;
263
+ }
264
+ throw err;
265
+ });
266
+ client.writeQueue = result.catch(() => {});
267
+ return result;
199
268
  }
200
269
 
201
270
  // =============================================================================
@@ -530,9 +599,18 @@ const EXIT_TIMEOUT_MS = 1_000;
530
599
  * Get or create an LSP client for the given server configuration and working directory.
531
600
  * @param config - Server configuration
532
601
  * @param cwd - Working directory
533
- * @param initTimeoutMs - Optional timeout for the initialize request (defaults to 30s)
602
+ * @param initTimeoutMs - Optional hard deadline for the initialize handshake (warmup / other
603
+ * short-lived callers). When set it takes precedence over `signal` inside `sendRequest`.
604
+ * @param signal - Optional caller abort signal. Threaded into the initialize `sendRequest`
605
+ * and the `initialized` notification so a wedged server surfaces the caller's
606
+ * timeout/cancel instead of falling back to the internal 30s default.
534
607
  */
535
- export async function getOrCreateClient(config: ServerConfig, cwd: string, initTimeoutMs?: number): Promise<LspClient> {
608
+ export async function getOrCreateClient(
609
+ config: ServerConfig,
610
+ cwd: string,
611
+ initTimeoutMs?: number,
612
+ signal?: AbortSignal,
613
+ ): Promise<LspClient> {
536
614
  const key = `${config.command}:${cwd}`;
537
615
 
538
616
  // Check if client already exists
@@ -649,7 +727,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
649
727
  initializationOptions: config.initOptions ?? {},
650
728
  workspaceFolders: currentWorkspaceFolders(client),
651
729
  },
652
- undefined, // signal
730
+ signal,
653
731
  initTimeoutMs,
654
732
  )) as { capabilities?: unknown };
655
733
 
@@ -660,7 +738,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
660
738
  client.serverCapabilities = initResult.capabilities as LspClient["serverCapabilities"];
661
739
 
662
740
  // Send initialized notification
663
- await sendNotification(client, "initialized", {});
741
+ await sendNotification(client, "initialized", {}, signal);
664
742
 
665
743
  client.status = "ready";
666
744
  // Publish only after init succeeds: pre-init clients are reachable
@@ -676,10 +754,10 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
676
754
  proc.kill();
677
755
  const message = err instanceof Error ? err.message : String(err);
678
756
  // Negative-cache deterministic failures. Timeouts under a
679
- // caller-shortened deadline (warmup/writethrough) are not cached —
680
- // the server may simply be slow and a later call with the full
681
- // deadline can still succeed.
682
- if (!(initTimeoutMs !== undefined && message.includes("timed out"))) {
757
+ // caller-shortened deadline (warmup/writethrough) and caller-signal
758
+ // aborts are transient — the server may simply be slow or the user may
759
+ // have cancelled, so a later call with a fresh deadline should retry.
760
+ if (!signal?.aborted && !(initTimeoutMs !== undefined && message.includes("timed out"))) {
683
761
  initFailures.set(key, { at: Date.now(), message });
684
762
  }
685
763
  throw err;
@@ -732,14 +810,19 @@ export async function ensureFileOpen(client: LspClient, filePath: string, signal
732
810
  const languageId = detectLanguageId(filePath);
733
811
  throwIfAborted(signal);
734
812
 
735
- await sendNotification(client, "textDocument/didOpen", {
736
- textDocument: {
737
- uri,
738
- languageId,
739
- version: 1,
740
- text: content,
813
+ await sendNotification(
814
+ client,
815
+ "textDocument/didOpen",
816
+ {
817
+ textDocument: {
818
+ uri,
819
+ languageId,
820
+ version: 1,
821
+ text: content,
822
+ },
741
823
  },
742
- });
824
+ signal,
825
+ );
743
826
 
744
827
  client.openFiles.set(uri, { version: 1, languageId });
745
828
  client.lastActivity = Date.now();
@@ -800,14 +883,19 @@ export async function syncContent(
800
883
  // Open file with provided content instead of reading from disk
801
884
  const languageId = detectLanguageId(filePath);
802
885
  throwIfAborted(signal);
803
- await sendNotification(client, "textDocument/didOpen", {
804
- textDocument: {
805
- uri,
806
- languageId,
807
- version: 1,
808
- text: content,
886
+ await sendNotification(
887
+ client,
888
+ "textDocument/didOpen",
889
+ {
890
+ textDocument: {
891
+ uri,
892
+ languageId,
893
+ version: 1,
894
+ text: content,
895
+ },
809
896
  },
810
- });
897
+ signal,
898
+ );
811
899
  client.openFiles.set(uri, { version: 1, languageId });
812
900
  client.lastActivity = Date.now();
813
901
  return;
@@ -815,10 +903,15 @@ export async function syncContent(
815
903
 
816
904
  const version = ++info.version;
817
905
  throwIfAborted(signal);
818
- await sendNotification(client, "textDocument/didChange", {
819
- textDocument: { uri, version },
820
- contentChanges: [{ text: content }],
821
- });
906
+ await sendNotification(
907
+ client,
908
+ "textDocument/didChange",
909
+ {
910
+ textDocument: { uri, version },
911
+ contentChanges: [{ text: content }],
912
+ },
913
+ signal,
914
+ );
822
915
  client.lastActivity = Date.now();
823
916
  })();
824
917
 
@@ -840,9 +933,14 @@ export async function notifySaved(client: LspClient, filePath: string, signal?:
840
933
  if (!info) return; // File not open, nothing to notify
841
934
 
842
935
  throwIfAborted(signal);
843
- await sendNotification(client, "textDocument/didSave", {
844
- textDocument: { uri },
845
- });
936
+ await sendNotification(
937
+ client,
938
+ "textDocument/didSave",
939
+ {
940
+ textDocument: { uri },
941
+ },
942
+ signal,
943
+ );
846
944
  client.lastActivity = Date.now();
847
945
  }
848
946
 
@@ -884,16 +982,26 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
884
982
  const version = ++info.version;
885
983
  throwIfAborted(signal);
886
984
 
887
- await sendNotification(client, "textDocument/didChange", {
888
- textDocument: { uri, version },
889
- contentChanges: [{ text: content }],
890
- });
985
+ await sendNotification(
986
+ client,
987
+ "textDocument/didChange",
988
+ {
989
+ textDocument: { uri, version },
990
+ contentChanges: [{ text: content }],
991
+ },
992
+ signal,
993
+ );
891
994
  throwIfAborted(signal);
892
995
 
893
- await sendNotification(client, "textDocument/didSave", {
894
- textDocument: { uri },
895
- text: content,
896
- });
996
+ await sendNotification(
997
+ client,
998
+ "textDocument/didSave",
999
+ {
1000
+ textDocument: { uri },
1001
+ text: content,
1002
+ },
1003
+ signal,
1004
+ );
897
1005
 
898
1006
  client.lastActivity = Date.now();
899
1007
  })();
@@ -1044,8 +1152,10 @@ export async function sendRequest(
1044
1152
  method,
1045
1153
  });
1046
1154
 
1047
- // Write request
1048
- queueWriteMessage(client, request).catch(err => {
1155
+ // Write request. `queueWriteMessage(..., signal)` bounds the sink flush
1156
+ // so a wedged server does not stall the write queue past the signal's
1157
+ // deadline; the write-queue teardown kills the client on abort.
1158
+ queueWriteMessage(client, request, signal).catch(err => {
1049
1159
  if (timeout) clearTimeout(timeout);
1050
1160
  client.pendingRequests.delete(id);
1051
1161
  cleanup();
@@ -1056,8 +1166,15 @@ export async function sendRequest(
1056
1166
 
1057
1167
  /**
1058
1168
  * Send an LSP notification (no response expected).
1169
+ * `signal` bounds the underlying `sink.flush()` — without it a server that
1170
+ * stops draining stdin blocks every future write on the client's write queue.
1059
1171
  */
1060
- export async function sendNotification(client: LspClient, method: string, params: unknown): Promise<void> {
1172
+ export async function sendNotification(
1173
+ client: LspClient,
1174
+ method: string,
1175
+ params: unknown,
1176
+ signal?: AbortSignal,
1177
+ ): Promise<void> {
1061
1178
  const notification: LspJsonRpcNotification = {
1062
1179
  jsonrpc: "2.0",
1063
1180
  method,
@@ -1065,7 +1182,7 @@ export async function sendNotification(client: LspClient, method: string, params
1065
1182
  };
1066
1183
 
1067
1184
  client.lastActivity = Date.now();
1068
- await queueWriteMessage(client, notification);
1185
+ await queueWriteMessage(client, notification, signal);
1069
1186
  }
1070
1187
 
1071
1188
  /**
package/src/lsp/index.ts CHANGED
@@ -216,7 +216,7 @@ async function syncFileContent(
216
216
  if (serverConfig.createClient) {
217
217
  return;
218
218
  }
219
- const client = await getOrCreateClient(serverConfig, cwd);
219
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
220
220
  throwIfAborted(signal);
221
221
  await syncContent(client, absolutePath, content, signal);
222
222
  }),
@@ -244,7 +244,7 @@ async function notifyFileSaved(
244
244
  if (serverConfig.createClient) {
245
245
  return;
246
246
  }
247
- const client = await getOrCreateClient(serverConfig, cwd);
247
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
248
248
  await notifySaved(client, absolutePath, signal);
249
249
  }),
250
250
  );
@@ -469,7 +469,7 @@ async function reloadServer(client: LspClient, serverName: string, signal?: Abor
469
469
  // as a request hangs until the tool deadline on servers that route it to
470
470
  // the notification handler and never respond.
471
471
  try {
472
- await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} });
472
+ await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} }, signal);
473
473
  return `Reloaded ${serverName}`;
474
474
  } catch {
475
475
  client.proc.kill();
@@ -643,12 +643,13 @@ async function captureDiagnosticVersions(
643
643
  cwd: string,
644
644
  servers: Array<[string, ServerConfig]>,
645
645
  initTimeoutMs?: number,
646
+ signal?: AbortSignal,
646
647
  ): Promise<ServerVersionMap> {
647
648
  const versions = new Map<string, number>();
648
649
  await Promise.allSettled(
649
650
  servers.map(async ([serverName, serverConfig]) => {
650
651
  if (serverConfig.createClient) return;
651
- const client = await getOrCreateClient(serverConfig, cwd, initTimeoutMs);
652
+ const client = await getOrCreateClient(serverConfig, cwd, initTimeoutMs, signal);
652
653
  versions.set(serverName, client.diagnosticsVersion);
653
654
  }),
654
655
  );
@@ -659,12 +660,13 @@ async function captureOpenFileVersions(
659
660
  absolutePath: string,
660
661
  cwd: string,
661
662
  servers: Array<[string, ServerConfig]>,
663
+ signal?: AbortSignal,
662
664
  ): Promise<ServerVersionMap> {
663
665
  const uri = fileToUri(absolutePath);
664
666
  const versions = new Map<string, number>();
665
667
  await Promise.allSettled(
666
668
  servers.map(async ([serverName, serverConfig]) => {
667
- const client = await getOrCreateClient(serverConfig, cwd);
669
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
668
670
  const version = client.openFiles.get(uri)?.version;
669
671
  if (version !== undefined) {
670
672
  versions.set(serverName, version);
@@ -711,7 +713,7 @@ async function getDiagnosticsForFile(
711
713
  }
712
714
 
713
715
  // Default: use LSP
714
- const client = await getOrCreateClient(serverConfig, cwd);
716
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
715
717
  throwIfAborted(signal);
716
718
  if (isProjectAwareLspServer(serverConfig)) {
717
719
  await waitForProjectLoaded(client, signal);
@@ -812,7 +814,7 @@ async function formatContent(
812
814
  }
813
815
 
814
816
  // Default: use LSP
815
- const client = await getOrCreateClient(serverConfig, cwd);
817
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
816
818
  throwIfAborted(signal);
817
819
 
818
820
  const caps = client.serverCapabilities;
@@ -1118,7 +1120,7 @@ async function runLspWritethrough(
1118
1120
  // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
1119
1121
  // Bound client creation by the writethrough budget: a hung/broken server
1120
1122
  // must not add its full init wait (30s default) to every edit.
1121
- const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers, 5_000) : undefined;
1123
+ const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
1122
1124
  let expectedDocumentVersions: ServerVersionMap | undefined;
1123
1125
 
1124
1126
  let formatter: FileFormatResult | undefined;
@@ -1163,7 +1165,7 @@ async function runLspWritethrough(
1163
1165
  }
1164
1166
 
1165
1167
  if (enableDiagnostics) {
1166
- expectedDocumentVersions = await captureOpenFileVersions(dst, cwd, lspServers);
1168
+ expectedDocumentVersions = await captureOpenFileVersions(dst, cwd, lspServers, operationSignal);
1167
1169
  }
1168
1170
 
1169
1171
  // 5. Notify saved to LSP servers
@@ -1460,7 +1462,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1460
1462
  allDiagnostics.push(...diagnostics);
1461
1463
  continue;
1462
1464
  }
1463
- const client = await getOrCreateClient(serverConfig, this.session.cwd);
1465
+ const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
1464
1466
  if (isProjectAwareLspServer(serverConfig)) {
1465
1467
  await waitForProjectLoaded(client, signal);
1466
1468
  throwIfAborted(signal);
@@ -1634,7 +1636,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1634
1636
  for (const [serverName, serverConfig] of servers) {
1635
1637
  throwIfAborted(signal);
1636
1638
  try {
1637
- const client = await getOrCreateClient(serverConfig, this.session.cwd);
1639
+ const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
1638
1640
  if (isProjectAwareLspServer(serverConfig)) {
1639
1641
  await waitForProjectLoaded(client, signal);
1640
1642
  }
@@ -1781,16 +1783,14 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1781
1783
 
1782
1784
  for (const [serverName, serverConfig] of servers) {
1783
1785
  try {
1784
- const client = await getOrCreateClient(serverConfig, this.session.cwd);
1786
+ const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
1785
1787
  for (const { oldUri } of pairs) {
1786
1788
  if (client.openFiles.has(oldUri)) {
1787
- await sendNotification(client, "textDocument/didClose", {
1788
- textDocument: { uri: oldUri },
1789
- });
1789
+ await sendNotification(client, "textDocument/didClose", { textDocument: { uri: oldUri } }, signal);
1790
1790
  client.openFiles.delete(oldUri);
1791
1791
  }
1792
1792
  }
1793
- await sendNotification(client, "workspace/didRenameFiles", lspParams);
1793
+ await sendNotification(client, "workspace/didRenameFiles", lspParams, signal);
1794
1794
  } catch (err) {
1795
1795
  if (err instanceof ToolAbortError || signal?.aborted) {
1796
1796
  throw err;
@@ -1844,7 +1844,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1844
1844
  for (const [serverName, serverConfig] of serverList) {
1845
1845
  throwIfAborted(signal);
1846
1846
  try {
1847
- const client = await getOrCreateClient(serverConfig, this.session.cwd);
1847
+ const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
1848
1848
  respondingServers.add(serverName);
1849
1849
  const caps = client.serverCapabilities ?? {};
1850
1850
  sections.push(`${serverName}:`);
@@ -1930,7 +1930,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1930
1930
  }
1931
1931
 
1932
1932
  try {
1933
- const client = await getOrCreateClient(chosenConfig, this.session.cwd);
1933
+ const client = await getOrCreateClient(chosenConfig, this.session.cwd, undefined, signal);
1934
1934
  if (resolvedTarget) {
1935
1935
  await ensureFileOpen(client, resolvedTarget, signal);
1936
1936
  }
@@ -2001,7 +2001,12 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
2001
2001
  for (const [workspaceServerName, workspaceServerConfig] of servers) {
2002
2002
  throwIfAborted(signal);
2003
2003
  try {
2004
- const workspaceClient = await getOrCreateClient(workspaceServerConfig, this.session.cwd);
2004
+ const workspaceClient = await getOrCreateClient(
2005
+ workspaceServerConfig,
2006
+ this.session.cwd,
2007
+ undefined,
2008
+ signal,
2009
+ );
2005
2010
  const workspaceResult = (await sendRequest(
2006
2011
  workspaceClient,
2007
2012
  "workspace/symbol",
@@ -2072,7 +2077,12 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
2072
2077
  for (const [workspaceServerName, workspaceServerConfig] of servers) {
2073
2078
  throwIfAborted(signal);
2074
2079
  try {
2075
- const workspaceClient = await getOrCreateClient(workspaceServerConfig, this.session.cwd);
2080
+ const workspaceClient = await getOrCreateClient(
2081
+ workspaceServerConfig,
2082
+ this.session.cwd,
2083
+ undefined,
2084
+ signal,
2085
+ );
2076
2086
  outputs.push(await reloadServer(workspaceClient, workspaceServerName, signal));
2077
2087
  } catch (err) {
2078
2088
  if (err instanceof ToolAbortError || signal?.aborted) {
@@ -2099,7 +2109,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
2099
2109
  const [serverName, serverConfig] = serverInfo;
2100
2110
 
2101
2111
  try {
2102
- const client = await getOrCreateClient(serverConfig, this.session.cwd);
2112
+ const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
2103
2113
  const targetFile = resolvedFile;
2104
2114
  const isRustAnalyzerServer =
2105
2115
  serverName === "rust-analyzer" ||
package/src/main.ts CHANGED
@@ -850,7 +850,6 @@ async function buildSessionOptions(
850
850
  {
851
851
  settings: activeSettings,
852
852
  matchPreferences: modelMatchPreferences,
853
- modelRegistry,
854
853
  },
855
854
  );
856
855
  const rememberedResolvedModel = rememberedSpec.model;
@@ -238,8 +238,6 @@ export class HttpTransport implements MCPTransport {
238
238
  signal: operation.signal,
239
239
  });
240
240
 
241
- operation.clear();
242
-
243
241
  // Check for session ID in response
244
242
  const newSessionId = response.headers.get("Mcp-Session-Id");
245
243
  if (newSessionId) {
@@ -276,11 +274,12 @@ export class HttpTransport implements MCPTransport {
276
274
 
277
275
  return result.result as T;
278
276
  } catch (error) {
279
- operation.clear();
280
277
  if (operation.isTimeoutAbort(error)) {
281
278
  throw new Error(`Request timeout after ${timeout}ms`);
282
279
  }
283
280
  throw error;
281
+ } finally {
282
+ operation.clear();
284
283
  }
285
284
  }
286
285
 
@@ -373,7 +372,7 @@ export class HttpTransport implements MCPTransport {
373
372
  headers["Mcp-Session-Id"] = this.#sessionId;
374
373
  }
375
374
  const timeout = resolveMCPTimeoutMs(this.config.timeout);
376
- let operation = createMCPTimeout(timeout);
375
+ const operation = createMCPTimeout(timeout);
377
376
  try {
378
377
  const resp = await fetch(this.config.url, {
379
378
  method: "POST",
@@ -381,7 +380,6 @@ export class HttpTransport implements MCPTransport {
381
380
  body: JSON.stringify(body),
382
381
  signal: operation.signal,
383
382
  });
384
- operation.clear();
385
383
  // Retry once on auth failure if onAuthError is wired
386
384
  if (this.onAuthError && (resp.status === 401 || resp.status === 403)) {
387
385
  await resp.body?.cancel();
@@ -390,22 +388,27 @@ export class HttpTransport implements MCPTransport {
390
388
  this.config.headers ??= {};
391
389
  Object.assign(this.config.headers, newHeaders);
392
390
  Object.assign(headers, newHeaders);
393
- operation = createMCPTimeout(timeout);
394
- const retry = await fetch(this.config.url, {
395
- method: "POST",
396
- headers,
397
- body: JSON.stringify(body),
398
- signal: operation.signal,
399
- });
400
391
  operation.clear();
401
- await retry.body?.cancel();
392
+ const retryOperation = createMCPTimeout(timeout);
393
+ try {
394
+ const retry = await fetch(this.config.url, {
395
+ method: "POST",
396
+ headers,
397
+ body: JSON.stringify(body),
398
+ signal: retryOperation.signal,
399
+ });
400
+ await retry.body?.cancel();
401
+ } finally {
402
+ retryOperation.clear();
403
+ }
402
404
  return;
403
405
  }
404
406
  }
405
407
  await resp.body?.cancel();
406
408
  } catch {
407
- operation.clear();
408
409
  // Best-effort response delivery — server may have disconnected
410
+ } finally {
411
+ operation.clear();
409
412
  }
410
413
  }
411
414
 
@@ -441,8 +444,6 @@ export class HttpTransport implements MCPTransport {
441
444
  signal: operation.signal,
442
445
  });
443
446
 
444
- operation.clear();
445
-
446
447
  // 202 Accepted is success for notifications
447
448
  if (!response.ok && response.status !== 202) {
448
449
  const text = await response.text();
@@ -465,11 +466,12 @@ export class HttpTransport implements MCPTransport {
465
466
  await response.body?.cancel();
466
467
  }
467
468
  } catch (error) {
468
- operation.clear();
469
469
  if (operation.isTimeoutAbort(error)) {
470
470
  throw new Error(`Notify timeout after ${timeout}ms`);
471
471
  }
472
472
  throw error;
473
+ } finally {
474
+ operation.clear();
473
475
  }
474
476
  }
475
477
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
 
3
- import { resolveStdioSpawnCommand } from "./stdio";
3
+ import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
4
4
 
5
5
  describe("resolveStdioSpawnCommand", () => {
6
6
  it("hides Windows executable MCP servers when the host has no console", async () => {
@@ -43,3 +43,53 @@ describe("resolveStdioSpawnCommand", () => {
43
43
  });
44
44
  });
45
45
  });
46
+
47
+ // Regression for #3945: request() awaited stdin.write/flush, so a child that
48
+ // stops draining stdin would park the async fn past the timeout timer and past
49
+ // `return promise`, orphaning the deferred rejection and hanging the caller
50
+ // forever. `sleep` is POSIX-only, so the check is scoped to non-Windows hosts.
51
+ describe.skipIf(process.platform === "win32")("StdioTransport request write stall", () => {
52
+ it("rejects with the timeout error when the child never drains stdin", async () => {
53
+ const timeoutMs = 400;
54
+ const orphaned: Error[] = [];
55
+ const captureOrphan = (reason: unknown) => {
56
+ if (reason instanceof Error) orphaned.push(reason);
57
+ };
58
+ process.on("unhandledRejection", captureOrphan);
59
+
60
+ // `sleep 60` accepts a stdin pipe but never reads it; a 1 MB payload
61
+ // overruns the OS pipe buffer plus any FileSink JS-side buffering, so
62
+ // Bun's write() returns a Promise that only settles if the child reads.
63
+ const transport = new StdioTransport({
64
+ command: "sleep",
65
+ args: ["60"],
66
+ timeout: timeoutMs,
67
+ });
68
+
69
+ try {
70
+ await transport.connect();
71
+
72
+ const bigParam = "x".repeat(1024 * 1024);
73
+ const started = performance.now();
74
+ const outcome = await transport.request("tools/call", { name: "noop", arguments: { blob: bigParam } }).then(
75
+ () => ({ kind: "resolved" as const }),
76
+ (error: unknown) => ({ kind: "rejected" as const, error }),
77
+ );
78
+ const elapsedMs = performance.now() - started;
79
+
80
+ expect(outcome.kind).toBe("rejected");
81
+ if (outcome.kind !== "rejected") return;
82
+ if (!(outcome.error instanceof Error)) {
83
+ throw new Error(`expected Error rejection, got ${String(outcome.error)}`);
84
+ }
85
+ expect(outcome.error.message).toContain(`Request timeout after ${timeoutMs}ms`);
86
+ // Generous ceiling: bare rejection latency plus room for slow CI. The
87
+ // pre-fix behavior was an unbounded hang, not a slightly-late reject.
88
+ expect(elapsedMs).toBeLessThan(timeoutMs + 1500);
89
+ expect(orphaned).toEqual([]);
90
+ } finally {
91
+ process.off("unhandledRejection", captureOrphan);
92
+ await transport.close();
93
+ }
94
+ }, 8000);
95
+ });