mixdog 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -1
  3. package/scripts/lib/run-node-tests.mjs +9 -0
  4. package/scripts/prune-desktop-runtime.mjs +3 -0
  5. package/scripts/test-direct.mjs +4 -2
  6. package/src/defaults/shipped-history.json +6 -0
  7. package/src/defaults/skills/setup/references/actions.md +5 -2
  8. package/src/defaults/skills/visualize/SKILL.md +157 -0
  9. package/src/defaults/skills/visualize/references/craft.md +88 -0
  10. package/src/headless-command.mjs +56 -3
  11. package/src/headless-exec.mjs +17 -1
  12. package/src/help.mjs +2 -0
  13. package/src/rules/shared/70-delivery.md +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
  18. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
  19. package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/config.mjs +9 -1
  21. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
  22. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
  23. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
  24. package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
  25. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
  29. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
  30. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
  33. package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
  34. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
  37. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
  38. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
  39. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
  40. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
  41. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
  42. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
  43. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
  44. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
  45. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
  53. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
  54. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
  56. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
  57. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
  58. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
  60. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
  62. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
  63. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
  64. package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
  65. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
  66. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
  69. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
  70. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
  71. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
  76. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
  78. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
  79. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
  80. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
  81. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
  82. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
  83. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
  84. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
  85. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  86. package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
  87. package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
  88. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
  89. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
  90. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
  91. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
  92. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
  93. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
  94. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
  95. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
  96. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
  97. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
  99. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
  100. package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
  103. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  111. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
  113. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
  114. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
  115. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
  116. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
  118. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
  119. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
  120. package/src/runtime/attachments/store.mjs +38 -0
  121. package/src/runtime/shared/atomic-file.mjs +8 -2
  122. package/src/runtime/shared/best-effort-stderr.mjs +8 -0
  123. package/src/runtime/shared/code-graph-state.mjs +12 -0
  124. package/src/runtime/shared/defaults-separation.mjs +2 -0
  125. package/src/runtime/shared/git-runner-worker.mjs +3 -2
  126. package/src/runtime/shared/git-runner.mjs +4 -3
  127. package/src/runtime/shared/llm/default-context-window.mjs +6 -0
  128. package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
  129. package/src/runtime/shared/llm/model-catalog.mjs +240 -118
  130. package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
  131. package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
  132. package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
  133. package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
  134. package/src/runtime/shared/model-proxy-mode.mjs +19 -0
  135. package/src/runtime/shared/provider-accounts.mjs +2 -2
  136. package/src/runtime/shared/provider-api-key.mjs +21 -1
  137. package/src/runtime/shared/schedule-model-ref.mjs +11 -2
  138. package/src/runtime/shared/session-usage-totals.mjs +19 -0
  139. package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
  140. package/src/runtime/shared/singleton-owner.mjs +36 -6
  141. package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
  142. package/src/runtime/shared/tool-surface.mjs +7 -0
  143. package/src/runtime/shared/tool-work-units.mjs +14 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +17 -1
  145. package/src/runtime/shared/turn-changes.mjs +791 -0
  146. package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
  147. package/src/runtime/shared/turn-snapshot.mjs +690 -81
  148. package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
  149. package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
  150. package/src/runtime/shared/worktrees/contract.mjs +551 -0
  151. package/src/runtime/shared/worktrees/disk.mjs +57 -0
  152. package/src/runtime/shared/worktrees/git.mjs +247 -0
  153. package/src/runtime/shared/worktrees/include.mjs +480 -0
  154. package/src/runtime/shared/worktrees/lock.mjs +46 -0
  155. package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
  156. package/src/runtime/shared/worktrees/manager.mjs +1215 -0
  157. package/src/runtime/shared/worktrees/merge.mjs +182 -0
  158. package/src/runtime/shared/worktrees/owner.mjs +46 -0
  159. package/src/runtime/shared/worktrees/paths.mjs +62 -0
  160. package/src/runtime/shared/worktrees/registry.mjs +110 -0
  161. package/src/runtime/shared/worktrees/settings.mjs +84 -0
  162. package/src/runtime/shared/worktrees/slug.mjs +62 -0
  163. package/src/runtime/shared/worktrees/test-support.mjs +96 -0
  164. package/src/runtime/visualize-bridge/client.mjs +181 -0
  165. package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
  166. package/src/session-runtime/boot/apis.mjs +35 -0
  167. package/src/session-runtime/boot/config.mjs +2 -0
  168. package/src/session-runtime/boot/facade.mjs +20 -0
  169. package/src/session-runtime/boot/infrastructure.mjs +1 -0
  170. package/src/session-runtime/boot/session.mjs +1 -0
  171. package/src/session-runtime/boot/tools.mjs +4 -0
  172. package/src/session-runtime/config-lifecycle.mjs +10 -1
  173. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
  174. package/src/session-runtime/global-extensions.mjs +2 -2
  175. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
  176. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
  177. package/src/session-runtime/internal-tool-executor.mjs +9 -1
  178. package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
  179. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
  180. package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
  181. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
  182. package/src/session-runtime/mcp-glue/connect.mjs +11 -6
  183. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
  184. package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
  185. package/src/session-runtime/model-route/route-persist.mjs +3 -0
  186. package/src/session-runtime/model-route/set-route.mjs +15 -2
  187. package/src/session-runtime/notification-bus.mjs +9 -1
  188. package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
  189. package/src/session-runtime/resource-extension-sync.mjs +30 -7
  190. package/src/session-runtime/resource-mcp-api.mjs +20 -7
  191. package/src/session-runtime/route-resolution.mjs +10 -1
  192. package/src/session-runtime/route-state.mjs +16 -2
  193. package/src/session-runtime/runtime-core.mjs +5 -0
  194. package/src/session-runtime/runtime-facade.mjs +8 -0
  195. package/src/session-runtime/runtime-review-api.mjs +9 -0
  196. package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
  197. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
  198. package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
  199. package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
  200. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
  201. package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
  202. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
  203. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
  204. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  205. package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
  206. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
  207. package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
  208. package/src/session-runtime/services/agent-tool.mjs +1 -0
  209. package/src/session-runtime/services/daemon-candidate.mjs +3 -2
  210. package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
  211. package/src/session-runtime/session-create/session-options.mjs +10 -1
  212. package/src/session-runtime/session-turn-api.mjs +1 -0
  213. package/src/session-runtime/settings-api.mjs +4 -2
  214. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
  215. package/src/session-runtime/settings-worktree-api.mjs +17 -0
  216. package/src/session-runtime/setup-tool/executor.mjs +11 -4
  217. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
  218. package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
  219. package/src/session-runtime/turn/agent-ops.mjs +12 -1
  220. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
  221. package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
  222. package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
  223. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
  224. package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
  225. package/src/session-runtime/turn/turn-run.mjs +6 -7
  226. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
  227. package/src/session-runtime/workspace/admission-gate.mjs +197 -0
  228. package/src/session-runtime/workspace/project-branches.mjs +136 -0
  229. package/src/session-runtime/workspace/transition-steps.mjs +364 -0
  230. package/src/session-runtime/workspace/transitions.mjs +651 -0
  231. package/src/session-runtime/workspace/workspace-state.mjs +112 -0
  232. package/src/standalone/channel-transport/http-routes.mjs +17 -0
  233. package/src/standalone/channel-transport.mjs +13 -1
  234. package/src/standalone/daemon-desktop-runtime.mjs +1 -0
  235. package/src/standalone/daemon-owner.mjs +372 -0
  236. package/src/standalone/daemon-stored-session-views.mjs +4 -1
  237. package/src/standalone/daemon.mjs +139 -5
  238. package/src/standalone/desktop-service-registry.mjs +20 -0
  239. package/src/standalone/local-session-runtime.mjs +11 -0
  240. package/src/standalone/session-client/attachment-calls.mjs +3 -1
  241. package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
  242. package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
  243. package/src/standalone/session-client.mjs +104 -13
  244. package/src/standalone/session-protocol.mjs +16 -0
  245. package/src/standalone/session-proxy.mjs +3 -0
  246. package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
  247. package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
  248. package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
  249. package/src/standalone/session-service/projection/revisions.mjs +2 -0
  250. package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
  251. package/src/standalone/session-service/projection/workspace.mjs +14 -0
  252. package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
  253. package/src/standalone/session-transport/call-route.mjs +13 -0
  254. package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
  255. package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
  256. package/src/standalone/session-transport/client-registry.mjs +20 -3
  257. package/src/standalone/session-transport/routes.mjs +79 -2
  258. package/src/standalone/session-transport/stack.mjs +2 -0
  259. package/src/standalone/session-transport.mjs +24 -1
  260. package/src/tui/app/message-selector.mjs +2 -1
  261. package/src/tui/app/model-options.mjs +35 -5
  262. package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
  263. package/src/tui/app/model-picker.mjs +4 -2
  264. package/src/tui/dist/index.mjs +332 -36
  265. package/src/tui/index.jsx +10 -2
  266. package/src/tui/session/compaction-view.mjs +147 -0
  267. package/src/tui/session/context-state.mjs +6 -0
  268. package/src/tui/session/initial-state.mjs +3 -0
  269. package/src/tui/session/quota-resume.mjs +2 -1
  270. package/src/tui/session/session-api/commands.mjs +10 -2
  271. package/src/tui/session/session-api/controls.mjs +4 -0
  272. package/src/tui/session/session-api/intake/rewind.mjs +2 -1
  273. package/src/tui/session/session-api/integrations.mjs +17 -0
  274. package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
  275. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
  276. package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
  277. package/src/tui/session/session-api/routes.mjs +10 -1
  278. package/src/tui/session/session-api/settings.mjs +2 -0
  279. package/src/tui/session/session-api/worktree.mjs +60 -0
  280. package/src/tui/session/session-api.mjs +2 -0
  281. package/src/tui/session/session-flow/auto-clear.mjs +31 -12
  282. package/src/tui/session/session-flow/drain.mjs +15 -1
  283. package/src/tui/session/session-flow/session-reset.mjs +4 -3
  284. package/src/tui/session/session-flow.mjs +2 -0
  285. package/src/tui/session/transcript-intake.mjs +14 -5
  286. package/src/tui/session/turn-stream.mjs +32 -4
  287. package/src/tui/session/turn-tool-cards.mjs +10 -2
  288. package/src/tui/session/turn.mjs +39 -10
@@ -22,9 +22,9 @@ import { createKeyedSingleflight } from './reconnect-singleflight.mjs';
22
22
  import { createOwnerFairGate } from '../../../shared/owner-fair-gate.mjs';
23
23
  import { currentToolExecutionOwner } from '../../../shared/tool-execution-owner.mjs';
24
24
  import { positiveInt } from '../../../shared/numbers.mjs';
25
- import { resolveMcpCallTimeoutMs, resolveMcpTransportKind, scrubMcpConnectionMessage } from './client-config.mjs';
26
- import { createMcpTransport } from './client-transport.mjs';
27
- import { runBoundedHandshake } from './client-handshake.mjs';
25
+ import { resolveMcpRequestTimeouts, resolveMcpTransportKind, scrubMcpConnectionMessage } from './client-config.mjs';
26
+ import { appendMcpStderrTail, createMcpTransport } from './client-transport.mjs';
27
+ import { listAllMcpTools, runBoundedHandshake } from './client-handshake.mjs';
28
28
 
29
29
  // Config interpretation (transport kind, URL policy, env expansion, headers,
30
30
  // timeouts, log scrubbing) lives in client-config.mjs; re-exported so every
@@ -37,6 +37,9 @@ export {
37
37
  resolveMcpHttpHeaders,
38
38
  resolveMcpTransportKind,
39
39
  resolveMcpStartupTimeoutMs,
40
+ resolveMcpCallTimeoutMs,
41
+ resolveMcpMaxTotalTimeoutMs,
42
+ resolveMcpRequestTimeouts,
40
43
  } from './client-config.mjs';
41
44
  const servers = new Map();
42
45
  const reconnects = createKeyedSingleflight();
@@ -226,7 +229,15 @@ function mcpFeatureTools(serverName, capabilities, protocolTools) {
226
229
  export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
227
230
  return scopedServerEntries(scopeId).map(([, server]) => ({
228
231
  name: server.name,
229
- connected: true,
232
+ connected: server.liveness?.alive !== false,
233
+ ...(server.liveness?.alive === false
234
+ ? {
235
+ error: appendMcpStderrTail(
236
+ server.liveness.lastError || `MCP server "${server.name}" closed`,
237
+ server.stderrTail?.()
238
+ ),
239
+ }
240
+ : {}),
230
241
  toolCount: Array.isArray(server.tools) ? server.tools.length : 0,
231
242
  tools: (server.tools || []).map((tool) => ({
232
243
  name: tool.name,
@@ -296,28 +307,98 @@ export function getMcpServerInstructionsMap(scopeId = DEFAULT_MCP_SCOPE_ID) {
296
307
  return out;
297
308
  }
298
309
 
310
+ // Close `current` (when registered) and connect `name` afresh with `cfg`.
311
+ // Callers run it inside the per-server reconnect lane. `genAtStart` is the
312
+ // shutdown generation captured BEFORE the caller queued/closed anything: a
313
+ // disconnectAll() landing during the close (or the connect) bumps it, and the
314
+ // replacement must then never register (no resurrection after shutdown).
315
+ async function replaceServerConnection(scopeId, name, current, cfg, genAtStart) {
316
+ const registryKey = mcpServerRegistryKey(scopeId, name);
317
+ if (current) {
318
+ await _closeServer(current);
319
+ if (servers.get(registryKey) === current) servers.delete(registryKey);
320
+ _invalidateMcpToolFieldMemo();
321
+ }
322
+ if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
323
+ throw new Error(`MCP server "${name}" connect aborted by shutdown`);
324
+ }
325
+ try {
326
+ // connectServer re-checks the same generation after loading the SDK and
327
+ // again right before registering, so a shutdown mid-handshake also aborts.
328
+ await connectServer(name, cfg, scopeId, genAtStart);
329
+ } catch (reason) {
330
+ const rawMessage = reason instanceof Error ? reason.message : String(reason);
331
+ throw new Error(scrubMcpConnectionMessage(rawMessage, cfg));
332
+ }
333
+ }
334
+
335
+ // Explicit single-server operations (connect/reconnect/disconnect) never join
336
+ // a failure-driven reconnect that is already in flight — its config may be
337
+ // stale — they queue behind it on the same lane instead, so at most one
338
+ // connect per server is ever running (no doubly registered transports).
339
+ async function runExclusiveServerOp(registryKey, task) {
340
+ for (let pending = reconnects.wait(registryKey); pending; pending = reconnects.wait(registryKey)) {
341
+ await pending.catch(() => {});
342
+ }
343
+ return reconnects.run(registryKey, task);
344
+ }
345
+
299
346
  async function reconnectMcpServer(scopeId, serverName, failedServer) {
300
347
  const registryKey = mcpServerRegistryKey(scopeId, serverName);
301
- return reconnects.run(registryKey, async () => {
348
+ // The generation the failed server was registered under, NOT the current
349
+ // one: when disconnectAll() itself caused the failure it has already bumped
350
+ // the generation (and still holds the entry while awaiting the close), so a
351
+ // freshly captured value would bless a post-shutdown reconnect.
352
+ const genAtStart = failedServer.generation ?? currentConnectAbortGeneration(scopeId);
353
+ await reconnects.run(registryKey, async () => {
302
354
  const current = servers.get(registryKey);
303
355
  // A peer already completed the replacement while this failed call was
304
356
  // unwinding. Reuse it immediately instead of closing a fresh transport.
305
- if (current && current !== failedServer) return current;
306
- if (current) {
307
- await _closeServer(current);
308
- if (servers.get(registryKey) === current) servers.delete(registryKey);
357
+ if (current && current !== failedServer) return;
358
+ // Unregistered meanwhile (explicit disconnect / shutdown), shut down since
359
+ // it was registered, or being deliberately closed right now: a failed call
360
+ // must not resurrect a server someone deliberately removed.
361
+ if (!current || genAtStart !== currentConnectAbortGeneration(scopeId) || current.liveness?.closing) {
362
+ throw new Error(`MCP server "${serverName}" was disconnected`);
309
363
  }
310
- try {
311
- await connectServer(serverName, failedServer.cfg, scopeId);
312
- } catch (reason) {
313
- const rawMessage = reason instanceof Error ? reason.message : String(reason);
314
- throw new Error(scrubMcpConnectionMessage(rawMessage, failedServer.cfg));
315
- }
316
- const replacement = servers.get(registryKey);
317
- if (!replacement) {
318
- throw new Error(`reconnect succeeded but server "${serverName}" entry is missing from registry`);
364
+ await replaceServerConnection(scopeId, serverName, current, failedServer.cfg, genAtStart);
365
+ });
366
+ const replacement = servers.get(registryKey);
367
+ if (!replacement) {
368
+ throw new Error(`reconnect succeeded but server "${serverName}" entry is missing from registry`);
369
+ }
370
+ return replacement;
371
+ }
372
+
373
+ /**
374
+ * Connect, or reconnect, ONE named MCP server in `scopeId` without touching
375
+ * any other server. A registered instance is closed first; `cfg` defaults to
376
+ * the registered config. `cfg.enabled === false` only disconnects. Resolves
377
+ * true when the server is connected afterwards, false when it was disabled;
378
+ * rejects (credential-scrubbed message) when the connect fails, leaving the
379
+ * server unregistered.
380
+ */
381
+ export async function reconnectSingleMcpServer(scopeId, name, cfg = undefined) {
382
+ const normalized = normalizeMcpScopeId(scopeId);
383
+ _knownMcpScopes.add(normalized);
384
+ const registryKey = mcpServerRegistryKey(normalized, name);
385
+ // Captured before queuing: a shutdown while this op waits or closes wins.
386
+ const genAtStart = currentConnectAbortGeneration(normalized);
387
+ return runExclusiveServerOp(registryKey, async () => {
388
+ const current = servers.get(registryKey);
389
+ const nextCfg = cfg ?? current?.cfg;
390
+ if (!nextCfg) throw new Error(`MCP server "${name}" has no config to connect with`);
391
+ if (nextCfg.enabled === false) {
392
+ if (current) {
393
+ await _closeServer(current);
394
+ if (servers.get(registryKey) === current) servers.delete(registryKey);
395
+ _invalidateMcpToolFieldMemo();
396
+ }
397
+ closeCallAdmission(normalized, name, `MCP ${name} disabled`);
398
+ return false;
319
399
  }
320
- return replacement;
400
+ await replaceServerConnection(normalized, name, current, nextCfg, genAtStart);
401
+ return true;
321
402
  });
322
403
  }
323
404
 
@@ -366,6 +447,16 @@ export async function executeMcpTool(name, args, options = {}) {
366
447
  mcpLog(`[mcp-client] Tool call timed out; skipping reconnect retry for "${serverName}/${toolName}".\n`);
367
448
  throw firstErr;
368
449
  }
450
+ const serverDead = server.liveness?.alive === false;
451
+ if (!serverDead && isMcpServerResponseError(firstErr)) {
452
+ // The server answered (JSON-RPC error): it is alive — nothing to
453
+ // reconnect, and the call already ran its course.
454
+ throw firstErr;
455
+ }
456
+ // Only a failure that proves the request never left this process
457
+ // may be replayed; anything else may already have run server-side.
458
+ const undelivered = isMcpUndeliveredError(firstErr);
459
+ const deadTail = serverDead ? server.stderrTail?.() : '';
369
460
  mcpLog(`[mcp-client] Tool call failed, attempting shared reconnect...\n`);
370
461
  let retryServer;
371
462
  try {
@@ -382,7 +473,17 @@ export async function executeMcpTool(name, args, options = {}) {
382
473
  } catch (reconnectErr) {
383
474
  if (isMcpCallAbortError(reconnectErr) || callSignal?.aborted) throw reconnectErr;
384
475
  const reconnectMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
385
- throw new Error(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`);
476
+ throw new Error(
477
+ appendMcpStderrTail(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`, deadTail)
478
+ );
479
+ }
480
+ if (!undelivered) {
481
+ throw new Error(
482
+ appendMcpStderrTail(
483
+ `Tool call failed: ${firstMsg}; MCP server "${serverName}" was reconnected for the next call, but this call was not replayed because the request may already have reached the server`,
484
+ deadTail
485
+ )
486
+ );
386
487
  }
387
488
  // A reconnect that outlived the caller's abort must not dispatch the
388
489
  // retry: that would run the side effect after cancellation.
@@ -410,71 +511,120 @@ export async function executeMcpTool(name, args, options = {}) {
410
511
  );
411
512
  }
412
513
 
413
- async function _callMcpFeatureWithTimeout(server, operation, args, signal = null) {
414
- const requestOptions = signal ? { signal } : undefined;
415
- const cursor = typeof args?.cursor === 'string' && args.cursor ? { cursor: args.cursor } : {};
416
- let request;
417
- if (operation === 'list-resources') {
418
- request = server.client.listResources(cursor, requestOptions);
419
- } else if (operation === 'list-resource-templates') {
420
- request = server.client.listResourceTemplates(cursor, requestOptions);
421
- } else if (operation === 'read-resource') {
422
- request = server.client.readResource({ uri: String(args?.uri || '') }, requestOptions);
423
- } else if (operation === 'list-prompts') {
424
- request = server.client.listPrompts(cursor, requestOptions);
425
- } else if (operation === 'get-prompt') {
426
- request = server.client.getPrompt(
514
+ const MCP_FEATURE_REQUESTS = {
515
+ 'list-resources': (client, args, options) => client.listResources(mcpCursorParams(args), options),
516
+ 'list-resource-templates': (client, args, options) => client.listResourceTemplates(mcpCursorParams(args), options),
517
+ 'read-resource': (client, args, options) => client.readResource({ uri: String(args?.uri || '') }, options),
518
+ 'list-prompts': (client, args, options) => client.listPrompts(mcpCursorParams(args), options),
519
+ 'get-prompt': (client, args, options) =>
520
+ client.getPrompt(
427
521
  {
428
522
  name: String(args?.name || ''),
429
523
  arguments:
430
524
  args?.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments) ? args.arguments : {},
431
525
  },
432
- requestOptions
433
- );
434
- } else {
435
- throw new Error(`Unsupported MCP feature operation: ${operation}`);
526
+ options
527
+ ),
528
+ };
529
+
530
+ function mcpCursorParams(args) {
531
+ return typeof args?.cursor === 'string' && args.cursor ? { cursor: args.cursor } : {};
532
+ }
533
+
534
+ async function _callMcpFeatureWithTimeout(server, operation, args, signal = null) {
535
+ const issue = MCP_FEATURE_REQUESTS[operation];
536
+ if (!issue) throw new Error(`Unsupported MCP feature operation: ${operation}`);
537
+ const result = await runMcpRequest(server, signal, (options) => issue(server.client, args, options), {
538
+ abortMessage: `MCP feature call aborted (server="${server?.name}", operation="${operation}")`,
539
+ timeoutMessage: (ms) =>
540
+ `MCP feature call timed out after ${ms}ms without progress (server="${server.name}", operation="${operation}")`,
541
+ errorFields: { serverName: server.name, operation },
542
+ });
543
+ return {
544
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
545
+ isError: false,
546
+ };
547
+ }
548
+
549
+ // Our per-call deadline. It is reset by every progress notification, but never
550
+ // beyond maxTotalTimeoutMs from the start. On expiry the call rejects with
551
+ // EMCPTOOLTIMEOUT and `cancelRequest` cancels ONLY this request (the SDK then
552
+ // sends notifications/cancelled); the server and its other in-flight calls
553
+ // are untouched. The caller clears the timer once its own race settles.
554
+ function mcpCallTimeout({ callTimeoutMs, maxTotalTimeoutMs }, message, errorFields, cancelRequest) {
555
+ const startedAt = Date.now();
556
+ let timer = null;
557
+ let rejectTimeout;
558
+ const promise = new Promise((_, reject) => {
559
+ rejectTimeout = reject;
560
+ });
561
+ const fire = () => {
562
+ timer = null;
563
+ const error = Object.assign(new Error(message), { code: 'EMCPTOOLTIMEOUT', ...errorFields });
564
+ rejectTimeout(error);
565
+ cancelRequest(error);
566
+ };
567
+ const arm = (ms) => {
568
+ if (timer) clearTimeout(timer);
569
+ timer = setTimeout(fire, ms);
570
+ if (timer.unref) timer.unref();
571
+ };
572
+ arm(callTimeoutMs);
573
+ return {
574
+ promise,
575
+ clear: () => {
576
+ if (timer) clearTimeout(timer);
577
+ timer = null;
578
+ },
579
+ progress: () => {
580
+ if (!timer) return;
581
+ const remainingTotal = maxTotalTimeoutMs ? maxTotalTimeoutMs - (Date.now() - startedAt) : Infinity;
582
+ if (remainingTotal > 0) arm(Math.min(callTimeoutMs, remainingTotal));
583
+ },
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Issue one SDK request under the per-call limits. The request gets its OWN
589
+ * abort signal (linked to the caller's), our timeout as the SDK `timeout`
590
+ * (or the largest timer when disabled, so the SDK's 60s default never
591
+ * applies), and progress handling: each progress report resets both the SDK
592
+ * timeout and ours, capped by `maxTotalTimeout`.
593
+ */
594
+ async function runMcpRequest(server, signal, issue, { abortMessage, timeoutMessage, errorFields }) {
595
+ const timeouts = resolveMcpRequestTimeouts(server?.cfg);
596
+ const controller = new AbortController();
597
+ const forwardAbort = () => controller.abort(signal.reason);
598
+ if (signal) {
599
+ allowConcurrentAbortListeners(signal);
600
+ if (signal.aborted) forwardAbort();
601
+ else signal.addEventListener('abort', forwardAbort, { once: true });
436
602
  }
437
- const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
438
- const abortMessage = `MCP feature call aborted (server="${server?.name}", operation="${operation}")`;
439
603
  const timeout =
440
- timeoutMs > 0
604
+ timeouts.callTimeoutMs > 0
441
605
  ? mcpCallTimeout(
442
- server,
443
- timeoutMs,
444
- `MCP feature call timed out after ${timeoutMs}ms (server="${server.name}", operation="${operation}")`
606
+ timeouts,
607
+ timeoutMessage(timeouts.callTimeoutMs),
608
+ { ...errorFields, timeoutMs: timeouts.callTimeoutMs },
609
+ (error) => controller.abort(error)
445
610
  )
446
611
  : null;
612
+ const requestOptions = {
613
+ signal: controller.signal,
614
+ timeout: timeouts.sdkTimeoutMs,
615
+ resetTimeoutOnProgress: true,
616
+ onprogress: () => timeout?.progress(),
617
+ ...(timeouts.maxTotalTimeoutMs ? { maxTotalTimeout: timeouts.maxTotalTimeoutMs } : {}),
618
+ };
447
619
  try {
448
- const result = await raceMcpAbort(
449
- timeout ? Promise.race([request, timeout.promise]) : request,
450
- signal,
451
- abortMessage
452
- );
453
- return {
454
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
455
- isError: false,
456
- };
620
+ const request = issue(requestOptions);
621
+ return await raceMcpAbort(timeout ? Promise.race([request, timeout.promise]) : request, signal, abortMessage);
457
622
  } finally {
458
623
  timeout?.clear();
624
+ signal?.removeEventListener?.('abort', forwardAbort);
459
625
  }
460
626
  }
461
627
 
462
- // Server-side per-call deadline. On expiry the server goes through the full
463
- // tree-shutdown path so a timed-out stdio server never orphans grandchildren
464
- // (fire-and-forget), and the call rejects with EMCPTOOLTIMEOUT. The caller
465
- // clears the timer once its own (abort-aware) race settles.
466
- function mcpCallTimeout(server, timeoutMs, message, errorFields = {}) {
467
- let timer;
468
- const promise = new Promise((_, reject) => {
469
- timer = setTimeout(() => {
470
- _closeServer(server).catch(() => {});
471
- reject(Object.assign(new Error(message), { code: 'EMCPTOOLTIMEOUT', ...errorFields }));
472
- }, timeoutMs);
473
- if (timer.unref) timer.unref();
474
- });
475
- return { promise, clear: () => clearTimeout(timer) };
476
- }
477
-
478
628
  function mcpResourceFilename(uri) {
479
629
  const tail = String(uri || '')
480
630
  .split(/[?#]/)[0]
@@ -506,14 +656,21 @@ function mcpCanonicalBase64(data, maxBytes) {
506
656
  const mcpMiB = (bytes) => `${bytes / 1024 / 1024} MiB`;
507
657
 
508
658
  function mcpImagePart(data, rawMime) {
509
- const mimeType = String(rawMime || 'image/png').split(';')[0].trim().toLowerCase() || 'image/png';
659
+ const mimeType =
660
+ String(rawMime || 'image/png')
661
+ .split(';')[0]
662
+ .trim()
663
+ .toLowerCase() || 'image/png';
510
664
  if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
511
665
  return { type: 'text', text: `[MCP image omitted: ${mimeType} is not a supported image type]` };
512
666
  }
513
667
  const canon = mcpCanonicalBase64(data, MAX_IMAGE_BYTES);
514
668
  if (!canon) return { type: 'text', text: '[MCP image omitted: empty image data]' };
515
669
  if (canon.tooLarge) {
516
- return { type: 'text', text: `[MCP image omitted: ${mimeType} image exceeds the ${mcpMiB(MAX_IMAGE_BYTES)} limit]` };
670
+ return {
671
+ type: 'text',
672
+ text: `[MCP image omitted: ${mimeType} image exceeds the ${mcpMiB(MAX_IMAGE_BYTES)} limit]`,
673
+ };
517
674
  }
518
675
  return { type: 'image', data: canon.data, mimeType };
519
676
  }
@@ -584,10 +741,19 @@ function mcpAudioPart(data, rawMime) {
584
741
  const canon = typeof data === 'string' && data ? mcpCanonicalBase64(data, MAX_ATTACHMENT_BLOB_BYTES) : null;
585
742
  if (!canon) return { type: 'text', text: `[MCP audio content: ${mimeType}]` };
586
743
  if (canon.tooLarge) {
587
- return { type: 'text', text: `[MCP audio omitted: ${mimeType} exceeds the ${mcpMiB(MAX_ATTACHMENT_BLOB_BYTES)} limit]` };
744
+ return {
745
+ type: 'text',
746
+ text: `[MCP audio omitted: ${mimeType} exceeds the ${mcpMiB(MAX_ATTACHMENT_BLOB_BYTES)} limit]`,
747
+ };
588
748
  }
589
749
  const subtype = mimeType.split(';')[0].split('/')[1] || '';
590
- const path = spillMcpBinary(canon.buffer, subtype.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase() || 'bin');
750
+ const path = spillMcpBinary(
751
+ canon.buffer,
752
+ subtype
753
+ .replace(/[^a-z0-9]/gi, '')
754
+ .slice(0, 8)
755
+ .toLowerCase() || 'bin'
756
+ );
591
757
  return {
592
758
  type: 'text',
593
759
  text: path
@@ -648,8 +814,32 @@ export async function normalizeMcpToolResult(result) {
648
814
  return parts ? { content: parts } : text;
649
815
  }
650
816
 
817
+ // JSON-RPC codes the SDK uses for client-side failures (McpError).
818
+ const MCP_CONNECTION_CLOSED_CODE = -32000;
819
+ const MCP_REQUEST_TIMEOUT_CODE = -32001;
820
+
821
+ // Our own deadline, or the SDK's (McpError RequestTimeout, incl. the
822
+ // progress maxTotalTimeout).
651
823
  function isMcpToolCallTimeoutError(err) {
652
- return err?.code === 'EMCPTOOLTIMEOUT';
824
+ return err?.code === 'EMCPTOOLTIMEOUT' || err?.code === MCP_REQUEST_TIMEOUT_CODE;
825
+ }
826
+
827
+ // The server answered with a JSON-RPC error: proof it is alive.
828
+ function isMcpServerResponseError(err) {
829
+ return (
830
+ typeof err?.code === 'number' && err.code !== MCP_CONNECTION_CLOSED_CODE && err.code !== MCP_REQUEST_TIMEOUT_CODE
831
+ );
832
+ }
833
+
834
+ // Connection-level failures that prove the request was never delivered: the
835
+ // SDK/transport had no live connection to send on, or the socket was refused
836
+ // before anything was written. ECONNRESET etc. are excluded — the request may
837
+ // have been read before the reset.
838
+ const MCP_UNDELIVERED_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH']);
839
+ function isMcpUndeliveredError(err) {
840
+ if (!err) return false;
841
+ if (/^Not connected$/i.test(String(err.message || '').trim())) return true;
842
+ return MCP_UNDELIVERED_CODES.has(String(err.code || err.cause?.code || ''));
653
843
  }
654
844
 
655
845
  // Caller-driven cancellation (hook timeout, aborted turn, closed pane).
@@ -714,38 +904,22 @@ export function raceMcpAbort(promise, signal, message) {
714
904
  }
715
905
 
716
906
  async function _callToolWithTimeout(server, toolName, args, signal = null) {
717
- const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
718
- // The signal goes INTO the SDK request: an aborted caller cancels the live
719
- // JSON-RPC request (notifications/cancelled) instead of merely walking away
720
- // from it. The race below additionally frees the admission slot at once.
721
- const requestOptions = signal ? { signal } : undefined;
722
- const abortMessage = `MCP tool call aborted (server="${server?.name}", tool="${toolName}")`;
723
- if (!(timeoutMs > 0)) {
724
- // Server timeout disabled: the caller's signal is then the ONLY bound.
725
- return raceMcpAbort(
726
- server.client.callTool({ name: toolName, arguments: args }, undefined, requestOptions),
727
- signal,
728
- abortMessage
729
- );
730
- }
731
- const timeout = mcpCallTimeout(
907
+ // The request's signal goes INTO the SDK: an aborted caller (or our own
908
+ // timeout) cancels the live JSON-RPC request (notifications/cancelled)
909
+ // instead of merely walking away from it. The abort race additionally frees
910
+ // the admission slot at once. With the timeout disabled the caller's signal
911
+ // is the ONLY bound.
912
+ return runMcpRequest(
732
913
  server,
733
- timeoutMs,
734
- `MCP tool call timed out after ${timeoutMs}ms (server="${server.name}", tool="${toolName}")`,
735
- { serverName: server.name, toolName, timeoutMs }
914
+ signal,
915
+ (options) => server.client.callTool({ name: toolName, arguments: args }, undefined, options),
916
+ {
917
+ abortMessage: `MCP tool call aborted (server="${server?.name}", tool="${toolName}")`,
918
+ timeoutMessage: (ms) =>
919
+ `MCP tool call timed out after ${ms}ms without progress (server="${server.name}", tool="${toolName}")`,
920
+ errorFields: { serverName: server.name, toolName },
921
+ }
736
922
  );
737
- try {
738
- return await raceMcpAbort(
739
- Promise.race([
740
- server.client.callTool({ name: toolName, arguments: args }, undefined, requestOptions),
741
- timeout.promise,
742
- ]),
743
- signal,
744
- abortMessage
745
- );
746
- } finally {
747
- timeout.clear();
748
- }
749
923
  }
750
924
 
751
925
  function countTextLines(text) {
@@ -932,17 +1106,19 @@ export async function disconnectAll(options = undefined) {
932
1106
  export async function disconnectMcpServer(name, options = {}) {
933
1107
  const scopeId = normalizeMcpScopeId(options);
934
1108
  const registryKey = mcpServerRegistryKey(scopeId, name);
935
- const server = servers.get(registryKey);
936
- if (!server) return false;
937
- try {
938
- await _closeServer(server);
939
- } catch {
940
- /* ignore */
941
- }
942
- servers.delete(registryKey);
943
- closeCallAdmission(scopeId, name, `MCP ${name} disconnected`);
944
- _invalidateMcpToolFieldMemo();
945
- return true;
1109
+ return runExclusiveServerOp(registryKey, async () => {
1110
+ const server = servers.get(registryKey);
1111
+ if (!server) return false;
1112
+ try {
1113
+ await _closeServer(server);
1114
+ } catch {
1115
+ /* ignore */
1116
+ }
1117
+ if (servers.get(registryKey) === server) servers.delete(registryKey);
1118
+ closeCallAdmission(scopeId, name, `MCP ${name} disconnected`);
1119
+ _invalidateMcpToolFieldMemo();
1120
+ return true;
1121
+ });
946
1122
  }
947
1123
  /**
948
1124
  * Close a single server: for stdio transports first shut down the full child
@@ -952,6 +1128,8 @@ export async function disconnectMcpServer(name, options = {}) {
952
1128
  * only kills the direct child and discards the pid we need to walk the tree.
953
1129
  */
954
1130
  async function _closeServer(server) {
1131
+ // A deliberate close is not a crash: suppress the unexpected-close report.
1132
+ if (server?.liveness) server.liveness.closing = true;
955
1133
  const transport = server?.transport;
956
1134
  // Live stdio transports expose the spawned ChildProcess on _process.
957
1135
  if (transport?._process) {
@@ -986,7 +1164,8 @@ async function connectServer(
986
1164
  throw new Error(`MCP server "${name}" connect aborted by shutdown`);
987
1165
  }
988
1166
  const client = createMcpClient(sdk, name, cfg);
989
- const { transport, autoDetectAdvert } = createMcpTransport({ name, cfg, sdk, log: mcpLog });
1167
+ const { transport, autoDetectAdvert, stderrTail } = createMcpTransport({ name, cfg, sdk, log: mcpLog });
1168
+ const liveness = trackMcpLiveness(client, name, cfg, stderrTail);
990
1169
  const pending = { scopeId, name, client, transport };
991
1170
  _pendingConnects.add(pending);
992
1171
  try {
@@ -996,11 +1175,12 @@ async function connectServer(
996
1175
  client,
997
1176
  transport,
998
1177
  autoDetectAdvert,
999
- closeServer: _closeServer,
1178
+ closeServer: (target) => _closeServer({ ...target, liveness }),
1179
+ stderrTail,
1000
1180
  });
1001
1181
  if (!toolsResult || !Array.isArray(toolsResult.tools)) {
1002
1182
  try {
1003
- await _closeServer({ client, transport });
1183
+ await _closeServer({ client, transport, liveness });
1004
1184
  } catch {
1005
1185
  /* ignore */
1006
1186
  }
@@ -1009,7 +1189,7 @@ async function connectServer(
1009
1189
  if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
1010
1190
  // disconnectAll() ran mid-handshake: never register — tear down.
1011
1191
  try {
1012
- await _closeServer({ client, transport });
1192
+ await _closeServer({ client, transport, liveness });
1013
1193
  } catch {
1014
1194
  /* ignore */
1015
1195
  }
@@ -1027,6 +1207,8 @@ async function connectServer(
1027
1207
  cfg,
1028
1208
  instructions,
1029
1209
  capabilities,
1210
+ liveness,
1211
+ stderrTail,
1030
1212
  generation: genAtStart,
1031
1213
  });
1032
1214
  if (capabilities?.tools?.listChanged && sdk.ToolListChangedNotificationSchema) {
@@ -1041,6 +1223,33 @@ async function connectServer(
1041
1223
  }
1042
1224
  }
1043
1225
 
1226
+ /**
1227
+ * Liveness of one server connection. The SDK calls client.onclose whenever
1228
+ * the transport closes (child exit, socket drop, our own close) — the server
1229
+ * is then dead and getMcpServerStatus reports it disconnected. onerror only
1230
+ * records the last error (parse/send hiccups do not mean the server died).
1231
+ * Handlers already installed on the client are chained, not replaced.
1232
+ */
1233
+ function trackMcpLiveness(client, name, cfg, stderrTail) {
1234
+ const liveness = { alive: true, closing: false, closedAt: 0, lastError: '' };
1235
+ const priorClose = client.onclose;
1236
+ const priorError = client.onerror;
1237
+ client.onclose = (...args) => {
1238
+ const unexpected = liveness.alive && !liveness.closing;
1239
+ liveness.alive = false;
1240
+ liveness.closedAt = Date.now();
1241
+ if (unexpected) {
1242
+ mcpLog(`${appendMcpStderrTail(`[mcp-client] MCP server "${name}" closed unexpectedly`, stderrTail())}\n`);
1243
+ }
1244
+ if (typeof priorClose === 'function') priorClose.apply(client, args);
1245
+ };
1246
+ client.onerror = (error, ...rest) => {
1247
+ liveness.lastError = scrubMcpConnectionMessage(error instanceof Error ? error.message : String(error), cfg);
1248
+ if (typeof priorError === 'function') priorError.call(client, error, ...rest);
1249
+ };
1250
+ return liveness;
1251
+ }
1252
+
1044
1253
  /** The SDK client for one server, advertising the project root when known. */
1045
1254
  function createMcpClient(sdk, name, cfg) {
1046
1255
  const projectRoot = String(cfg?._mixdogProjectRoot || cfg?.cwd || '').trim();
@@ -1075,11 +1284,26 @@ function mcpServerTools(name, capabilities, rawTools) {
1075
1284
  return [...protocolTools, ...mcpFeatureTools(name, capabilities, protocolTools)];
1076
1285
  }
1077
1286
 
1078
- async function refreshServerTools(registryKey, client, name, cfg, capabilities) {
1287
+ // tools/list_changed refreshes run one at a time per server connection, so an
1288
+ // older listing can never land after a newer one.
1289
+ function refreshServerTools(registryKey, client, name, cfg, capabilities) {
1079
1290
  const live = servers.get(registryKey);
1080
- if (!live || live.client !== client) return;
1291
+ if (!live || live.client !== client) return Promise.resolve();
1292
+ live.refreshTail = (live.refreshTail || Promise.resolve()).then(() =>
1293
+ runToolRefresh(registryKey, live, name, cfg, capabilities)
1294
+ );
1295
+ return live.refreshTail;
1296
+ }
1297
+
1298
+ async function runToolRefresh(registryKey, live, name, cfg, capabilities) {
1299
+ if (servers.get(registryKey) !== live) return;
1081
1300
  try {
1082
- const refreshed = await client.listTools();
1301
+ const { sdkTimeoutMs } = resolveMcpRequestTimeouts(cfg);
1302
+ const refreshed = await listAllMcpTools(live.client, { timeout: sdkTimeoutMs });
1303
+ if (!refreshed || !Array.isArray(refreshed.tools)) {
1304
+ throw new Error('ListTools returned invalid shape: missing or non-array tools field');
1305
+ }
1306
+ if (servers.get(registryKey) !== live) return;
1083
1307
  live.tools = mcpServerTools(name, capabilities, refreshed.tools);
1084
1308
  _invalidateMcpToolFieldMemo();
1085
1309
  } catch (error) {
@@ -1109,6 +1333,8 @@ export function _registerMcpServerForTest(scopeId, name, rawTools = [], options
1109
1333
  cfg: options.cfg || {},
1110
1334
  instructions: options.instructions || '',
1111
1335
  capabilities,
1336
+ liveness: { alive: true, closing: false, closedAt: 0, lastError: '' },
1337
+ stderrTail: typeof options.stderrTail === 'function' ? options.stderrTail : () => '',
1112
1338
  transport: null,
1113
1339
  client: {
1114
1340
  callTool:
@@ -13,6 +13,10 @@ export function createKeyedSingleflight() {
13
13
  pending.set(lane, tracked);
14
14
  return tracked;
15
15
  },
16
+ /** The in-flight task promise for `key`, or undefined. */
17
+ wait(key) {
18
+ return pending.get(String(key || ''));
19
+ },
16
20
  has(key) {
17
21
  return pending.has(String(key || ''));
18
22
  },