mulmoterminal 1.5.0 → 1.6.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 (211) hide show
  1. package/README.md +30 -3
  2. package/bin/claude-ollama.js +183 -0
  3. package/bin/cli-args.d.ts +11 -0
  4. package/bin/cli-args.js +135 -0
  5. package/bin/mulmoterminal.js +71 -88
  6. package/bin/ollama-launch.d.ts +13 -0
  7. package/bin/ollama-launch.js +60 -0
  8. package/bin/update-check.d.ts +21 -0
  9. package/bin/update-check.js +164 -2
  10. package/common/median.ts +13 -0
  11. package/common/modelIds.ts +28 -0
  12. package/common/modelPresets.ts +313 -0
  13. package/dist/assets/{abnfDiagram-VRR7QNED-6nNByj6v-BmTAK_lf.js → abnfDiagram-VRR7QNED-6nNByj6v-w5RLkQ22.js} +1 -1
  14. package/dist/assets/architecture-TIHT7OUA-CYMWc3UT-BUaZ0MSo.js +1 -0
  15. package/dist/assets/{architectureDiagram-ZJ3FMSHR-Bgnyaj_i-Rm4oPVV8.js → architectureDiagram-ZJ3FMSHR-Bgnyaj_i-D69YcDkW.js} +1 -1
  16. package/dist/assets/{blockDiagram-677ZJIJ3-DQ35o5E4-CpSusKj_.js → blockDiagram-677ZJIJ3-DQ35o5E4-7uQXieKy.js} +1 -1
  17. package/dist/assets/{c4Diagram-LMCZKHZV-ClWZeiWo-CDPWcIZ7.js → c4Diagram-LMCZKHZV-ClWZeiWo-EXpL5q8c.js} +1 -1
  18. package/dist/assets/channel-Di5rtkx0-C_m9yiey.js +1 -0
  19. package/dist/assets/{chunk-32BRIVSS-CCt9wtYd-BNsLemkR.js → chunk-32BRIVSS-CCt9wtYd-BBNy9Wsw.js} +1 -1
  20. package/dist/assets/{chunk-52WLFC77-FbBbR4uI-hDAV5e1Z.js → chunk-52WLFC77-FbBbR4uI-nrizj9KQ.js} +1 -1
  21. package/dist/assets/{chunk-C7G6YPKG-C87hlS9c-DfzZj0PL.js → chunk-C7G6YPKG-C87hlS9c-BeUrCw4a.js} +1 -1
  22. package/dist/assets/{chunk-EX3LRPZG-BqGqMXLN-uoc2Ztzj.js → chunk-EX3LRPZG-BqGqMXLN-B6S4Nm2k.js} +1 -1
  23. package/dist/assets/{chunk-FWX5IMBZ-BkbSAAuW-B397K7iE.js → chunk-FWX5IMBZ-BkbSAAuW-DyThgqZX.js} +2 -2
  24. package/dist/assets/{chunk-HOUHSVGY-Cxu0eDlh-BgHFcMoQ.js → chunk-HOUHSVGY-Cxu0eDlh-D5RjKEII.js} +1 -1
  25. package/dist/assets/{chunk-ICXQ74PX-hiraF_Xj-BJT-y8zi.js → chunk-ICXQ74PX-hiraF_Xj-JuRpIiaL.js} +1 -1
  26. package/dist/assets/{chunk-MOJQB5TN-CqxshQHA-rfYFgPHl.js → chunk-MOJQB5TN-CqxshQHA-D3AD-OQe.js} +1 -1
  27. package/dist/assets/{chunk-OGEWGWER-CjCr7ceX-DuZ8UI47.js → chunk-OGEWGWER-CjCr7ceX-CqjmtDlt.js} +1 -1
  28. package/dist/assets/{chunk-PUDLZKDR-Dx6M-vz1-C20tVRvs.js → chunk-PUDLZKDR-Dx6M-vz1-DnwunR7_.js} +1 -1
  29. package/dist/assets/{chunk-Q4XR5HBZ-CZd-9lTB-BFkl1myD.js → chunk-Q4XR5HBZ-CZd-9lTB-CTRA-UzE.js} +1 -1
  30. package/dist/assets/{chunk-V7JOEXUC-rI0xlC_O-C2QrFL3o.js → chunk-V7JOEXUC-rI0xlC_O-yFVHoSz8.js} +1 -1
  31. package/dist/assets/{chunk-VAUOI2AC-DrcykVNK-BSJoRdEM.js → chunk-VAUOI2AC-DrcykVNK-Bu5JrdaI.js} +1 -1
  32. package/dist/assets/{chunk-VR4S4FIN-O6iF8Yvf-CL4V93u8.js → chunk-VR4S4FIN-O6iF8Yvf-bQ7KsnFU.js} +1 -1
  33. package/dist/assets/{chunk-WYO6CB5R-B83L_z6I-ybwRHaqV.js → chunk-WYO6CB5R-B83L_z6I-Db0d_mXD.js} +1 -1
  34. package/dist/assets/{chunk-ZGVPDNZ5-BpFv9JSP-DQGEhX3k.js → chunk-ZGVPDNZ5-BpFv9JSP-D6iQstYB.js} +1 -1
  35. package/dist/assets/classDiagram-OUVF2IWQ-BgAZMSbT-DEFx7H3k.js +1 -0
  36. package/dist/assets/classDiagram-v2-EOCWNBFH-DxHTyui1-DEFx7H3k.js +1 -0
  37. package/dist/assets/cynefin-VYW2F7L2-DqA3n9nY-BCw_wwOv.js +1 -0
  38. package/dist/assets/{cynefinDiagram-TSTJHNR4-x0-0sQ15-B3DfOJaL.js → cynefinDiagram-TSTJHNR4-x0-0sQ15-DX3rOamM.js} +1 -1
  39. package/dist/assets/{dagre-VKFMJZFB-CQdfl-bx-Bf4AA431.js → dagre-VKFMJZFB-CQdfl-bx-CMvy6K78.js} +1 -1
  40. package/dist/assets/{diagram-FQU43EPY-BOSB6VUb-BO7S37GV.js → diagram-FQU43EPY-BOSB6VUb-7Cloywn1.js} +1 -1
  41. package/dist/assets/{diagram-G47NLZAW-DLXrcXsN-Cw4lBqWi.js → diagram-G47NLZAW-DLXrcXsN-BTVMn6-x.js} +1 -1
  42. package/dist/assets/{diagram-NH7WQ7WH-BMQp1rkF-B4OObXRC.js → diagram-NH7WQ7WH-BMQp1rkF-YaK2dWz0.js} +1 -1
  43. package/dist/assets/{diagram-OA4YK3LP-D1wQ0vUj-CbSsGWhY.js → diagram-OA4YK3LP-D1wQ0vUj-D3A1N794.js} +1 -1
  44. package/dist/assets/{diagram-WEI45ONY-RR0DpF8R-CkVYiUn6.js → diagram-WEI45ONY-RR0DpF8R-mQyI-Ztc.js} +1 -1
  45. package/dist/assets/{ebnfDiagram-CCIWWBDH-M123uVJ8-BIFkvee9.js → ebnfDiagram-CCIWWBDH-M123uVJ8-6QHlDAzo.js} +1 -1
  46. package/dist/assets/{erDiagram-Q63AITRT-BWx_-PXG-DhFy59tl.js → erDiagram-Q63AITRT-BWx_-PXG-Dm9LOTEX.js} +1 -1
  47. package/dist/assets/eventmodeling-45OFAUF4-_BVSjAXf-CAJF7-9m.js +1 -0
  48. package/dist/assets/flowDiagram-23GEKE2U-BeOc_anm-CLQEzYDX.js +1 -0
  49. package/dist/assets/{ganttDiagram-NO4QXBWP-BOoJ1eTw-Cy64oRyD.js → ganttDiagram-NO4QXBWP-BOoJ1eTw-DF8P-WOj.js} +1 -1
  50. package/dist/assets/gitGraph-TEB2WS4Q-CH12KLTN-CuWNblKk.js +1 -0
  51. package/dist/assets/{gitGraphDiagram-IHSO6WYX-B2CJhk_G-Do02StAh.js → gitGraphDiagram-IHSO6WYX-B2CJhk_G-C1VDCiQA.js} +1 -1
  52. package/dist/assets/index-BFfKUWpL.js +617 -0
  53. package/dist/assets/index-aQgV74tC.css +1 -0
  54. package/dist/assets/info-DKCQHKI2-Cbw3mbiK-rc7lkPBD.js +1 -0
  55. package/dist/assets/{infoDiagram-FWYZ7A6U-Mp1X3pBP-CTODYevC.js → infoDiagram-FWYZ7A6U-Mp1X3pBP-BPoQawfx.js} +1 -1
  56. package/dist/assets/{ishikawaDiagram-FXEZZL3T-BNG7tkJu-DWG68Fod.js → ishikawaDiagram-FXEZZL3T-BNG7tkJu-lE_jc2TT.js} +1 -1
  57. package/dist/assets/{journeyDiagram-5HDEW3XC-Dbp_hY9X-C21LZ7jk.js → journeyDiagram-5HDEW3XC-Dbp_hY9X-CO0R6cU-.js} +1 -1
  58. package/dist/assets/{kanban-definition-HUTT4EX6-DSTc5u3q-ahPtq3pf.js → kanban-definition-HUTT4EX6-DSTc5u3q-CxGAv9XA.js} +1 -1
  59. package/dist/assets/{lib-Df1jlz1Q.js → lib-DRGyh9Q-.js} +1 -1
  60. package/dist/assets/{line-B1wBwzrY-DAl65sYb.js → line-B1wBwzrY-CRPsJOzA.js} +1 -1
  61. package/dist/assets/{marp-D5H57UyA.js → marp--aqCYout.js} +1 -1
  62. package/dist/assets/{mermaid-parser.core-DC7NPJ_M-QXgmJCuN.js → mermaid-parser.core-DC7NPJ_M-4_BR6vAg.js} +2 -2
  63. package/dist/assets/{mermaid.core-DZM3Ha-E-B1G-85N2.js → mermaid.core-DZM3Ha-E-QA2iKs10.js} +3 -3
  64. package/dist/assets/{mindmap-definition-LN4V7U3C-DYtgcMsY-CBnOdPk8.js → mindmap-definition-LN4V7U3C-DYtgcMsY-dUsG7ytl.js} +1 -1
  65. package/dist/assets/packet-7NZHBO7P-lwb58iYx-BRjtDgmF.js +1 -0
  66. package/dist/assets/{pegDiagram-2B236MQR-C43eIpKM-hEGXt4rK.js → pegDiagram-2B236MQR-C43eIpKM-C0qYy0kY.js} +1 -1
  67. package/dist/assets/pie-RZYD4A2V-B1UWb4Gu-DjuYUfZC.js +1 -0
  68. package/dist/assets/{pieDiagram-ENE6RG2P-BkTqgJyR-D84c_7gF.js → pieDiagram-ENE6RG2P-BkTqgJyR-p9ruWXka.js} +1 -1
  69. package/dist/assets/{quadrantDiagram-ABIIQ3AL-Bm1Zjm45-m6ws2UZn.js → quadrantDiagram-ABIIQ3AL-Bm1Zjm45-D6JMmK1K.js} +1 -1
  70. package/dist/assets/radar-I7S5WNFK-7CKcb_l--BSS9MxXL.js +1 -0
  71. package/dist/assets/railroad-3IZDKUUU-gCySKdnW-hpLOScUf.js +1 -0
  72. package/dist/assets/railroad-abnf-AHOZXSZD-BophH4r--D67cs0T8.js +1 -0
  73. package/dist/assets/railroad-ebnf-EBAXGLYW-AZNjl_Zu-D64IlM4-.js +1 -0
  74. package/dist/assets/railroad-peg-LSFZ7HO6-BSiEEyeb-Da5GoZb9.js +1 -0
  75. package/dist/assets/{railroadDiagram-RFXS5EU6-BkfbdeAs-BN3y7GqL.js → railroadDiagram-RFXS5EU6-BkfbdeAs-DKGrmtAb.js} +1 -1
  76. package/dist/assets/{requirementDiagram-TGXJPOKE-CrGTTjYg-B0zoTBgF.js → requirementDiagram-TGXJPOKE-CrGTTjYg-_rK4hExQ.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-HTMAVEWB-rWXPf03Z-XgG2MUCe.js → sankeyDiagram-HTMAVEWB-rWXPf03Z-CtaFLD0A.js} +1 -1
  78. package/dist/assets/{sequenceDiagram-DBY2YBRQ-nkJYWO2m-BxK5UjRj.js → sequenceDiagram-DBY2YBRQ-nkJYWO2m-Cfwgbl-y.js} +1 -1
  79. package/dist/assets/{stateDiagram-2N3HPSRC-T4-clK8b-Y_DtKuon.js → stateDiagram-2N3HPSRC-T4-clK8b-DHGQRUOm.js} +1 -1
  80. package/dist/assets/stateDiagram-v2-6OUMAXLB-DIp7nhRd-Ud1cpdGy.js +1 -0
  81. package/dist/assets/{swimlanes-5IMT3BWC-HmQNEntu-D5dAJGJF.js → swimlanes-5IMT3BWC-HmQNEntu-D869r1NM.js} +1 -1
  82. package/dist/assets/swimlanesDiagram-G3AALYLV-BeoZhwg7-DUGuyc0H.js +8 -0
  83. package/dist/assets/{timeline-definition-FHXFAJF6-CNc9jSTP-CRCAKMND.js → timeline-definition-FHXFAJF6-CNc9jSTP-BeazEwi2.js} +1 -1
  84. package/dist/assets/treeView-QDETBFTQ-nIQcG1h9-u70as5aK.js +1 -0
  85. package/dist/assets/treemap-6X3UGDF4-BnsvC8yL-CkNsgl3O.js +1 -0
  86. package/dist/assets/{vennDiagram-L72KCM5P-Dr3pTJ_0-DIFYkt_z.js → vennDiagram-L72KCM5P-Dr3pTJ_0-CMOqalF2.js} +1 -1
  87. package/dist/assets/wardley-OPB4EBWU-8Odxkx6V-DsDgGaTC.js +1 -0
  88. package/dist/assets/{wardleyDiagram-EHGQE667-DSFc7ZZa-BL_kqA2P.js → wardleyDiagram-EHGQE667-DSFc7ZZa-CEPlNlLn.js} +1 -1
  89. package/dist/assets/{xychartDiagram-FW5EYKEG-BP0Nn4Pp-q75mTWUr.js → xychartDiagram-FW5EYKEG-BP0Nn4Pp-Bvdb_Uk8.js} +1 -1
  90. package/dist/index.html +2 -2
  91. package/package.json +3 -2
  92. package/server/agents/claude-args.ts +5 -0
  93. package/server/agents/codex-activity.ts +70 -0
  94. package/server/agents/codex-resume.ts +26 -0
  95. package/server/backends/audioAdmission.ts +62 -0
  96. package/server/backends/byte-range.ts +40 -0
  97. package/server/backends/collectionNotifierAdapter.ts +57 -0
  98. package/server/backends/collectionWatchers.ts +9 -49
  99. package/server/backends/collections.ts +22 -18
  100. package/server/backends/docPath.ts +53 -0
  101. package/server/backends/feed-summary.ts +27 -0
  102. package/server/backends/feeds.ts +2 -9
  103. package/server/backends/fileChange.ts +5 -8
  104. package/server/backends/files.ts +10 -52
  105. package/server/backends/image-gen.ts +2 -18
  106. package/server/backends/imageResult.ts +28 -0
  107. package/server/backends/markdown.ts +2 -30
  108. package/server/backends/mutateStatus.ts +28 -0
  109. package/server/backends/rawServingPlan.ts +56 -0
  110. package/server/backends/remoteHost/handlers.ts +2 -9
  111. package/server/backends/remoteHost/index.ts +5 -12
  112. package/server/backends/remoteHost/onExpire.ts +2 -16
  113. package/server/backends/remoteHost/stagedStorageIds.spec.ts +38 -0
  114. package/server/backends/remoteHost/stagedStorageIds.ts +16 -0
  115. package/server/backends/remoteView.ts +17 -3
  116. package/server/backends/whisper.ts +6 -53
  117. package/server/config/app-config.ts +29 -1
  118. package/server/config/config-body.ts +27 -0
  119. package/server/config/config-routes.ts +24 -20
  120. package/server/config/config-schema.ts +38 -0
  121. package/server/config/dir-config.ts +9 -0
  122. package/server/config/launch-options.ts +45 -0
  123. package/server/config/update-status.ts +38 -0
  124. package/server/git/branch-query.ts +29 -0
  125. package/server/git/git-parse.ts +34 -0
  126. package/server/git/pr-for-branch.ts +3 -11
  127. package/server/git/prPhase.ts +3 -10
  128. package/server/git/worktree-diff.ts +3 -4
  129. package/server/git/worktree-pr.ts +2 -10
  130. package/server/index.ts +88 -883
  131. package/server/infra/allowed-origin.ts +24 -0
  132. package/server/infra/plugins-registry.ts +20 -9
  133. package/server/infra/server-exit.ts +30 -0
  134. package/server/infra/server-tool-load.ts +29 -0
  135. package/server/infra/tmux.ts +21 -1
  136. package/server/infra/tool-precedence.ts +43 -0
  137. package/server/mcp/broker.ts +18 -49
  138. package/server/mcp/tool-envelope.ts +38 -0
  139. package/server/mcp/tool-gate.ts +63 -0
  140. package/server/routes/app-routes.ts +280 -0
  141. package/server/routes/dir-routes.ts +2 -1
  142. package/server/routes/hook-routes.ts +157 -0
  143. package/server/routes/mcp-routes.ts +54 -0
  144. package/server/routes/plugin-narration.ts +18 -0
  145. package/server/routes/plugin-routes.ts +103 -0
  146. package/server/routes/routeParams.ts +19 -0
  147. package/server/routes/session-routes.ts +15 -22
  148. package/server/routes/terminal-ws-path.ts +25 -0
  149. package/server/routes/tool-routes.ts +8 -27
  150. package/server/routes/toolResultPlan.ts +39 -0
  151. package/server/routes/ws-routes.ts +47 -45
  152. package/server/session/activity-flag.ts +31 -0
  153. package/server/session/activity-hook.ts +18 -1
  154. package/server/session/activity-state.ts +17 -0
  155. package/server/session/activity-transition.ts +64 -0
  156. package/server/session/background-chat.ts +47 -0
  157. package/server/session/codex-activity-track.ts +69 -0
  158. package/server/session/codex-activity-watch.ts +51 -0
  159. package/server/session/dev-terminal-sessions.ts +54 -0
  160. package/server/session/handoff-text.ts +17 -1
  161. package/server/session/header-hook.ts +38 -0
  162. package/server/session/hiddenMarker.ts +15 -0
  163. package/server/session/hook-settings.ts +57 -0
  164. package/server/session/launch-choice.ts +71 -0
  165. package/server/session/lifecycle.ts +200 -0
  166. package/server/session/mcp-config.ts +36 -0
  167. package/server/session/partitionPending.ts +39 -0
  168. package/server/session/provider-env.ts +147 -0
  169. package/server/session/pty-spawn.ts +21 -6
  170. package/server/session/reap-policy.ts +36 -0
  171. package/server/session/registry.ts +60 -19
  172. package/server/session/resumable-sessions.ts +18 -0
  173. package/server/session/scheduled-sessions.ts +9 -0
  174. package/server/session/session-detail-view.ts +54 -0
  175. package/server/session/session-reads.ts +28 -21
  176. package/server/session/session-settings.ts +45 -0
  177. package/server/session/session-title.ts +103 -0
  178. package/server/session/sessionListTitle.ts +22 -0
  179. package/server/session/spawn-claude.ts +55 -23
  180. package/server/session/spawn-codex.ts +19 -2
  181. package/server/session/spawn-deps.ts +7 -2
  182. package/server/session/spawners.ts +15 -0
  183. package/server/session/task-push.ts +54 -0
  184. package/server/session/taskPushRules.ts +33 -0
  185. package/server/session/tool-hook.ts +59 -0
  186. package/server/session/tool-store.ts +63 -15
  187. package/server/session/translation-submit.ts +27 -0
  188. package/server/skills/mulmoterminal-config/SKILL.md +58 -2
  189. package/dist/assets/architecture-TIHT7OUA-CYMWc3UT-ChEc_7wP.js +0 -1
  190. package/dist/assets/channel-Di5rtkx0-CW5bKrWi.js +0 -1
  191. package/dist/assets/classDiagram-OUVF2IWQ-BgAZMSbT-BJBeI7ut.js +0 -1
  192. package/dist/assets/classDiagram-v2-EOCWNBFH-DxHTyui1-BJBeI7ut.js +0 -1
  193. package/dist/assets/cynefin-VYW2F7L2-DqA3n9nY-CHrZLRBv.js +0 -1
  194. package/dist/assets/eventmodeling-45OFAUF4-_BVSjAXf-Dows47_h.js +0 -1
  195. package/dist/assets/flowDiagram-23GEKE2U-BeOc_anm-D8EOvOHn.js +0 -1
  196. package/dist/assets/gitGraph-TEB2WS4Q-CH12KLTN-D0iLvwj3.js +0 -1
  197. package/dist/assets/index-CkheN-4M.css +0 -1
  198. package/dist/assets/index-D7iecJBA.js +0 -607
  199. package/dist/assets/info-DKCQHKI2-Cbw3mbiK-BnqrlbY_.js +0 -1
  200. package/dist/assets/packet-7NZHBO7P-lwb58iYx-vktMe7EJ.js +0 -1
  201. package/dist/assets/pie-RZYD4A2V-B1UWb4Gu-DQdd8O0g.js +0 -1
  202. package/dist/assets/radar-I7S5WNFK-7CKcb_l--C24Fc6mu.js +0 -1
  203. package/dist/assets/railroad-3IZDKUUU-gCySKdnW-CZSGevii.js +0 -1
  204. package/dist/assets/railroad-abnf-AHOZXSZD-BophH4r--CUsXkbxZ.js +0 -1
  205. package/dist/assets/railroad-ebnf-EBAXGLYW-AZNjl_Zu-CvkVfCff.js +0 -1
  206. package/dist/assets/railroad-peg-LSFZ7HO6-BSiEEyeb-DVG8HAZ0.js +0 -1
  207. package/dist/assets/stateDiagram-v2-6OUMAXLB-DIp7nhRd-Cehiuymv.js +0 -1
  208. package/dist/assets/swimlanesDiagram-G3AALYLV-BeoZhwg7-CoSHy1jT.js +0 -8
  209. package/dist/assets/treeView-QDETBFTQ-nIQcG1h9-CWUjubva.js +0 -1
  210. package/dist/assets/treemap-6X3UGDF4-BnsvC8yL-BKgXJcXq.js +0 -1
  211. package/dist/assets/wardley-OPB4EBWU-8Odxkx6V-BJhsIgOJ.js +0 -1
package/README.md CHANGED
@@ -94,6 +94,19 @@ listener and needs a browser **on the host**. Either way it needs a Desktop OAut
94
94
  as `~/.secrets/client_secret_*.json`; the refresh token lands in `~/.config/mulmo/google-token.json`
95
95
  and is **shared with MulmoClaude**, so one link per machine covers both apps.
96
96
 
97
+ **Local models (optional).** The package also ships `claude-ollama` — a one-command launcher that
98
+ runs Claude Code **fully locally against an [Ollama](https://ollama.com) model** (no cloud, no API
99
+ key). It starts a large-context Ollama server and launches `claude` with a minimal system prompt so
100
+ small models aren't drowned:
101
+
102
+ ```bash
103
+ ollama pull qwen3:4b
104
+ npx -p mulmoterminal claude-ollama qwen3:4b # or, if installed globally: claude-ollama qwen3:4b
105
+ ```
106
+
107
+ See [Local models with claude-ollama](https://receptron.github.io/mulmoterminal/guide/en/claude-ollama.html)
108
+ for the details and model notes.
109
+
97
110
  > **Already linked before the calendar-list / colour features?** They need a read scope your existing
98
111
  > link doesn't have, so `listCalendars` (and, in practice, `colors`) fail with an insufficient-scope
99
112
  > 403 until you re-authorize: **Settings → Google account → Unlink**, then sign in again (or re-run
@@ -225,6 +238,17 @@ today — **Claude Code** (the default) and **Codex**.
225
238
  cell's launch form and the Collections browser carry a **Claude / Codex** toggle (your
226
239
  choice is remembered).
227
240
 
241
+ **Other models.**
242
+ Claude Code can run against any **Anthropic-compatible** backend (OpenRouter, Moonshot, a
243
+ LiteLLM gateway). Backends are listed in `~/.mulmoterminal/config.json` under `providers`,
244
+ and their **keys are read from the server's environment** — never from a file the app
245
+ serves. A directory sets its default in `.mulmoterminal.json` (`provider` / `model`), and
246
+ each grid cell's launch form has a **MODEL** select that overrides it for one session,
247
+ listing ~27 curated models with the measured pass rate of a real tool-using task beside
248
+ each. A provider whose token can't be resolved **refuses to start** rather than falling
249
+ back to Anthropic, and providers can't be combined with the Docker sandbox. Full walkthrough — setup, the measured model list, adding your own models, troubleshooting:
250
+ [Using another model via OpenRouter](https://receptron.github.io/mulmoterminal/guide/en/providers.html).
251
+
228
252
  **Skills for Codex.** Codex has no `/<slug>` slash commands, so on session setup
229
253
  MulmoTerminal **mirrors the workspace's `.claude/skills` into `~/.codex/skills`** (each
230
254
  mirrored directory carries a `.mt-mirror` marker so a re-sync overwrites what MulmoTerminal
@@ -668,8 +692,10 @@ Each grid cell's header shows two badges for its session, refreshed when a turn
668
692
 
669
693
  - **Context badge** — e.g. `Opus · ctx 35%`: the model family plus how full its context
670
694
  window is (the *last* turn's input + cache tokens ÷ the model's window — **1M** for
671
- current-gen Opus / Sonnet / Fable, **200k** otherwise; an unknown model shows the label
672
- with no %).
695
+ current-gen Opus / Sonnet / Fable, **200k** otherwise). A session running on a
696
+ [provider model](#agents-claude--codex) shows that model's name and its published window
697
+ (`Kimi K2.7 Code · ctx 12%`); a model in neither list keeps the label and hides the %,
698
+ since the window is never guessed.
673
699
  - **Token badge** — `⇡<in> ⇣<out>`: cumulative input (fresh + cache-read + cache-creation)
674
700
  and output tokens for the session, k/M-formatted, with a full breakdown in the tooltip.
675
701
 
@@ -938,8 +964,9 @@ same-origin-guarded.
938
964
 
939
965
  | Endpoint | Purpose |
940
966
  | -------- | ------- |
941
- | `GET\|POST /api/config` | User UI config (`cwdPresets`, `soundFile`, `prRepos`, `launchers`, `userMcpServers`). |
967
+ | `GET\|POST /api/config` | User UI config (`cwdPresets`, `soundFile`, `prRepos`, `launchers`, `userMcpServers`, `providers`). |
942
968
  | `GET /api/sound` · `/api/dir-sound?cwd=` · `/api/dir-config?cwd=` | Custom / per-directory attention sound + per-dir config. |
969
+ | `GET /api/launch-options` | The Anthropic-compatible backends this server can reach, each with its models and — when it can't — the reason. Reports the **name** of the env var a key is read from, never the key. |
943
970
  | `GET /api/notifications`(`/history`) · `POST /api/notifications/:id/clear` | Notification feed. |
944
971
  | `POST /api/transcribe`(`/model`…) | Voice-input transcription (Whisper, macOS). |
945
972
  | `POST /api/translation` | Runtime UI-string translation. |
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+
3
+ // `claude-ollama <model> [claude args…]` — run Claude Code fully locally against an
4
+ // Ollama-served model. Ollama 0.31+ serves a native Anthropic-compatible `/v1/messages`, so
5
+ // Claude Code connects directly; this launcher just sets up the three things that make it
6
+ // actually work with a small local model:
7
+ // 1. a dedicated large-context `ollama serve` (the 4096 default overflows Claude's prompt),
8
+ // 2. `--bare --disable-slash-commands` so the system prompt is small enough for the model,
9
+ // 3. the env recipe (unset ANTHROPIC_API_KEY, point ANTHROPIC_BASE_URL at the local server).
10
+
11
+ import { execSync, spawn } from "node:child_process";
12
+ import { createServer } from "node:net";
13
+ import { get as httpGet } from "node:http";
14
+ import { parseClaudeOllamaArgs, buildClaudeEnv, buildOllamaServeEnv, buildClaudeArgs, modelIsInstalled, OLLAMA_CONTEXT_LENGTH } from "./ollama-launch.js";
15
+
16
+ const log = (msg) => console.log(`\x1b[36m[claude-ollama]\x1b[0m ${msg}`);
17
+ const error = (msg) => console.error(`\x1b[31m[claude-ollama]\x1b[0m ${msg}`);
18
+
19
+ const READY_TIMEOUT_MS = 20_000;
20
+ const READY_POLL_MS = 400;
21
+
22
+ function printHelp() {
23
+ console.log(
24
+ [
25
+ "Usage: claude-ollama <model> [claude args…]",
26
+ "",
27
+ "Run Claude Code against a local Ollama model — no cloud, no API key.",
28
+ "Starts a dedicated large-context Ollama server on a private port and launches",
29
+ "Claude Code with a minimal system prompt (--bare) so small models aren't drowned.",
30
+ "",
31
+ "Examples:",
32
+ " claude-ollama qwen3:4b",
33
+ ' claude-ollama qwen3:30b-a3b "refactor this module"',
34
+ "",
35
+ "Notes:",
36
+ " - The model must be pulled first: ollama pull <model>",
37
+ " - Validate a model through a real multi-turn tool run; small ones vary.",
38
+ ].join("\n"),
39
+ );
40
+ }
41
+
42
+ function hasCommand(cmd) {
43
+ try {
44
+ execSync(`${cmd} --version`, { stdio: "ignore" });
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // A free TCP port for the private ollama server, so an Ollama the user already runs (11434)
52
+ // is left alone.
53
+ function findFreePort() {
54
+ return new Promise((resolve, reject) => {
55
+ const probe = createServer();
56
+ probe.once("error", reject);
57
+ probe.listen(0, "127.0.0.1", () => {
58
+ const { port } = probe.address();
59
+ probe.close(() => resolve(port));
60
+ });
61
+ });
62
+ }
63
+
64
+ // GET a small JSON endpoint on the local server, best-effort (null on any failure).
65
+ function getJson(port, path, timeout_ms = 2000) {
66
+ return new Promise((resolve) => {
67
+ const req = httpGet({ host: "127.0.0.1", port, path, timeout: timeout_ms }, (res) => {
68
+ let body = "";
69
+ res.on("data", (chunk) => (body += chunk));
70
+ res.on("end", () => {
71
+ try {
72
+ resolve(JSON.parse(body));
73
+ } catch {
74
+ resolve(null);
75
+ }
76
+ });
77
+ });
78
+ req.on("error", () => resolve(null));
79
+ req.on("timeout", () => {
80
+ req.destroy();
81
+ resolve(null);
82
+ });
83
+ });
84
+ }
85
+
86
+ async function waitUntilReady(port) {
87
+ const startedAt = Date.now();
88
+ while (Date.now() - startedAt < READY_TIMEOUT_MS) {
89
+ if (await getJson(port, "/api/version")) return true;
90
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
91
+ }
92
+ return false;
93
+ }
94
+
95
+ function requireCommands() {
96
+ if (!hasCommand("ollama")) {
97
+ error("`ollama` not found on PATH. Install it: https://ollama.com/download");
98
+ process.exit(1);
99
+ }
100
+ if (!hasCommand("claude")) {
101
+ error("`claude` (Claude Code) not found. Install it: npm install -g @anthropic-ai/claude-code");
102
+ process.exit(1);
103
+ }
104
+ }
105
+
106
+ // Start the private ollama server and return a `stop` that kills it. The stop is wired to
107
+ // this process's own exit and signals so the server never outlives the launcher.
108
+ function startPrivateOllama(host) {
109
+ const serve = spawn("ollama", ["serve"], {
110
+ stdio: "ignore",
111
+ env: buildOllamaServeEnv(process.env, host, OLLAMA_CONTEXT_LENGTH),
112
+ });
113
+ let stopped = false;
114
+ const stop = () => {
115
+ if (stopped) return;
116
+ stopped = true;
117
+ try {
118
+ serve.kill("SIGTERM");
119
+ } catch {
120
+ // already gone
121
+ }
122
+ };
123
+ process.on("exit", stop);
124
+ for (const signal of ["SIGINT", "SIGTERM"]) {
125
+ process.on(signal, () => {
126
+ stop();
127
+ process.exit(signal === "SIGINT" ? 130 : 143);
128
+ });
129
+ }
130
+ serve.on("error", (e) => {
131
+ error(`failed to start ollama serve: ${e.message}`);
132
+ process.exit(1);
133
+ });
134
+ return stop;
135
+ }
136
+
137
+ // Hand off to interactive Claude Code; exit with its code and stop our server behind it.
138
+ function runClaude(model, baseUrl, claudeArgs, stopServe) {
139
+ log(`launching Claude Code on ${model} (minimal prompt: --bare --disable-slash-commands)`);
140
+ const claude = spawn("claude", buildClaudeArgs(claudeArgs), {
141
+ stdio: "inherit",
142
+ env: buildClaudeEnv(process.env, model, baseUrl),
143
+ });
144
+ claude.on("error", (e) => {
145
+ error(`failed to launch claude: ${e.message}`);
146
+ stopServe();
147
+ process.exit(1);
148
+ });
149
+ claude.on("exit", (code) => {
150
+ stopServe();
151
+ process.exit(code ?? 0);
152
+ });
153
+ }
154
+
155
+ async function main() {
156
+ const { help, model, claudeArgs } = parseClaudeOllamaArgs(process.argv.slice(2));
157
+ if (help || !model) {
158
+ printHelp();
159
+ process.exit(help ? 0 : 1);
160
+ }
161
+ requireCommands();
162
+ const port = await findFreePort();
163
+ const host = `127.0.0.1:${port}`;
164
+ log(`starting a private Ollama server on ${host} (context ${OLLAMA_CONTEXT_LENGTH})…`);
165
+ const stopServe = startPrivateOllama(host);
166
+ if (!(await waitUntilReady(port))) {
167
+ error("the Ollama server did not become ready in time");
168
+ stopServe();
169
+ process.exit(1);
170
+ }
171
+ const tags = await getJson(port, "/api/tags");
172
+ if (tags && !modelIsInstalled(tags, model)) {
173
+ error(`model '${model}' is not installed. Pull it first: ollama pull ${model}`);
174
+ stopServe();
175
+ process.exit(1);
176
+ }
177
+ runClaude(model, `http://${host}`, claudeArgs, stopServe);
178
+ }
179
+
180
+ main().catch((e) => {
181
+ error(e?.message || String(e));
182
+ process.exit(1);
183
+ });
@@ -0,0 +1,11 @@
1
+ export type PortChoice = { port: number; explicit: boolean } | { error: string };
2
+ export type CwdChoice = { path: string; mustExist: boolean } | { error: string };
3
+ export declare function parsePortArg(args: string[], defaultPort: number): PortChoice;
4
+ export declare function chooseCwd(args: string[], env: Record<string, string | undefined>): CwdChoice;
5
+ export declare function portInUseMessage(port: number, explicit: boolean): string;
6
+ export declare function portInUseAction(explicit: boolean, isTTY: boolean | undefined): "ask" | "stop";
7
+ export declare function secondInstancePrompt(port: number): string;
8
+ export declare function saysYes(answer: unknown): boolean;
9
+ export declare const SECOND_INSTANCE_NOTE: string;
10
+ export declare const MIN_NODE_LABEL: string;
11
+ export declare function nodeMeetsMinimum(version: string): boolean;
@@ -0,0 +1,135 @@
1
+ // What the launcher decides before it starts anything, kept out of the executable so each
2
+ // decision can be checked without a process to exit or a terminal to type into.
3
+ //
4
+ // `--cwd` picks the workspace claude runs in and whose sessions the sidebar lists, so it is
5
+ // a data-scope boundary: getting it wrong points the whole app at someone else's project.
6
+ // `--port` decides whether a clash is a hard error or a silent retry. Both used to be
7
+ // decided inside the executable, where they exit the process on a bad value and so could not
8
+ // be checked at all (#611 A3).
9
+ //
10
+ // These return a decision; the caller prints and exits. Nothing here reads argv, the
11
+ // environment or the filesystem.
12
+
13
+ /**
14
+ * Which port the launcher should ask for.
15
+ * `--port` must be a plain integer in range: a value that merely starts with digits ("80x")
16
+ * or carries a sign or padding ("+80", "080") is a typo, and silently launching on 80 is
17
+ * worse than saying so.
18
+ */
19
+ export function parsePortArg(args, defaultPort) {
20
+ const at = args.indexOf("--port");
21
+ if (at === -1) return { port: defaultPort, explicit: false };
22
+ const raw = args[at + 1];
23
+ const parsed = Number.parseInt(raw ?? "", 10);
24
+ if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed < 1 || parsed > 65535) {
25
+ return { error: `Invalid --port value: "${raw ?? ""}" (expected integer 1..65535)` };
26
+ }
27
+ return { port: parsed, explicit: true };
28
+ }
29
+
30
+ /**
31
+ * Which directory to run in, before it is made absolute.
32
+ * Precedence: `--cwd` (relative allowed) > CLAUDE_CWD > the directory the launcher was run
33
+ * from. `mustExist` is set only for `--cwd`: a typo there should stop the launch, while a
34
+ * CLAUDE_CWD naming a directory that isn't there yet is the managed-workspace case the
35
+ * server creates on boot.
36
+ */
37
+ export function chooseCwd(args, env) {
38
+ const at = args.indexOf("--cwd");
39
+ if (at === -1) return { path: env.CLAUDE_CWD ?? ".", mustExist: false };
40
+ const value = args[at + 1];
41
+ // A missing value swallows the next flag ("--cwd --port 3000" would run in a directory
42
+ // called "--port"), so anything flag-shaped is treated as absent.
43
+ if (value === undefined || value.startsWith("-")) return { error: "--cwd requires a directory path" };
44
+ return { path: value, mustExist: true };
45
+ }
46
+
47
+ /**
48
+ * What to say when the port is taken.
49
+ *
50
+ * Running a second server is not a supported setup: both share ~/.mulmoterminal and the
51
+ * workspace, but each keeps its own PTYs, pub/sub and in-memory caches, so the two disagree
52
+ * about state neither can see the other change. Starting one silently on another port —
53
+ * which is what a plain second `npx mulmoterminal` used to do — is how someone ends up in
54
+ * that setup without knowing (#611).
55
+ *
56
+ * So the message has to answer the two things the user actually wants: where the running one
57
+ * is, and how to insist if a second is really wanted.
58
+ */
59
+ export function portInUseMessage(port, explicit) {
60
+ const lines = [`Port ${port} is already in use.`];
61
+ lines.push(` If that is MulmoTerminal, it is already running at http://localhost:${port}`);
62
+ lines.push(explicit ? " Pick a different --port, or stop the other process." : " To start a second one anyway: --port <number>");
63
+ return lines.join("\n");
64
+ }
65
+
66
+ /**
67
+ * What to do when the wanted port is taken: "ask" whether to start a second instance,
68
+ * or "stop".
69
+ *
70
+ * Two conditions rule the question out. An explicit --port already named the port that
71
+ * was wanted, so offering a different one answers a question nobody asked; and with no
72
+ * terminal to type into (a script, a service, CI) a prompt has nobody to answer it and
73
+ * would hang the start instead of failing it.
74
+ */
75
+ export function portInUseAction(explicit, isTTY) {
76
+ return explicit || !isTTY ? "stop" : "ask";
77
+ }
78
+
79
+ /**
80
+ * The question asked when the default port is taken and there is somebody to answer.
81
+ *
82
+ * Two servers is not a supported setup (#611), but it is a legitimate thing to want on
83
+ * purpose — so the answer is a question rather than a refusal, and the default is no.
84
+ */
85
+ export function secondInstancePrompt(port) {
86
+ return [`Port ${port} is already in use — MulmoTerminal may already be running at http://localhost:${port}`, "Start a second instance anyway? [y/N] "].join(
87
+ "\n",
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Whether an answer to that question is a yes.
93
+ *
94
+ * Only an explicit yes counts. The prompt says [y/N], so Enter — and anything unrecognised —
95
+ * means no: starting a second server by misreading a stray keystroke is the outcome worth
96
+ * avoiding, and saying no costs one retyped command.
97
+ */
98
+ export function saysYes(answer) {
99
+ return /^y(es)?$/i.test(String(answer ?? "").trim());
100
+ }
101
+
102
+ /**
103
+ * What someone who said yes should know before the second one comes up.
104
+ *
105
+ * One line, and only the part that is still true: config and the hidden-session list are
106
+ * safe across instances, but a session's tool history is cached per process and its live
107
+ * updates never cross — so the instance that did not start a session shows a frozen history
108
+ * for it (#611).
109
+ */
110
+ export const SECOND_INSTANCE_NOTE = [
111
+ " Note: both share ~/.mulmoterminal. A session's tool history does not live-update",
112
+ " in the instance that did not start it.",
113
+ ].join("\n");
114
+
115
+ const MIN_NODE_MAJOR = 22;
116
+ const MIN_NODE_MINOR = 9;
117
+
118
+ /**
119
+ * The lowest Node the `init` check reports as good, for the "needs ≥ x.y" line.
120
+ */
121
+ export const MIN_NODE_LABEL = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`;
122
+
123
+ /**
124
+ * Whether the running Node is new enough for the `init` pre-flight tick.
125
+ *
126
+ * `process.versions.node` is "major.minor.patch", with a "-prerelease" tag on the patch for
127
+ * nightlies ("22.9.0-nightly…"). Only major.minor gate, and Number.parseInt stops at the
128
+ * first non-digit, so that tag never reaches the comparison. A string that is not a version
129
+ * parses to NaN, and every comparison against NaN is false, so an unreadable version reads as
130
+ * "below minimum" — the safe direction for a display-only check.
131
+ */
132
+ export function nodeMeetsMinimum(version) {
133
+ const [major, minor] = version.split(".").map((part) => Number.parseInt(part, 10));
134
+ return major > MIN_NODE_MAJOR || (major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR);
135
+ }
@@ -13,14 +13,24 @@ import { createServer } from "node:net";
13
13
  import { dirname, join, resolve } from "node:path";
14
14
  import { createInterface } from "node:readline";
15
15
  import { fileURLToPath } from "node:url";
16
- import { fetchLatestVersion, isNewerVersion } from "./update-check.js";
16
+ import { computeUpdateNotice, isUpdateCheckDisabled } from "./update-check.js";
17
+ import {
18
+ chooseCwd,
19
+ parsePortArg,
20
+ portInUseAction,
21
+ portInUseMessage,
22
+ saysYes,
23
+ secondInstancePrompt,
24
+ SECOND_INSTANCE_NOTE,
25
+ nodeMeetsMinimum,
26
+ MIN_NODE_LABEL,
27
+ } from "./cli-args.js";
17
28
 
18
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
30
  const PKG_DIR = join(__dirname, "..");
20
31
  const SERVER_ENTRY = join(PKG_DIR, "server", "index.ts");
21
32
  const DEFAULT_PORT = 34567;
22
33
  const READY_TIMEOUT_MS = 15_000;
23
- const MAX_BIND_RETRIES = 5;
24
34
  // Server exit code meaning "port taken at bind time" — keep in sync with
25
35
  // server/index.ts (PORT_IN_USE_EXIT_CODE).
26
36
  const PORT_IN_USE_EXIT_CODE = 75;
@@ -32,19 +42,18 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
32
42
  const log = (msg) => console.log(`\x1b[36m[mulmoterminal]\x1b[0m ${msg}`);
33
43
  const error = (msg) => console.error(`\x1b[31m[mulmoterminal]\x1b[0m ${msg}`);
34
44
 
35
- // Non-blocking notice when a newer version is published — `npm i -g` never
36
- // auto-updates. Opt out via MULMOTERMINAL_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER.
37
- function checkForUpdate() {
38
- if (process.env.MULMOTERMINAL_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) return;
39
- fetchLatestVersion()
40
- .then((latest) => {
41
- if (latest && isNewerVersion(latest, VERSION)) {
42
- log(`\x1b[33mUpdate available: ${VERSION} ${latest} · run: npm i -g mulmoterminal\x1b[0m`);
43
- }
44
- })
45
- .catch(() => {
46
- // best-effort; never disrupt startup
47
- });
45
+ // Non-blocking console notice that a newer version existsneither `npm i -g` nor a git
46
+ // checkout auto-updates. Opt out via MULMOTERMINAL_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER. The
47
+ // same check runs in the server for the web-header badge (the launcher isn't involved under
48
+ // `yarn dev`), sharing computeUpdateNotice so the two never drift.
49
+ async function checkForUpdate() {
50
+ if (isUpdateCheckDisabled(process.env)) return;
51
+ try {
52
+ const notice = await computeUpdateNotice(PKG_DIR, VERSION);
53
+ if (notice) log(`\x1b[33m${notice}\x1b[0m`);
54
+ } catch {
55
+ // best-effort; never disrupt startup
56
+ }
48
57
  }
49
58
 
50
59
  // Detect a CLI on the user's PATH by asking for its version. Intentionally resolves from
@@ -52,7 +61,6 @@ function checkForUpdate() {
52
61
  // `init` checks.
53
62
  function hasCommand(cmd, versionArg = "--version") {
54
63
  try {
55
- // eslint-disable-next-line sonarjs/no-os-command-from-path
56
64
  execSync(`${cmd} ${versionArg}`, { stdio: "pipe" });
57
65
  return true;
58
66
  } catch {
@@ -69,7 +77,7 @@ function promptYesNo(question) {
69
77
  const rl = createInterface({ input: process.stdin, output: process.stdout });
70
78
  rl.question(question, (answer) => {
71
79
  rl.close();
72
- res(/^y(es)?$/i.test(answer.trim()));
80
+ res(saysYes(answer));
73
81
  });
74
82
  });
75
83
  }
@@ -80,9 +88,8 @@ function promptYesNo(question) {
80
88
  async function runInit(initArgs) {
81
89
  log("Setting up MulmoTerminal…\n");
82
90
 
83
- const [maj, min] = process.versions.node.split(".").map((n) => Number.parseInt(n, 10));
84
- const nodeOk = maj > 22 || (maj === 22 && min >= 9);
85
- console.log(nodeOk ? ` ✓ Node ${process.versions.node}` : ` ✗ Node ${process.versions.node} — MulmoTerminal needs ≥ 22.9`);
91
+ const nodeOk = nodeMeetsMinimum(process.versions.node);
92
+ console.log(nodeOk ? ` ✓ Node ${process.versions.node}` : ` ✗ Node ${process.versions.node} MulmoTerminal needs ≥ ${MIN_NODE_LABEL}`);
86
93
 
87
94
  const hasClaude = claudeInstalled();
88
95
  if (hasClaude) {
@@ -120,7 +127,6 @@ async function runInit(initArgs) {
120
127
  // must never block waiting on stdin.
121
128
  if (hasClaude && process.stdin.isTTY && (await promptYesNo("\nConfigure interactively now with the /mulmoterminal-config skill? [y/N] "))) {
122
129
  log("Launching Claude — use /mulmoterminal-config (or just ask it to configure MulmoTerminal).");
123
- // eslint-disable-next-line sonarjs/no-os-command-from-path
124
130
  spawn("claude", ["Use the mulmoterminal-config skill to configure MulmoTerminal."], { stdio: "inherit" });
125
131
  return;
126
132
  }
@@ -164,23 +170,6 @@ function isPortFree(port) {
164
170
  });
165
171
  }
166
172
 
167
- // Ask the OS for a free port (listen on 0) and return the one it assigned, or
168
- // null. An effectively-random fallback when the preferred port is taken; the
169
- // bind-retry in main() closes the small probe-to-bind race so concurrent starts
170
- // don't clash.
171
- function findEphemeralPort() {
172
- return new Promise((resolve) => {
173
- const probe = createServer();
174
- probe.once("error", () => resolve(null));
175
- probe.once("listening", () => {
176
- const addr = probe.address();
177
- const assigned = addr && typeof addr === "object" ? addr.port : null;
178
- probe.close(() => resolve(assigned));
179
- });
180
- probe.listen(0);
181
- });
182
- }
183
-
184
173
  // Poll the server until it answers, then call onReady; give up after the timeout
185
174
  // so the launcher never hangs on a crash loop. Returns a cancel function — a
186
175
  // raced/abandoned attempt stops polling so it can't fire a stale banner.
@@ -220,60 +209,65 @@ function printReadyBanner(url) {
220
209
  console.log(`${bar}\n`);
221
210
  }
222
211
 
223
- function parsePortArg(args) {
224
- const idx = args.indexOf("--port");
225
- if (idx === -1) return { requestedPort: DEFAULT_PORT, portExplicit: false };
226
- const raw = args[idx + 1];
227
- const parsed = Number.parseInt(raw ?? "", 10);
228
- if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed < 1 || parsed > 65535) {
229
- error(`Invalid --port value: "${raw ?? ""}" (expected integer 1..65535)`);
212
+ // The two flag decisions live in cli-args.js so they can be checked without a process to
213
+ // exit; this turns a decision into the message and the exit.
214
+ function decideOrExit(choice) {
215
+ if ("error" in choice) {
216
+ error(choice.error);
230
217
  process.exit(1);
231
218
  }
232
- return { requestedPort: parsed, portExplicit: true };
219
+ return choice;
233
220
  }
234
221
 
235
222
  // Resolve the workspace directory claude runs in (and whose sessions the sidebar
236
- // lists). Precedence: --cwd (relative paths allowed) > CLAUDE_CWD env > the
237
- // directory npx was run from. Always returned absolute. An explicit --cwd that
238
- // isn't an existing directory is a hard error (catches typos before launch).
223
+ // lists), always absolute. An explicit --cwd that isn't an existing directory is a hard
224
+ // error (catches typos before launch); an inherited one is the workspace the server creates.
239
225
  function resolveCwd(args) {
240
- const idx = args.indexOf("--cwd");
241
- let flagValue;
242
- if (idx !== -1) {
243
- flagValue = args[idx + 1];
244
- if (flagValue === undefined || flagValue.startsWith("-")) {
245
- error("--cwd requires a directory path");
246
- process.exit(1);
247
- }
248
- }
249
- const chosen = flagValue ?? process.env.CLAUDE_CWD ?? ".";
226
+ const { path: chosen, mustExist } = decideOrExit(chooseCwd(args, process.env));
250
227
  const abs = resolve(process.cwd(), chosen);
251
- if (idx !== -1 && (!existsSync(abs) || !statSync(abs).isDirectory())) {
228
+ if (mustExist && (!existsSync(abs) || !statSync(abs).isDirectory())) {
252
229
  error(`--cwd is not a directory: ${abs}`);
253
230
  process.exit(1);
254
231
  }
255
232
  return abs;
256
233
  }
257
234
 
235
+ // Ask the OS for a free port (listen on 0) and return the one it assigned, or null. Only
236
+ // reached once someone has said yes to a second instance.
237
+ function findEphemeralPort() {
238
+ return new Promise((resolve) => {
239
+ const probe = createServer();
240
+ probe.once("error", () => resolve(null));
241
+ probe.once("listening", () => {
242
+ const { port } = probe.address();
243
+ probe.close(() => resolve(port));
244
+ });
245
+ probe.listen(0);
246
+ });
247
+ }
248
+
258
249
  async function choosePort(requested, explicit) {
259
250
  if (await isPortFree(requested)) return requested;
260
- if (explicit) {
261
- error(`Port ${requested} is already in use. Stop the other process or pick a different --port.`);
251
+ // No SILENT fallback: starting a second server on another port without saying so is how
252
+ // someone ends up with two sharing one home directory without knowing (#611).
253
+ if (portInUseAction(explicit, process.stdin.isTTY) === "stop") {
254
+ error(portInUseMessage(requested, explicit));
262
255
  process.exit(1);
263
256
  }
257
+ if (!(await promptYesNo(secondInstancePrompt(requested)))) process.exit(1);
264
258
  const fallback = await findEphemeralPort();
265
259
  if (fallback === null) {
266
- error(`Port ${requested} is in use and no free port could be found.`);
260
+ error("No free port could be found for a second instance.");
267
261
  process.exit(1);
268
262
  }
269
- log(`Port ${requested} busy → using ${fallback} instead. (Pass --port <N> to pin.)`);
263
+ log(SECOND_INSTANCE_NOTE);
270
264
  return fallback;
271
265
  }
272
266
 
273
267
  // Spawn the server on `port` and report the child via `onChild` (so signal
274
268
  // handlers target the live process). Resolves only when the server exits because
275
269
  // the port was taken at bind time before it became ready — the caller then
276
- // retries on a fresh port. In every other case (clean shutdown, fatal error,
270
+ // reports that and stops. In every other case (clean shutdown, fatal error,
277
271
  // or the server simply running) the process exits with the server's code.
278
272
  function runServer(port, noOpen, cwd, onChild) {
279
273
  return new Promise((resolveExit) => {
@@ -326,7 +320,9 @@ Commands:
326
320
 
327
321
  Options:
328
322
  --cwd <dir> Working directory claude runs in (default: current directory; relative paths allowed)
329
- --port <number> Server port (default: ${DEFAULT_PORT}; a free port is chosen if it's busy)
323
+ --port <number> Server port (default: ${DEFAULT_PORT}). If it is in use, you are asked
324
+ whether to start a second instance on a free port; with --port, or
325
+ with no terminal to ask, startup stops instead.
330
326
  --no-open Don't open the browser automatically
331
327
  --version Show version
332
328
  --help Show this help
@@ -369,7 +365,7 @@ async function main() {
369
365
  process.exit(1);
370
366
  }
371
367
 
372
- const { requestedPort, portExplicit } = parsePortArg(args);
368
+ const { port: requestedPort, explicit: portExplicit } = decideOrExit(parsePortArg(args, DEFAULT_PORT));
373
369
  const noOpen = args.includes("--no-open");
374
370
  const cwd = resolveCwd(args);
375
371
  log(`Workspace: ${cwd}`);
@@ -383,27 +379,14 @@ async function main() {
383
379
  process.on("SIGINT", shutdown);
384
380
  process.on("SIGTERM", shutdown);
385
381
 
386
- // Start on the chosen port; if the server loses the rare probe-to-bind race,
387
- // fall back to a fresh OS-assigned port and retry. An explicit --port is not
388
- // second-guessed. `runServer` only returns when the port was raced.
389
- let port = await choosePort(requestedPort, portExplicit);
390
- for (let attempt = 0; attempt <= MAX_BIND_RETRIES; attempt++) {
391
- await runServer(port, noOpen, cwd, (c) => {
392
- child = c;
393
- });
394
- if (portExplicit) {
395
- error(`Port ${port} is already in use. Stop the other process or pick a different --port.`);
396
- process.exit(1);
397
- }
398
- const next = await findEphemeralPort();
399
- if (next === null) {
400
- error("No free port available to retry on.");
401
- process.exit(1);
402
- }
403
- log(`Port ${port} was taken at bind time → retrying on ${next}.`);
404
- port = next;
405
- }
406
- error(`Could not bind a free port after ${MAX_BIND_RETRIES + 1} attempts.`);
382
+ // The probe above can still lose to something binding the port in the same instant, in
383
+ // which case the server exits 75 and runServer returns. Same answer as the probe: say who
384
+ // has it rather than moving to a port nobody asked for.
385
+ const port = await choosePort(requestedPort, portExplicit);
386
+ await runServer(port, noOpen, cwd, (c) => {
387
+ child = c;
388
+ });
389
+ error(portInUseMessage(port, portExplicit));
407
390
  process.exit(1);
408
391
  }
409
392