@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.1

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 (349) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4685 -4614
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +374 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  25. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  26. package/dist/types/live/attestation.d.ts +2 -0
  27. package/dist/types/live/controller.d.ts +48 -0
  28. package/dist/types/live/protocol.d.ts +85 -0
  29. package/dist/types/live/protocol.test.d.ts +1 -0
  30. package/dist/types/live/transport.d.ts +35 -0
  31. package/dist/types/live/visualizer.d.ts +30 -0
  32. package/dist/types/lsp/client.d.ts +16 -1
  33. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  34. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  35. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  36. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  37. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  38. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  39. package/dist/types/modes/components/custom-message.d.ts +1 -1
  40. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  41. package/dist/types/modes/components/message-frame.d.ts +8 -4
  42. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  43. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  44. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  45. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  46. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  48. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  49. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  51. package/dist/types/modes/interactive-mode.d.ts +6 -0
  52. package/dist/types/modes/theme/theme.d.ts +1 -1
  53. package/dist/types/modes/types.d.ts +4 -1
  54. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  55. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  56. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  57. package/dist/types/registry/agent-registry.d.ts +11 -4
  58. package/dist/types/sdk.d.ts +15 -1
  59. package/dist/types/secrets/index.d.ts +15 -2
  60. package/dist/types/secrets/obfuscator.d.ts +113 -15
  61. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  62. package/dist/types/session/agent-session-events.d.ts +84 -0
  63. package/dist/types/session/agent-session-types.d.ts +321 -0
  64. package/dist/types/session/agent-session.d.ts +135 -753
  65. package/dist/types/session/async-job-delivery.d.ts +28 -0
  66. package/dist/types/session/auth-storage.d.ts +1 -1
  67. package/dist/types/session/bash-runner.d.ts +75 -0
  68. package/dist/types/session/blob-store.d.ts +11 -1
  69. package/dist/types/session/blob-store.test.d.ts +1 -0
  70. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  71. package/dist/types/session/eval-runner.d.ts +51 -0
  72. package/dist/types/session/irc-bridge.d.ts +46 -0
  73. package/dist/types/session/messages.d.ts +24 -0
  74. package/dist/types/session/model-controls.d.ts +180 -0
  75. package/dist/types/session/prewalk.d.ts +46 -0
  76. package/dist/types/session/queued-messages.d.ts +22 -0
  77. package/dist/types/session/redis-session-storage.d.ts +1 -0
  78. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  79. package/dist/types/session/role-models.d.ts +15 -0
  80. package/dist/types/session/session-advisors.d.ts +228 -0
  81. package/dist/types/session/session-entries.d.ts +8 -0
  82. package/dist/types/session/session-handoff.d.ts +70 -0
  83. package/dist/types/session/session-history-format.d.ts +6 -1
  84. package/dist/types/session/session-maintenance.d.ts +246 -0
  85. package/dist/types/session/session-manager.d.ts +41 -0
  86. package/dist/types/session/session-memory.d.ts +56 -0
  87. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  88. package/dist/types/session/session-stats.d.ts +48 -0
  89. package/dist/types/session/session-tools.d.ts +158 -0
  90. package/dist/types/session/session-workspace.d.ts +32 -0
  91. package/dist/types/session/stream-guards.d.ts +44 -0
  92. package/dist/types/session/streaming-output.d.ts +8 -0
  93. package/dist/types/session/todo-tracker.d.ts +56 -0
  94. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  95. package/dist/types/session/turn-recovery.d.ts +225 -0
  96. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  97. package/dist/types/stt/index.d.ts +0 -2
  98. package/dist/types/stt/stt-controller.d.ts +7 -0
  99. package/dist/types/system-prompt.d.ts +2 -0
  100. package/dist/types/task/executor.d.ts +4 -1
  101. package/dist/types/task/output-manager.d.ts +3 -1
  102. package/dist/types/tiny/title-client.d.ts +10 -0
  103. package/dist/types/tools/approval.d.ts +1 -0
  104. package/dist/types/tools/browser/launch.d.ts +5 -0
  105. package/dist/types/tools/builtin-names.d.ts +1 -1
  106. package/dist/types/tools/computer/protocol.d.ts +43 -0
  107. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  108. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  109. package/dist/types/tools/computer/worker.d.ts +15 -0
  110. package/dist/types/tools/computer-renderer.d.ts +22 -0
  111. package/dist/types/tools/computer.d.ts +71 -0
  112. package/dist/types/tools/context.d.ts +2 -0
  113. package/dist/types/tools/default-renderer.d.ts +21 -0
  114. package/dist/types/tools/image-gen.d.ts +4 -4
  115. package/dist/types/tools/image-providers.d.ts +38 -0
  116. package/dist/types/tools/index.d.ts +7 -0
  117. package/dist/types/tools/path-utils.d.ts +8 -0
  118. package/dist/types/tools/todo.d.ts +10 -4
  119. package/dist/types/tools/xdev.d.ts +5 -1
  120. package/dist/types/tts/streaming-player.d.ts +10 -29
  121. package/dist/types/tts/vocalizer.d.ts +5 -0
  122. package/dist/types/utils/jj.d.ts +29 -0
  123. package/dist/types/utils/lang-from-path.d.ts +1 -0
  124. package/dist/types/utils/title-generator.d.ts +30 -0
  125. package/dist/types/utils/tools-manager.d.ts +1 -2
  126. package/dist/types/vibe/runtime.d.ts +36 -8
  127. package/dist/types/web/search/index.d.ts +1 -1
  128. package/dist/types/web/search/provider.d.ts +15 -5
  129. package/dist/types/web/search/types.d.ts +94 -0
  130. package/package.json +12 -12
  131. package/scripts/generate-share-viewer.ts +4 -6
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +3 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/setup-cli.ts +2 -14
  141. package/src/cli/usage-cli.ts +10 -1
  142. package/src/cli.ts +8 -0
  143. package/src/collab/host.ts +3 -2
  144. package/src/commands/bench.ts +18 -4
  145. package/src/commands/launch.ts +4 -0
  146. package/src/config/model-registry.ts +16 -2
  147. package/src/config/models-config-schema.ts +15 -4
  148. package/src/config/provider-globals.ts +9 -9
  149. package/src/config/service-tier.ts +26 -1
  150. package/src/config/settings-schema.ts +270 -51
  151. package/src/config/settings.ts +81 -2
  152. package/src/dap/client.ts +37 -4
  153. package/src/edit/index.ts +49 -12
  154. package/src/eval/executor-base.ts +1 -0
  155. package/src/eval/js/executor.ts +2 -0
  156. package/src/eval/py/executor.ts +103 -33
  157. package/src/exec/bash-executor.ts +90 -6
  158. package/src/exec/direnv.ts +145 -0
  159. package/src/export/html/args.ts +20 -0
  160. package/src/export/html/index.ts +45 -47
  161. package/src/export/html/template.css +19 -1
  162. package/src/export/html/template.html +6 -0
  163. package/src/export/html/template.js +21 -0
  164. package/src/export/html/tool-views.generated.js +31 -31
  165. package/src/export/html/web-palette.ts +116 -132
  166. package/src/export/share.ts +249 -49
  167. package/src/extensibility/extensions/loader.ts +29 -1
  168. package/src/extensibility/extensions/runner.ts +6 -0
  169. package/src/extensibility/extensions/types.ts +33 -2
  170. package/src/extensibility/extensions/wrapper.ts +68 -12
  171. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  172. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  173. package/src/hindsight/state.ts +64 -6
  174. package/src/internal-urls/local-protocol.ts +2 -1
  175. package/src/internal-urls/memory-protocol.ts +2 -2
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/attestation.ts +91 -0
  180. package/src/live/controller.ts +517 -0
  181. package/src/live/prompts/agent-final-message.md +3 -0
  182. package/src/live/prompts/live-instructions.md +23 -0
  183. package/src/live/protocol.test.ts +140 -0
  184. package/src/live/protocol.ts +233 -0
  185. package/src/live/transport.ts +422 -0
  186. package/src/live/visualizer.ts +214 -0
  187. package/src/lsp/client.ts +29 -9
  188. package/src/lsp/index.ts +19 -4
  189. package/src/main.ts +8 -1
  190. package/src/mcp/config-writer.test.ts +43 -0
  191. package/src/mcp/config-writer.ts +109 -82
  192. package/src/mcp/oauth-discovery.ts +10 -2
  193. package/src/mcp/smithery-auth.test.ts +29 -0
  194. package/src/mcp/smithery-auth.ts +3 -2
  195. package/src/memories/index.ts +40 -20
  196. package/src/memory-backend/tool-names.ts +2 -0
  197. package/src/mnemopi/backend.ts +24 -7
  198. package/src/modes/acp/acp-agent.ts +56 -10
  199. package/src/modes/acp/acp-event-mapper.ts +8 -1
  200. package/src/modes/components/agent-hub.ts +1 -1
  201. package/src/modes/components/assistant-message.ts +7 -2
  202. package/src/modes/components/custom-message.ts +4 -1
  203. package/src/modes/components/login-dialog.ts +14 -4
  204. package/src/modes/components/message-frame.ts +14 -8
  205. package/src/modes/components/plan-review-overlay.ts +350 -35
  206. package/src/modes/components/session-account-selector.ts +62 -0
  207. package/src/modes/components/settings-defs.ts +23 -1
  208. package/src/modes/components/settings-selector.ts +169 -1
  209. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  210. package/src/modes/components/status-line/component.ts +123 -2
  211. package/src/modes/components/tool-execution.ts +28 -112
  212. package/src/modes/controllers/command-controller.ts +56 -15
  213. package/src/modes/controllers/event-controller.ts +111 -16
  214. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  215. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  216. package/src/modes/controllers/input-controller.ts +47 -39
  217. package/src/modes/controllers/live-command-controller.ts +255 -0
  218. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  219. package/src/modes/controllers/omfg-controller.ts +44 -40
  220. package/src/modes/controllers/selector-controller.ts +80 -10
  221. package/src/modes/controllers/session-focus-controller.ts +6 -1
  222. package/src/modes/interactive-mode.ts +176 -38
  223. package/src/modes/runtime-init.ts +2 -0
  224. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  225. package/src/modes/theme/theme.ts +1 -1
  226. package/src/modes/types.ts +4 -1
  227. package/src/modes/utils/context-usage.ts +23 -7
  228. package/src/modes/utils/ui-helpers.ts +10 -5
  229. package/src/plan-mode/plan-files.ts +40 -0
  230. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  231. package/src/prompts/bench/cache-prefix.md +3 -0
  232. package/src/prompts/bench/cache-suffix.md +1 -0
  233. package/src/prompts/system/computer-safety.md +14 -0
  234. package/src/prompts/system/custom-system-prompt.md +1 -1
  235. package/src/prompts/system/project-prompt.md +8 -1
  236. package/src/prompts/system/subagent-async-pending.md +6 -0
  237. package/src/prompts/system/system-prompt.md +1 -1
  238. package/src/prompts/system/workflow-notice.md +48 -40
  239. package/src/prompts/system/xdev-mount-notice.md +4 -0
  240. package/src/prompts/tools/computer.md +26 -0
  241. package/src/prompts/tools/todo.md +3 -1
  242. package/src/registry/agent-lifecycle.ts +116 -46
  243. package/src/registry/agent-registry.ts +35 -8
  244. package/src/registry/persisted-agents.ts +26 -2
  245. package/src/sdk.ts +215 -136
  246. package/src/secrets/index.ts +127 -4
  247. package/src/secrets/obfuscator.ts +2328 -105
  248. package/src/session/acp-permission-gate.ts +165 -0
  249. package/src/session/agent-session-error-log.test.ts +1 -1
  250. package/src/session/agent-session-events.ts +66 -0
  251. package/src/session/agent-session-types.ts +343 -0
  252. package/src/session/agent-session.ts +1940 -12218
  253. package/src/session/artifacts.ts +6 -5
  254. package/src/session/async-job-delivery.ts +74 -0
  255. package/src/session/auth-storage.ts +1 -0
  256. package/src/session/bash-runner.ts +326 -0
  257. package/src/session/blob-store.test.ts +56 -0
  258. package/src/session/blob-store.ts +18 -2
  259. package/src/session/checkpoint-entries.ts +81 -0
  260. package/src/session/eval-runner.ts +217 -0
  261. package/src/session/indexed-session-storage.ts +9 -2
  262. package/src/session/irc-bridge.ts +203 -0
  263. package/src/session/messages.ts +266 -1
  264. package/src/session/model-controls.ts +714 -0
  265. package/src/session/prewalk.ts +252 -0
  266. package/src/session/queued-messages.ts +93 -0
  267. package/src/session/redis-session-storage.ts +51 -11
  268. package/src/session/retry-fallback-chains.ts +455 -0
  269. package/src/session/role-models.ts +85 -0
  270. package/src/session/session-advisors.ts +1679 -0
  271. package/src/session/session-context.ts +6 -3
  272. package/src/session/session-entries.ts +8 -0
  273. package/src/session/session-handoff.ts +308 -0
  274. package/src/session/session-history-format.ts +20 -9
  275. package/src/session/session-loader.ts +0 -46
  276. package/src/session/session-maintenance.ts +2983 -0
  277. package/src/session/session-manager.ts +390 -30
  278. package/src/session/session-memory.ts +222 -0
  279. package/src/session/session-provider-boundary.ts +307 -0
  280. package/src/session/session-stats.ts +293 -0
  281. package/src/session/session-tools.ts +980 -0
  282. package/src/session/session-workspace.ts +53 -0
  283. package/src/session/stream-guards.ts +417 -0
  284. package/src/session/streaming-output.ts +52 -5
  285. package/src/session/todo-tracker.ts +380 -0
  286. package/src/session/ttsr-coordinator.ts +496 -0
  287. package/src/session/turn-recovery.ts +1629 -0
  288. package/src/slash-commands/builtin-registry.ts +257 -20
  289. package/src/slash-commands/helpers/session-pin.ts +44 -0
  290. package/src/slash-commands/helpers/usage-report.ts +22 -3
  291. package/src/stt/downloader.ts +0 -2
  292. package/src/stt/index.ts +0 -2
  293. package/src/stt/stt-controller.ts +57 -146
  294. package/src/system-prompt.ts +45 -17
  295. package/src/task/executor.ts +285 -70
  296. package/src/task/index.ts +98 -105
  297. package/src/task/output-manager.ts +25 -3
  298. package/src/task/persisted-revive.ts +4 -3
  299. package/src/task/structured-subagent.ts +1 -0
  300. package/src/tiny/title-client.ts +22 -0
  301. package/src/tools/approval.ts +55 -10
  302. package/src/tools/bash-interactive.ts +116 -86
  303. package/src/tools/bash-skill-urls.ts +28 -2
  304. package/src/tools/bash.ts +339 -123
  305. package/src/tools/browser/launch.ts +9 -1
  306. package/src/tools/browser/tab-supervisor.ts +8 -5
  307. package/src/tools/browser.ts +94 -47
  308. package/src/tools/builtin-names.ts +1 -0
  309. package/src/tools/computer/protocol.ts +28 -0
  310. package/src/tools/computer/supervisor.ts +258 -0
  311. package/src/tools/computer/worker-entry.ts +25 -0
  312. package/src/tools/computer/worker.ts +135 -0
  313. package/src/tools/computer-renderer.ts +108 -0
  314. package/src/tools/computer.ts +433 -0
  315. package/src/tools/context.ts +2 -0
  316. package/src/tools/default-renderer.ts +139 -0
  317. package/src/tools/essential-tools.ts +1 -0
  318. package/src/tools/image-gen.ts +20 -28
  319. package/src/tools/image-providers.ts +50 -0
  320. package/src/tools/index.ts +14 -0
  321. package/src/tools/path-utils.ts +49 -2
  322. package/src/tools/read.ts +156 -88
  323. package/src/tools/renderers.ts +9 -1
  324. package/src/tools/todo.ts +101 -11
  325. package/src/tools/vibe.ts +1 -2
  326. package/src/tools/write.ts +51 -1
  327. package/src/tools/xdev.ts +121 -29
  328. package/src/tts/streaming-player.ts +82 -283
  329. package/src/tts/vocalizer.ts +18 -1
  330. package/src/utils/clipboard.ts +1 -30
  331. package/src/utils/jj.ts +125 -4
  332. package/src/utils/lang-from-path.ts +7 -0
  333. package/src/utils/mac-file-urls.applescript +37 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/utils/tool-choice.ts +14 -0
  336. package/src/utils/tools-manager.ts +1 -19
  337. package/src/vibe/runtime.ts +852 -72
  338. package/src/web/search/index.ts +3 -6
  339. package/src/web/search/provider.ts +31 -16
  340. package/src/web/search/providers/xai.ts +7 -1
  341. package/src/web/search/types.ts +3 -0
  342. package/dist/types/stt/recorder.d.ts +0 -30
  343. package/dist/types/stt/transcriber.d.ts +0 -14
  344. package/dist/types/stt/wav.d.ts +0 -29
  345. package/dist/types/tts/player.d.ts +0 -32
  346. package/src/stt/recorder.ts +0 -551
  347. package/src/stt/transcriber.ts +0 -60
  348. package/src/stt/wav.ts +0 -173
  349. package/src/tts/player.ts +0 -137
@@ -0,0 +1,214 @@
1
+ import {
2
+ type Component,
3
+ matchesKey,
4
+ replaceTabs,
5
+ sliceWithWidth,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ } from "@oh-my-pi/pi-tui";
9
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
10
+ import { type ThemeColor, theme } from "../modes/theme/theme";
11
+
12
+ /** Distinct states of a realtime call connection. */
13
+ export type LivePhase = "connecting" | "listening" | "working" | "speaking" | "muted" | "error";
14
+ /** Configuration callbacks for user interactions in the visualizer. */
15
+ export interface LiveVisualizerOptions {
16
+ onStop(): void;
17
+ onToggleMute(): void;
18
+ }
19
+
20
+ function normalizeTranscript(text: string): string {
21
+ return replaceTabs(sanitizeText(text)).replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ function truncateFromStart(text: string, width: number): string {
25
+ if (width <= 0) return "";
26
+ const textWidth = visibleWidth(text);
27
+ if (textWidth <= width) return text;
28
+ if (width === 1) return "…";
29
+ return `…${sliceWithWidth(text, textWidth - width + 1, width - 1, true).text}`;
30
+ }
31
+
32
+ /** A compact, fixed-height terminal component for displaying a realtime call. */
33
+ export class LiveVisualizer implements Component {
34
+ readonly wantsKeyRelease = false;
35
+
36
+ readonly #options: LiveVisualizerOptions;
37
+
38
+ #phase: LivePhase = "connecting";
39
+ #inputLevel = 0;
40
+ #displayLevel = 0;
41
+ #frame = 0;
42
+ #userTranscript = "";
43
+
44
+ #cache:
45
+ | {
46
+ width: number;
47
+ phase: LivePhase;
48
+ displayLevel: number;
49
+ frame: number;
50
+ userTranscript: string;
51
+ lines: readonly string[];
52
+ }
53
+ | undefined;
54
+
55
+ constructor(options: LiveVisualizerOptions) {
56
+ this.#options = options;
57
+ }
58
+
59
+ /** Updates the current call phase. */
60
+ setPhase(phase: LivePhase): void {
61
+ if (this.#phase !== phase) {
62
+ this.#phase = phase;
63
+ this.invalidate();
64
+ }
65
+ }
66
+
67
+ /** Updates the microphone volume level (0..1). */
68
+ setInputLevel(level: number): void {
69
+ const next = Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0;
70
+ if (this.#inputLevel === next) return;
71
+ this.#inputLevel = next;
72
+ if (next > this.#displayLevel) this.#displayLevel = next;
73
+ this.invalidate();
74
+ }
75
+
76
+ /** Advances the spectrum animation and its peak decay. */
77
+ setFrame(frame: number): void {
78
+ const nextLevel = Math.max(this.#inputLevel, this.#displayLevel * 0.84);
79
+ if (this.#frame !== frame || this.#displayLevel !== nextLevel) {
80
+ this.#frame = frame;
81
+ this.#displayLevel = nextLevel;
82
+ this.invalidate();
83
+ }
84
+ }
85
+
86
+ /** Updates the user's streaming voice transcript. */
87
+ setTranscript(text: string): void {
88
+ const normalized = normalizeTranscript(text);
89
+ if (this.#userTranscript === normalized) return;
90
+ this.#userTranscript = normalized;
91
+ this.invalidate();
92
+ }
93
+
94
+ /** Clears the user's voice transcript row. */
95
+ clearTranscript(): void {
96
+ if (!this.#userTranscript) return;
97
+ this.#userTranscript = "";
98
+ this.invalidate();
99
+ }
100
+
101
+ /** Processes user keypresses. */
102
+ handleInput(data: string): void {
103
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
104
+ this.#options.onStop();
105
+ } else if (matchesKey(data, "space")) {
106
+ this.#options.onToggleMute();
107
+ }
108
+ }
109
+
110
+ /** Clears the render cache. */
111
+ invalidate(): void {
112
+ this.#cache = undefined;
113
+ }
114
+
115
+ /** Renders the microphone spectrum into a compact fixed-height panel. */
116
+ render(width: number): readonly string[] {
117
+ if (
118
+ this.#cache &&
119
+ this.#cache.width === width &&
120
+ this.#cache.phase === this.#phase &&
121
+ this.#cache.displayLevel === this.#displayLevel &&
122
+ this.#cache.frame === this.#frame &&
123
+ this.#cache.userTranscript === this.#userTranscript
124
+ ) {
125
+ return this.#cache.lines;
126
+ }
127
+
128
+ const lines = this.#renderLines(width);
129
+ this.#cache = {
130
+ width,
131
+ phase: this.#phase,
132
+ displayLevel: this.#displayLevel,
133
+ frame: this.#frame,
134
+ userTranscript: this.#userTranscript,
135
+ lines,
136
+ };
137
+ return lines;
138
+ }
139
+
140
+ #renderLines(maxWidth: number): readonly string[] {
141
+ const width = Math.max(2, maxWidth);
142
+ const innerWidth = width - 2;
143
+ const border = (content: string): string =>
144
+ theme.fg("border", "│") + content + (width > 1 ? theme.fg("border", "│") : "");
145
+ const top = theme.fg("border", `┌${"─".repeat(innerWidth)}${width > 1 ? "┐" : ""}`);
146
+ const spectrumColor: ThemeColor = this.#phase === "muted" ? "dim" : this.#phase === "error" ? "error" : "success";
147
+ const spectrum = this.#generateSpectrum(innerWidth, 2);
148
+ const spectrumRows = spectrum.map(row => border(theme.fg(spectrumColor, row)));
149
+ const transcript = this.#renderTranscript(this.#userTranscript, innerWidth, border);
150
+ return [top, ...spectrumRows, transcript, this.#renderFooter(width, innerWidth)];
151
+ }
152
+
153
+ #renderTranscript(transcript: string, innerWidth: number, border: (content: string) => string): string {
154
+ const content = truncateFromStart(transcript, innerWidth);
155
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
156
+ return border(theme.fg("accent", content) + padding);
157
+ }
158
+
159
+ #renderFooter(width: number, innerWidth: number): string {
160
+ const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
161
+ const staticIcons: Record<LivePhase, string> = {
162
+ connecting: "○",
163
+ listening: "●",
164
+ working: "○",
165
+ speaking: "»",
166
+ muted: "×",
167
+ error: "!",
168
+ };
169
+ const icon = this.#phase === "working" ? spinners[this.#frame % spinners.length] : staticIcons[this.#phase];
170
+ const phaseColors: Record<LivePhase, ThemeColor> = {
171
+ connecting: "dim",
172
+ listening: "success",
173
+ working: "warning",
174
+ speaking: "accent",
175
+ muted: "dim",
176
+ error: "error",
177
+ };
178
+ const status = `${icon} ${this.#phase}`;
179
+ const fullLabel = ` ${status} · space mute · esc end `;
180
+ const shortLabel = ` ${status} `;
181
+ const label =
182
+ innerWidth >= visibleWidth(fullLabel) + 1
183
+ ? fullLabel
184
+ : innerWidth >= visibleWidth(shortLabel) + 1
185
+ ? shortLabel
186
+ : "";
187
+ if (!label) {
188
+ return theme.fg("border", `└${"─".repeat(innerWidth)}${width > 1 ? "┘" : ""}`);
189
+ }
190
+ const remaining = Math.max(0, innerWidth - visibleWidth(label) - 1);
191
+ return (
192
+ theme.fg("border", "└─") +
193
+ theme.fg(phaseColors[this.#phase], truncateToWidth(label, innerWidth - 1)) +
194
+ theme.fg("border", `${"─".repeat(remaining)}${width > 1 ? "┘" : ""}`)
195
+ );
196
+ }
197
+
198
+ #generateSpectrum(width: number, rows: number): string[] {
199
+ const blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
200
+ const output = Array.from({ length: rows }, () => "");
201
+ const energy = this.#phase === "muted" ? 0 : Math.min(1, Math.sqrt(this.#displayLevel * 5));
202
+ const maxHeight = rows * (blocks.length - 1);
203
+ for (let column = 0; column < width; column += 1) {
204
+ const carrier = 0.5 + 0.5 * Math.sin(this.#frame * 0.43 + column * 0.71);
205
+ const shimmer = 0.5 + 0.5 * Math.sin(this.#frame * 0.19 - column * 1.17);
206
+ const height = Math.round(energy * (0.3 + carrier * 0.5 + shimmer * 0.2) * maxHeight);
207
+ for (let row = 0; row < rows; row += 1) {
208
+ const units = Math.max(0, Math.min(blocks.length - 1, height - (rows - row - 1) * 8));
209
+ output[row] += blocks[units];
210
+ }
211
+ }
212
+ return output;
213
+ }
214
+ }
package/src/lsp/client.ts CHANGED
@@ -364,7 +364,15 @@ async function startMessageReader(client: LspClient): Promise<void> {
364
364
  if (pending) {
365
365
  client.pendingRequests.delete(message.id);
366
366
  if ("error" in message && message.error) {
367
- pending.reject(new Error(`LSP error: ${message.error.message}`));
367
+ // Include the JSON-RPC error code: `isMethodNotFoundError` matches
368
+ // `-32601` by substring, so method-not-found is recognized even when
369
+ // the server's message text is nonstandard (e.g. "Unknown request").
370
+ const code = message.error.code;
371
+ pending.reject(
372
+ new Error(
373
+ `LSP error${typeof code === "number" ? ` ${code}` : ""}: ${message.error.message}`,
374
+ ),
375
+ );
368
376
  } else {
369
377
  pending.resolve(message.result);
370
378
  }
@@ -1181,9 +1189,19 @@ async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolea
1181
1189
  }
1182
1190
 
1183
1191
  /**
1184
- * Shutdown a specific client instance using the LSP shutdown/exit handshake.
1192
+ * Tear down a specific client instance using the LSP shutdown/exit handshake.
1193
+ *
1194
+ * Removes the client from the registry by identity first (never evicting a
1195
+ * newer client already republished under the same key), then performs a bounded
1196
+ * graceful shutdown, force-killing and awaiting confirmed process exit.
1197
+ *
1198
+ * @returns `true` once the process is confirmed exited, `false` if it outlived
1199
+ * the shutdown budget — callers reporting a restart must treat `false` as a
1200
+ * failed teardown, not a completed restart.
1185
1201
  */
1186
- async function shutdownClientInstance(client: LspClient): Promise<void> {
1202
+ export async function shutdownClientInstance(client: LspClient): Promise<boolean> {
1203
+ if (clients.get(client.name) === client) clients.delete(client.name);
1204
+
1187
1205
  const err = new Error("LSP client shutdown");
1188
1206
  for (const pending of Array.from(client.pendingRequests.values())) {
1189
1207
  pending.reject(err);
@@ -1196,21 +1214,23 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
1196
1214
  );
1197
1215
  if (shutdownCompleted) {
1198
1216
  await sendNotification(client, "exit", undefined).catch(() => {});
1199
- if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
1217
+ if (await waitForExit(client, EXIT_TIMEOUT_MS)) return true;
1200
1218
  }
1201
1219
 
1202
1220
  client.proc.kill();
1203
- await waitForExit(client, EXIT_TIMEOUT_MS);
1221
+ return await waitForExit(client, EXIT_TIMEOUT_MS);
1204
1222
  }
1205
1223
 
1206
1224
  /**
1207
1225
  * Shutdown a specific client by key.
1226
+ *
1227
+ * @returns `true` when the client is gone (already absent or confirmed exited),
1228
+ * `false` if a live process outlived the shutdown budget.
1208
1229
  */
1209
- export async function shutdownClient(key: string): Promise<void> {
1230
+ export async function shutdownClient(key: string): Promise<boolean> {
1210
1231
  const client = clients.get(key);
1211
- if (!client) return;
1212
- clients.delete(key);
1213
- await shutdownClientInstance(client);
1232
+ if (!client) return true;
1233
+ return await shutdownClientInstance(client);
1214
1234
  }
1215
1235
 
1216
1236
  // =============================================================================
package/src/lsp/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  sendNotification,
30
30
  sendRequest,
31
31
  setIdleTimeout,
32
+ shutdownClientInstance,
32
33
  supportsDocumentDiagnostics,
33
34
  syncContent,
34
35
  WARMUP_TIMEOUT_MS,
@@ -507,12 +508,18 @@ function isMethodNotFoundError(err: unknown): boolean {
507
508
  }
508
509
 
509
510
  async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
510
- // rust-analyzer exposes a real reload request.
511
+ throwIfAborted(signal);
512
+ // rust-analyzer exposes a real reload request. Every other server rejects it
513
+ // with method-not-found — that alone justifies the generic fallback. A caller
514
+ // cancel or tool timeout must propagate, never be mistaken for an unsupported
515
+ // method and swallowed into a bogus "Restarted" (issue #6369).
511
516
  try {
512
517
  await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal);
513
518
  return `Reloaded ${serverName}`;
514
- } catch {
515
- // Method not supported — fall through.
519
+ } catch (err) {
520
+ throwIfAborted(signal);
521
+ if (!isMethodNotFoundError(err)) throw err;
522
+ // Method not supported — fall through to the generic reload.
516
523
  }
517
524
  // workspace/didChangeConfiguration is a notification per spec; sending it
518
525
  // as a request hangs until the tool deadline on servers that route it to
@@ -521,7 +528,15 @@ async function reloadServer(client: LspClient, serverName: string, signal?: Abor
521
528
  await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} }, signal);
522
529
  return `Reloaded ${serverName}`;
523
530
  } catch {
524
- client.proc.kill();
531
+ throwIfAborted(signal);
532
+ // The reload notification could not be delivered — the connection is
533
+ // wedged or the process already died. Tear the client down (removing it
534
+ // from the registry by identity and awaiting confirmed process exit) so
535
+ // the next request cold-starts a fresh client. A kill that never confirms
536
+ // exit is not a restart: surface the teardown failure truthfully.
537
+ if (!(await shutdownClientInstance(client))) {
538
+ throw new Error(`Failed to restart ${serverName}: server process did not exit after kill`);
539
+ }
525
540
  return `Restarted ${serverName}`;
526
541
  }
527
542
  }
package/src/main.ts CHANGED
@@ -391,7 +391,9 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess
391
391
  authStorage: args.authStorage,
392
392
  modelRegistry: args.modelRegistry,
393
393
  agentId,
394
- hasUI: false,
394
+ // Preserve reserve-policy confirmation until ACP capabilities are known
395
+ // without enabling AskTool or other UI-only session behavior.
396
+ deferUsageReserveConfirmation: true,
395
397
  enableMCP: false,
396
398
  titleSystemPrompt,
397
399
  });
@@ -834,6 +836,11 @@ export async function buildSessionOptions(
834
836
  cwd: parsed.cwd ?? getProjectDir(),
835
837
  autoApprove: parsed.autoApprove ?? false,
836
838
  };
839
+ const cliDirs = parsed.addDir ?? [];
840
+ const settingsDirs = activeSettings.get("workspace.additionalDirectories");
841
+ if (cliDirs.length > 0 || settingsDirs.length > 0) {
842
+ options.additionalDirectories = [...new Set([...cliDirs, ...settingsDirs])];
843
+ }
837
844
  if (parsed.maxTime !== undefined) {
838
845
  options.deadline = Date.now() + parsed.maxTime * 1000;
839
846
  }
@@ -0,0 +1,43 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { addMCPServer, readDisabledServers, readMCPConfigFile, setServerDisabled } from "./config-writer";
6
+
7
+ describe("config-writer concurrent mutations", () => {
8
+ let dir: string;
9
+ let filePath: string;
10
+
11
+ beforeEach(async () => {
12
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-config-"));
13
+ filePath = path.join(dir, "mcp.json");
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await fs.rm(dir, { recursive: true, force: true });
18
+ });
19
+
20
+ it("preserves both servers when two adds race the same file", async () => {
21
+ await Promise.all([
22
+ addMCPServer(filePath, "alpha", { type: "stdio", command: "a" }),
23
+ addMCPServer(filePath, "bravo", { type: "stdio", command: "b" }),
24
+ ]);
25
+
26
+ const config = await readMCPConfigFile(filePath);
27
+ expect(Object.keys(config.mcpServers ?? {}).sort()).toEqual(["alpha", "bravo"]);
28
+ });
29
+
30
+ it("preserves both denylist edits when disable calls race", async () => {
31
+ await Promise.all([setServerDisabled(filePath, "alpha", true), setServerDisabled(filePath, "bravo", true)]);
32
+
33
+ expect((await readDisabledServers(filePath)).sort()).toEqual(["alpha", "bravo"]);
34
+ });
35
+
36
+ it("writes into a directory that does not exist yet", async () => {
37
+ const nestedPath = path.join(dir, "nested", "deep", "mcp.json");
38
+ await addMCPServer(nestedPath, "alpha", { type: "stdio", command: "a" });
39
+
40
+ const config = await readMCPConfigFile(nestedPath);
41
+ expect(Object.keys(config.mcpServers ?? {})).toEqual(["alpha"]);
42
+ });
43
+ });
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * Utilities for reading/writing .omp/mcp.json files at user or project level.
5
5
  */
6
+ import { randomUUID } from "node:crypto";
6
7
  import * as fs from "node:fs";
7
8
  import * as path from "node:path";
8
9
  import { isEnoent } from "@oh-my-pi/pi-utils";
9
10
  import { invalidate as invalidateFsCache } from "../capability/fs";
11
+ import { withFileLock } from "../config/file-lock";
10
12
 
11
13
  import { validateServerConfig } from "./config";
12
14
  import { MCP_CONFIG_SCHEMA_URL, type MCPConfigFile, type MCPServerConfig } from "./types";
@@ -18,6 +20,20 @@ function withSchema(config: MCPConfigFile): MCPConfigFile {
18
20
  };
19
21
  }
20
22
 
23
+ /**
24
+ * Serialize a read-modify-write against one config file.
25
+ *
26
+ * Wraps {@link withFileLock} but first ensures the config's parent directory
27
+ * exists, because the lock directory (`${filePath}.lock`) is created with a
28
+ * non-recursive `mkdir` — without this the very first write (before the config
29
+ * file or its parent exists) would fail to acquire the lock with ENOENT.
30
+ */
31
+ function withConfigLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
32
+ return fs.promises
33
+ .mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
34
+ .then(() => withFileLock(filePath, fn));
35
+ }
36
+
21
37
  /**
22
38
  * Read an MCP config file.
23
39
  * Returns empty config if file doesn't exist.
@@ -45,13 +61,20 @@ export async function writeMCPConfigFile(filePath: string, config: MCPConfigFile
45
61
  const dir = path.dirname(filePath);
46
62
  await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
47
63
 
48
- // Write to temp file first (atomic write)
49
- const tmpPath = `${filePath}.tmp`;
64
+ // Write to a per-writer temp file, then atomically rename into place. The
65
+ // temp name is unique (pid + random) so two concurrent writers to the same
66
+ // config never share one `.tmp` path and rename each other's file out from
67
+ // under them (which surfaced as ENOENT or a clobbered final file).
68
+ const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
50
69
  const content = JSON.stringify(withSchema(config), null, 2);
51
- await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
52
-
53
- // Rename to final path (atomic on most systems)
54
- await fs.promises.rename(tmpPath, filePath);
70
+ try {
71
+ await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
72
+ // Rename to final path (atomic on most systems)
73
+ await fs.promises.rename(tmpPath, filePath);
74
+ } catch (error) {
75
+ await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
76
+ throw error;
77
+ }
55
78
  // Invalidate the capability fs cache so subsequent reads see the new content
56
79
  invalidateFsCache(filePath);
57
80
  }
@@ -97,25 +120,26 @@ export async function addMCPServer(filePath: string, name: string, config: MCPSe
97
120
  throw new Error(`Invalid server config: ${errors.join("; ")}`);
98
121
  }
99
122
 
100
- // Read existing config
101
- const existing = await readMCPConfigFile(filePath);
123
+ // Serialize the read-modify-write under a per-file lock so a concurrent
124
+ // mutation cannot overwrite this one (lost update). The lock also guards
125
+ // against cross-process writers sharing the same config file.
126
+ await withConfigLock(filePath, async () => {
127
+ const existing = await readMCPConfigFile(filePath);
102
128
 
103
- // Check for duplicate name
104
- if (existing.mcpServers?.[name]) {
105
- throw new Error(`Server "${name}" already exists in ${filePath}`);
106
- }
107
-
108
- // Add server
109
- const updated: MCPConfigFile = {
110
- ...existing,
111
- mcpServers: {
112
- ...existing.mcpServers,
113
- [name]: config,
114
- },
115
- };
129
+ // Check for duplicate name
130
+ if (existing.mcpServers?.[name]) {
131
+ throw new Error(`Server "${name}" already exists in ${filePath}`);
132
+ }
116
133
 
117
- // Write back
118
- await writeMCPConfigFile(filePath, updated);
134
+ const updated: MCPConfigFile = {
135
+ ...existing,
136
+ mcpServers: {
137
+ ...existing.mcpServers,
138
+ [name]: config,
139
+ },
140
+ };
141
+ await writeMCPConfigFile(filePath, updated);
142
+ });
119
143
  }
120
144
 
121
145
  /**
@@ -137,20 +161,19 @@ export async function updateMCPServer(filePath: string, name: string, config: MC
137
161
  throw new Error(`Invalid server config: ${errors.join("; ")}`);
138
162
  }
139
163
 
140
- // Read existing config
141
- const existing = await readMCPConfigFile(filePath);
142
-
143
- // Update server
144
- const updated: MCPConfigFile = {
145
- ...existing,
146
- mcpServers: {
147
- ...existing.mcpServers,
148
- [name]: config,
149
- },
150
- };
151
-
152
- // Write back
153
- await writeMCPConfigFile(filePath, updated);
164
+ // Serialize the read-modify-write (see addMCPServer).
165
+ await withConfigLock(filePath, async () => {
166
+ const existing = await readMCPConfigFile(filePath);
167
+
168
+ const updated: MCPConfigFile = {
169
+ ...existing,
170
+ mcpServers: {
171
+ ...existing.mcpServers,
172
+ [name]: config,
173
+ },
174
+ };
175
+ await writeMCPConfigFile(filePath, updated);
176
+ });
154
177
  }
155
178
 
156
179
  /**
@@ -159,23 +182,21 @@ export async function updateMCPServer(filePath: string, name: string, config: MC
159
182
  * @throws Error if server doesn't exist
160
183
  */
161
184
  export async function removeMCPServer(filePath: string, name: string): Promise<void> {
162
- // Read existing config
163
- const existing = await readMCPConfigFile(filePath);
164
-
165
- // Check if server exists
166
- if (!existing.mcpServers?.[name]) {
167
- throw new Error(`Server "${name}" not found in ${filePath}`);
168
- }
185
+ // Serialize the read-modify-write (see addMCPServer).
186
+ await withConfigLock(filePath, async () => {
187
+ const existing = await readMCPConfigFile(filePath);
169
188
 
170
- // Remove server
171
- const { [name]: _removed, ...remaining } = existing.mcpServers;
172
- const updated: MCPConfigFile = {
173
- ...existing,
174
- mcpServers: remaining,
175
- };
189
+ if (!existing.mcpServers?.[name]) {
190
+ throw new Error(`Server "${name}" not found in ${filePath}`);
191
+ }
176
192
 
177
- // Write back
178
- await writeMCPConfigFile(filePath, updated);
193
+ const { [name]: _removed, ...remaining } = existing.mcpServers;
194
+ const updated: MCPConfigFile = {
195
+ ...existing,
196
+ mcpServers: remaining,
197
+ };
198
+ await writeMCPConfigFile(filePath, updated);
199
+ });
179
200
  }
180
201
 
181
202
  /**
@@ -207,25 +228,28 @@ export async function readDisabledServers(filePath: string): Promise<string[]> {
207
228
  * Add or remove a server name from the disabled servers list.
208
229
  */
209
230
  export async function setServerDisabled(filePath: string, name: string, disabled: boolean): Promise<void> {
210
- const config = await readMCPConfigFile(filePath);
211
- const current = new Set(config.disabledServers ?? []);
231
+ // Serialize the read-modify-write (see addMCPServer).
232
+ await withConfigLock(filePath, async () => {
233
+ const config = await readMCPConfigFile(filePath);
234
+ const current = new Set(config.disabledServers ?? []);
212
235
 
213
- if (disabled) {
214
- current.add(name);
215
- } else {
216
- current.delete(name);
217
- }
236
+ if (disabled) {
237
+ current.add(name);
238
+ } else {
239
+ current.delete(name);
240
+ }
218
241
 
219
- const updated: MCPConfigFile = {
220
- ...config,
221
- disabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
222
- };
242
+ const updated: MCPConfigFile = {
243
+ ...config,
244
+ disabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
245
+ };
223
246
 
224
- if (!updated.disabledServers) {
225
- delete updated.disabledServers;
226
- }
247
+ if (!updated.disabledServers) {
248
+ delete updated.disabledServers;
249
+ }
227
250
 
228
- await writeMCPConfigFile(filePath, updated);
251
+ await writeMCPConfigFile(filePath, updated);
252
+ });
229
253
  }
230
254
 
231
255
  /**
@@ -243,25 +267,28 @@ export async function readEnabledServers(filePath: string): Promise<string[]> {
243
267
  * NOT override the `disabledServers` denylist.
244
268
  */
245
269
  export async function setServerForceEnabled(filePath: string, name: string, force: boolean): Promise<void> {
246
- const config = await readMCPConfigFile(filePath);
247
- const current = new Set(config.enabledServers ?? []);
270
+ // Serialize the read-modify-write (see addMCPServer).
271
+ await withConfigLock(filePath, async () => {
272
+ const config = await readMCPConfigFile(filePath);
273
+ const current = new Set(config.enabledServers ?? []);
248
274
 
249
- if (force) {
250
- current.add(name);
251
- } else {
252
- current.delete(name);
253
- }
275
+ if (force) {
276
+ current.add(name);
277
+ } else {
278
+ current.delete(name);
279
+ }
254
280
 
255
- const updated: MCPConfigFile = {
256
- ...config,
257
- enabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
258
- };
281
+ const updated: MCPConfigFile = {
282
+ ...config,
283
+ enabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
284
+ };
259
285
 
260
- if (!updated.enabledServers) {
261
- delete updated.enabledServers;
262
- }
286
+ if (!updated.enabledServers) {
287
+ delete updated.enabledServers;
288
+ }
263
289
 
264
- await writeMCPConfigFile(filePath, updated);
290
+ await writeMCPConfigFile(filePath, updated);
291
+ });
265
292
  }
266
293
 
267
294
  /** Paths and target state for toggling one MCP server across known config files. */