@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.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 (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  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/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  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 +2 -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/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  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/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
@@ -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. */
@@ -38,6 +38,7 @@ import type { MCPToolDetails } from "./tool-bridge";
38
38
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
39
39
  import type { MCPToolCache } from "./tool-cache";
40
40
  import type {
41
+ MCPAuthChallenge,
41
42
  MCPGetPromptResult,
42
43
  MCPPrompt,
43
44
  MCPRequestOptions,
@@ -160,6 +161,9 @@ export interface MCPDiscoverOptions {
160
161
  onStatus?: (event: McpConnectionStatusEvent) => void;
161
162
  }
162
163
 
164
+ /** Handles an MCP `WWW-Authenticate` challenge and returns refreshed config. */
165
+ export type MCPAuthHandler = (serverName: string, challenge: MCPAuthChallenge) => Promise<MCPServerConfig | undefined>;
166
+
163
167
  /**
164
168
  * MCP Server Manager.
165
169
  *
@@ -189,6 +193,7 @@ export class MCPManager {
189
193
  #pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
190
194
  #sources = new Map<string, SourceMeta>();
191
195
  #authStorage: AuthStorage | null = null;
196
+ #authHandler?: MCPAuthHandler;
192
197
  #onNotification?: (serverName: string, method: string, params: unknown) => void;
193
198
  #onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
194
199
  #onResourcesChanged?: (serverName: string, uri: string) => void;
@@ -201,7 +206,7 @@ export class MCPManager {
201
206
  /** Preserved configs for reconnection after connection loss. */
202
207
  #serverConfigs = new Map<string, MCPServerConfig>();
203
208
  /**
204
- * Timestamps of recent `reconnectServer` invocations per server, used by the
209
+ * Timestamps of recent reconnectServer invocations per server, used by the
205
210
  * crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
206
211
  */
207
212
  #reconnectHistory = new Map<string, number[]>();
@@ -312,6 +317,11 @@ export class MCPManager {
312
317
  this.#authStorage = authStorage;
313
318
  }
314
319
 
320
+ /** Set the callback used to complete OAuth after a tool-level auth challenge. */
321
+ setAuthHandler(handler: MCPAuthHandler | undefined): void {
322
+ this.#authHandler = handler;
323
+ }
324
+
315
325
  /**
316
326
  * Discover and connect to all MCP servers from .mcp.json files.
317
327
  * Returns tools and any connection errors.
@@ -474,7 +484,8 @@ export class MCPManager {
474
484
  .then(async ({ connection, serverTools }) => {
475
485
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
476
486
  this.#pendingToolLoads.delete(name);
477
- const reconnect = () => this.reconnectServer(name);
487
+ const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) =>
488
+ this.reconnectServer(name, options);
478
489
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
479
490
  this.#replaceServerTools(name, customTools);
480
491
  this.#onToolsChanged?.(this.#tools);
@@ -810,13 +821,15 @@ export class MCPManager {
810
821
  * the same server share one reconnection attempt. Returns the new
811
822
  * connection, or `null` if reconnection failed or the per-server crash
812
823
  * burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
813
- *
814
824
  * @param options.manual - When `true`, resets the crash-burst window so a
815
825
  * user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
816
826
  * earlier storm. Defaults to `false`; the transport `onClose` callback
817
827
  * and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
818
828
  */
819
- async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
829
+ async reconnectServer(
830
+ name: string,
831
+ options?: { manual?: boolean; authChallenge?: MCPAuthChallenge },
832
+ ): Promise<MCPServerConnection | null> {
820
833
  if (options?.manual) {
821
834
  this.#reconnectHistory.delete(name);
822
835
  }
@@ -828,7 +841,7 @@ export class MCPManager {
828
841
  return null;
829
842
  }
830
843
 
831
- const attempt = this.#doReconnect(name);
844
+ const attempt = this.#doReconnect(name, options?.authChallenge);
832
845
  this.#pendingReconnections.set(name, attempt);
833
846
  return attempt.finally(() => this.#pendingReconnections.delete(name));
834
847
  }
@@ -873,12 +886,30 @@ export class MCPManager {
873
886
  return false;
874
887
  }
875
888
 
876
- async #doReconnect(name: string): Promise<MCPServerConnection | null> {
889
+ async #doReconnect(name: string, authChallenge?: MCPAuthChallenge): Promise<MCPServerConnection | null> {
877
890
  const oldConnection = this.#connections.get(name);
878
- const config = oldConnection?.config ?? this.#serverConfigs.get(name);
891
+ let config = oldConnection?.config ?? this.#serverConfigs.get(name);
879
892
  const source = this.#sources.get(name) ?? oldConnection?._source;
880
893
  if (!config) return null;
881
894
 
895
+ if (authChallenge) {
896
+ if (!this.#authHandler) {
897
+ logger.error("MCP auth challenge cannot be handled; no auth handler is configured", {
898
+ path: `mcp:${name}`,
899
+ });
900
+ return null;
901
+ }
902
+ try {
903
+ const refreshedConfig = await this.#authHandler(name, authChallenge);
904
+ if (!refreshedConfig) return null;
905
+ config = refreshedConfig;
906
+ this.#serverConfigs.set(name, config);
907
+ } catch (error) {
908
+ logger.error("MCP auth challenge handling failed", { path: `mcp:${name}`, error });
909
+ return null;
910
+ }
911
+ }
912
+
882
913
  logger.debug("MCP reconnecting", { path: `mcp:${name}` });
883
914
 
884
915
  // Close the old transport without removing tools or notifying consumers.
@@ -987,7 +1018,7 @@ export class MCPManager {
987
1018
  };
988
1019
  try {
989
1020
  const serverTools = await listTools(connection);
990
- const reconnect = () => this.reconnectServer(name);
1021
+ const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) => this.reconnectServer(name, options);
991
1022
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
992
1023
  void this.toolCache?.set(name, config, serverTools);
993
1024
  this.#replaceServerTools(name, customTools);
@@ -6,6 +6,10 @@
6
6
  */
7
7
  import * as AIError from "@oh-my-pi/pi-ai/error";
8
8
  import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
9
+ import { withTimeoutSignal } from "../utils/fetch-timeout";
10
+
11
+ /** Per-request abort deadline for each OAuth discovery metadata fetch. */
12
+ const DISCOVERY_FETCH_TIMEOUT_MS = 10_000;
9
13
 
10
14
  export interface OAuthEndpoints {
11
15
  authorizationUrl: string;
@@ -346,7 +350,7 @@ function readMetadataScopes(metadata: Record<string, unknown>): string | undefin
346
350
  */
347
351
  export async function fetchResourceMetadataScopes(
348
352
  resourceMetadataUrl: string,
349
- opts?: { fetch?: FetchImpl },
353
+ opts?: { fetch?: FetchImpl; signal?: AbortSignal },
350
354
  ): Promise<string | undefined> {
351
355
  const fetchImpl: FetchImpl = opts?.fetch ?? fetch;
352
356
  try {
@@ -354,6 +358,7 @@ export async function fetchResourceMetadataScopes(
354
358
  method: "GET",
355
359
  headers: { Accept: "application/json" },
356
360
  redirect: "follow",
361
+ signal: withTimeoutSignal(DISCOVERY_FETCH_TIMEOUT_MS, opts?.signal),
357
362
  });
358
363
  if (!resp.ok) return undefined;
359
364
  const meta = (await resp.json()) as Record<string, unknown>;
@@ -371,7 +376,7 @@ export async function discoverOAuthEndpoints(
371
376
  serverUrl: string,
372
377
  authServerUrl?: string,
373
378
  resourceMetadataUrl?: string,
374
- opts?: { fetch?: FetchImpl; protectedResource?: string; protectedScopes?: string },
379
+ opts?: { fetch?: FetchImpl; protectedResource?: string; protectedScopes?: string; signal?: AbortSignal },
375
380
  ): Promise<OAuthEndpoints | null> {
376
381
  const fetchImpl: FetchImpl = opts?.fetch ?? fetch;
377
382
  const wellKnownPaths = [
@@ -402,6 +407,7 @@ export async function discoverOAuthEndpoints(
402
407
  method: "GET",
403
408
  headers: { Accept: "application/json" },
404
409
  redirect: "follow",
410
+ signal: withTimeoutSignal(DISCOVERY_FETCH_TIMEOUT_MS, opts?.signal),
405
411
  });
406
412
  if (metaResp.ok) {
407
413
  const meta = (await metaResp.json()) as Record<string, unknown>;
@@ -486,6 +492,7 @@ export async function discoverOAuthEndpoints(
486
492
  method: "GET",
487
493
  headers: { Accept: "application/json" },
488
494
  redirect: "follow",
495
+ signal: withTimeoutSignal(DISCOVERY_FETCH_TIMEOUT_MS, opts?.signal),
489
496
  });
490
497
 
491
498
  if (response.ok) {
@@ -521,6 +528,7 @@ export async function discoverOAuthEndpoints(
521
528
  fetch: fetchImpl,
522
529
  protectedResource: discoveredProtectedResource,
523
530
  protectedScopes: readMetadataScopes(metadata) ?? protectedScopes,
531
+ signal: opts?.signal,
524
532
  });
525
533
  if (discovered) return discovered;
526
534
  }
package/src/mcp/render.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * Provides structured display of MCP tool calls and results,
5
5
  * showing args and output in JSON tree format similar to task tool.
6
6
  */
7
- import type { Component } from "@oh-my-pi/pi-tui";
7
+ import { type Component, Markdown } from "@oh-my-pi/pi-tui";
8
+ import { settings } from "../config/settings";
8
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import type { Theme } from "../modes/theme/theme";
10
+ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
10
11
  import {
11
12
  formatArgsInline,
12
13
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -48,6 +49,61 @@ export function renderMCPCall(args: Record<string, unknown>, theme: Theme, label
48
49
  );
49
50
  }
50
51
 
52
+ /** Render an MCP status/args prefix followed by Markdown-aware text output. */
53
+ function renderMarkdownMCPResult(
54
+ result: { details?: MCPToolDetails; isError?: boolean },
55
+ trimmedOutput: string,
56
+ truncationWarning: string | null,
57
+ options: RenderResultOptions,
58
+ theme: Theme,
59
+ args?: Record<string, unknown>,
60
+ ): Component {
61
+ const markdown = new Markdown(trimmedOutput, 0, 0, getMarkdownTheme(), {
62
+ color: text => theme.fg("toolOutput", text),
63
+ });
64
+ return {
65
+ render(contentWidth: number): readonly string[] {
66
+ const lines: string[] = [];
67
+ const isError = result.isError ?? result.details?.isError ?? false;
68
+ const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
69
+ lines.push(
70
+ renderStatusLine(
71
+ isError ? { icon: "error", title } : { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title },
72
+ theme,
73
+ ),
74
+ );
75
+
76
+ if (options.expanded && args && Object.keys(args).length > 0) {
77
+ lines.push(theme.fg("dim", "Args"));
78
+ const tree = renderJsonTreeLines(
79
+ args,
80
+ theme,
81
+ JSON_TREE_MAX_DEPTH_EXPANDED,
82
+ JSON_TREE_MAX_LINES_EXPANDED,
83
+ JSON_TREE_SCALAR_LEN_EXPANDED,
84
+ );
85
+ lines.push(...tree.lines);
86
+ if (tree.truncated) lines.push(theme.fg("dim", "…"));
87
+ lines.push("");
88
+ }
89
+
90
+ const rendered = markdown.render(Math.max(1, contentWidth));
91
+ const maxOutputLines = options.expanded ? 12 : 4;
92
+ lines.push(...rendered.slice(0, maxOutputLines));
93
+ if (rendered.length > maxOutputLines) {
94
+ lines.push(
95
+ `${theme.fg("dim", `… ${rendered.length - maxOutputLines} more lines`)} ${formatExpandHint(theme, options.expanded, true)}`,
96
+ );
97
+ } else if (!options.expanded) {
98
+ lines.push(formatExpandHint(theme, options.expanded, true));
99
+ }
100
+ if (truncationWarning) lines.push(truncationWarning);
101
+ return lines;
102
+ },
103
+ invalidate(): void {},
104
+ };
105
+ }
106
+
51
107
  /**
52
108
  * Render MCP tool result.
53
109
  */
@@ -58,6 +114,24 @@ export function renderMCPResult(
58
114
  args?: Record<string, unknown>,
59
115
  ): Component {
60
116
  const { expanded } = options;
117
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
118
+ const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
119
+ const truncationWarning = result.details?.meta?.truncation
120
+ ? formatStyledTruncationWarning(result.details.meta, theme)
121
+ : null;
122
+ let parsedOutput: unknown;
123
+ let isJsonOutput = false;
124
+ if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
125
+ try {
126
+ parsedOutput = JSON.parse(trimmedOutput);
127
+ isJsonOutput = true;
128
+ } catch {
129
+ // Non-JSON text beginning with a bracket is still eligible for Markdown.
130
+ }
131
+ }
132
+ if (trimmedOutput && settings.get("mcp.renderMarkdownResults") && !isJsonOutput) {
133
+ return renderMarkdownMCPResult(result, trimmedOutput, truncationWarning, options, theme, args);
134
+ }
61
135
  return new WidthAwareText(
62
136
  contentWidth => {
63
137
  const lines: string[] = [];
@@ -86,46 +160,31 @@ export function renderMCPResult(
86
160
  lines.push(""); // Blank line before output
87
161
  }
88
162
 
89
- // Output section
90
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
91
- // Strip the LLM-facing spill notice before parsing/rendering: a spilled
92
- // result appends `[Showing… artifact://N]` to the body, which would break
93
- // JSON detection and bury the recovery link. Surface it as a styled warning
94
- // instead, mirroring the built-in read/bash/ssh/browser renderers.
95
- const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
96
- const truncationWarning = result.details?.meta?.truncation
97
- ? formatStyledTruncationWarning(result.details.meta, theme)
98
- : null;
163
+ // Output section. The body and spill metadata are normalized before
164
+ // component selection so the opt-in Markdown path can use its own renderer.
99
165
 
100
166
  if (!trimmedOutput) {
101
167
  lines.push(theme.fg("dim", "(no output)"));
102
168
  return lines.join("\n");
103
169
  }
104
170
 
105
- // Try to parse as JSON for structured display
106
- if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
107
- try {
108
- const parsed = JSON.parse(trimmedOutput);
109
- const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
110
- const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
111
- const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
112
- const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
113
-
114
- if (tree.lines.length > 0) {
115
- for (const line of tree.lines) {
116
- lines.push(line);
117
- }
118
- // Always show expand hint when collapsed (expanded view shows longer values and deeper nesting)
119
- if (!expanded) {
120
- lines.push(formatExpandHint(theme, expanded, true));
121
- } else if (tree.truncated) {
122
- lines.push(theme.fg("dim", "…"));
123
- }
124
- if (truncationWarning) lines.push(truncationWarning);
125
- return lines.join("\n");
171
+ // Preserve the existing structured JSON renderer regardless of the
172
+ // Markdown preference; JSON trees remain more useful than styled source.
173
+ if (isJsonOutput) {
174
+ const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
175
+ const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
176
+ const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
177
+ const tree = renderJsonTreeLines(parsedOutput, theme, maxDepth, maxLines, maxScalarLen);
178
+
179
+ if (tree.lines.length > 0) {
180
+ lines.push(...tree.lines);
181
+ if (!expanded) {
182
+ lines.push(formatExpandHint(theme, expanded, true));
183
+ } else if (tree.truncated) {
184
+ lines.push(theme.fg("dim", "…"));
126
185
  }
127
- } catch {
128
- // Fall through to raw output
186
+ if (truncationWarning) lines.push(truncationWarning);
187
+ return lines.join("\n");
129
188
  }
130
189
  }
131
190
 
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import { pollSmitheryCliAuthSession } from "./smithery-auth";
3
+
4
+ type FetchInput = string | URL | Request;
5
+ type FetchInit = RequestInit | BunFetchRequestInit;
6
+
7
+ describe("pollSmitheryCliAuthSession fetch cancellation", () => {
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("bounds each poll request with a timeout abort signal even without a caller signal", async () => {
13
+ const signals: AbortSignal[] = [];
14
+ const fetchStub = Object.assign(
15
+ async (_input: FetchInput, init?: FetchInit) => {
16
+ if (init?.signal instanceof AbortSignal) signals.push(init.signal);
17
+ return Response.json({ status: "pending" });
18
+ },
19
+ { preconnect: globalThis.fetch.preconnect },
20
+ );
21
+ vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
22
+
23
+ const result = await pollSmitheryCliAuthSession("sess-123");
24
+
25
+ expect(result.status).toBe("pending");
26
+ expect(signals).toHaveLength(1);
27
+ expect(signals[0]?.aborted).toBe(false);
28
+ });
29
+ });
@@ -7,13 +7,14 @@ import { withTimeoutSignal } from "../utils/fetch-timeout";
7
7
  const SMITHERY_AUTH_FILENAME = "smithery.json";
8
8
  const SMITHERY_URL = process.env.SMITHERY_URL || "https://smithery.ai";
9
9
  const SMITHERY_AUTH_TIMEOUT_MS = 10_000;
10
+ const SMITHERY_POLL_TIMEOUT_MS = 30_000;
10
11
 
11
12
  type SmitheryCliAuthSession = {
12
13
  sessionId: string;
13
14
  authUrl: string;
14
15
  };
15
16
 
16
- type SmitheryCliPollResponse = {
17
+ export type SmitheryCliPollResponse = {
17
18
  status: "pending" | "success" | "error";
18
19
  apiKey?: string;
19
20
  message?: string;
@@ -53,7 +54,7 @@ export async function pollSmitheryCliAuthSession(
53
54
  signal?: AbortSignal,
54
55
  ): Promise<SmitheryCliPollResponse> {
55
56
  const response = await fetch(`${SMITHERY_URL}/api/auth/cli/poll/${sessionId}`, {
56
- signal,
57
+ signal: withTimeoutSignal(SMITHERY_POLL_TIMEOUT_MS, signal),
57
58
  });
58
59
  if (!response.ok) {
59
60
  if (response.status === 404 || response.status === 410) {