mulmoterminal 2.9.1 → 4.0.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 (176) hide show
  1. package/README.md +126 -90
  2. package/bin/mulmoterminal.js +0 -1
  3. package/common/collectionSeed.ts +126 -0
  4. package/common/dirPathKey.ts +45 -0
  5. package/common/dirPriorityOrder.ts +24 -0
  6. package/common/notifyKinds.ts +4 -1
  7. package/common/prPhase.ts +38 -5
  8. package/common/repoDirs.ts +57 -0
  9. package/common/sessionAgent.ts +9 -0
  10. package/common/sessionOccupancy.ts +45 -0
  11. package/common/terminalSize.ts +30 -0
  12. package/common/workerStatus.ts +35 -0
  13. package/common/worktreeSession.ts +37 -0
  14. package/dist/assets/{abnfDiagram-VRR7QNED-RjiYivmv-6JcEFHs8.js → abnfDiagram-VRR7QNED-RjiYivmv-Ds795mMl.js} +1 -1
  15. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-_YNv0NMb.js +1 -0
  16. package/dist/assets/{architectureDiagram-ZJ3FMSHR-3nWA91tG-Dt6beUAa.js → architectureDiagram-ZJ3FMSHR-3nWA91tG-C_pKLVRy.js} +1 -1
  17. package/dist/assets/{blockDiagram-677ZJIJ3-BPuAJQRW-DbJk7wmt.js → blockDiagram-677ZJIJ3-BPuAJQRW-C1XW9rk5.js} +1 -1
  18. package/dist/assets/{c4Diagram-LMCZKHZV-C0LAqQso-DWJ7HKC8.js → c4Diagram-LMCZKHZV-C0LAqQso-B_DJUxYS.js} +1 -1
  19. package/dist/assets/channel-7wUqSdoX-6DJ89-Qe.js +1 -0
  20. package/dist/assets/{chunk-32BRIVSS-BRrYpgtb-BNBEavcV.js → chunk-32BRIVSS-BRrYpgtb-muO9l3Wd.js} +1 -1
  21. package/dist/assets/{chunk-52WLFC77-BJ-ss3Xr-Dw7VDdHR.js → chunk-52WLFC77-BJ-ss3Xr-2G1qxJCe.js} +1 -1
  22. package/dist/assets/{chunk-C7G6YPKG-BZEucKEL-BVh_Wzgs.js → chunk-C7G6YPKG-BZEucKEL-DcP1Ik_Y.js} +1 -1
  23. package/dist/assets/{chunk-EX3LRPZG-DLS6FBN1-wddVin31.js → chunk-EX3LRPZG-DLS6FBN1-DzGx10EI.js} +1 -1
  24. package/dist/assets/{chunk-FWX5IMBZ-DHLSFw1H-Dzm5lyFF.js → chunk-FWX5IMBZ-DHLSFw1H-C_1-WiA5.js} +2 -2
  25. package/dist/assets/{chunk-HOUHSVGY-Bhlt8hXJ-BuAcpLyo.js → chunk-HOUHSVGY-Bhlt8hXJ-CNQLLKZo.js} +1 -1
  26. package/dist/assets/{chunk-ICXQ74PX-DwgHBX_g-D2eZGz0O.js → chunk-ICXQ74PX-DwgHBX_g-zUyYvMip.js} +1 -1
  27. package/dist/assets/{chunk-MOJQB5TN-CMZRaeqt-CAFPabY4.js → chunk-MOJQB5TN-CMZRaeqt-CuddDdck.js} +1 -1
  28. package/dist/assets/{chunk-OGEWGWER-8Qy4a8b5-CYju6v7J.js → chunk-OGEWGWER-8Qy4a8b5-OusgHU3N.js} +1 -1
  29. package/dist/assets/{chunk-PUDLZKDR-DcrWQRYh-Dir94jUH.js → chunk-PUDLZKDR-DcrWQRYh-CbEu-YVk.js} +1 -1
  30. package/dist/assets/{chunk-Q4XR5HBZ-ZXVGkG8Z-8Yiftf85.js → chunk-Q4XR5HBZ-ZXVGkG8Z-C8R8rf8U.js} +1 -1
  31. package/dist/assets/{chunk-V7JOEXUC-DmGdheTX-BvC9wePo.js → chunk-V7JOEXUC-DmGdheTX-CAvev2P-.js} +1 -1
  32. package/dist/assets/{chunk-VAUOI2AC-CS9QJ4yz-Ciy2Wc1T.js → chunk-VAUOI2AC-CS9QJ4yz-6Fqcztnb.js} +1 -1
  33. package/dist/assets/{chunk-VR4S4FIN-C6a91eNY-BNwIxkVv.js → chunk-VR4S4FIN-C6a91eNY-Oo6w4umS.js} +1 -1
  34. package/dist/assets/{chunk-WYO6CB5R-BlzOfotS-CzBk75nc.js → chunk-WYO6CB5R-BlzOfotS-CIUpBpsn.js} +1 -1
  35. package/dist/assets/{chunk-ZGVPDNZ5-BYwxNFTK-6KINEIo8.js → chunk-ZGVPDNZ5-BYwxNFTK-DJKhMGKs.js} +1 -1
  36. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-B85sB4KG.js +1 -0
  37. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-B85sB4KG.js +1 -0
  38. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CVLC7hXj.js +1 -0
  39. package/dist/assets/{cynefinDiagram-TSTJHNR4-DHA9iPo--BuHuPfiX.js → cynefinDiagram-TSTJHNR4-DHA9iPo--D510DZwZ.js} +1 -1
  40. package/dist/assets/{dagre-VKFMJZFB--oJKqXBZ-B8zf7Xc1.js → dagre-VKFMJZFB--oJKqXBZ-BqyoygxX.js} +1 -1
  41. package/dist/assets/{diagram-FQU43EPY-CcJCB9bG-CGCT5uJZ.js → diagram-FQU43EPY-CcJCB9bG-DfnckLd4.js} +1 -1
  42. package/dist/assets/{diagram-G47NLZAW-C_o-WGG1-DKGjGj34.js → diagram-G47NLZAW-C_o-WGG1-BPt0rgWP.js} +1 -1
  43. package/dist/assets/{diagram-NH7WQ7WH-CXJCYvY--7ufwhy_r.js → diagram-NH7WQ7WH-CXJCYvY--CkJBzBK2.js} +1 -1
  44. package/dist/assets/{diagram-OA4YK3LP-BMzeJ87A-CV2UfBf9.js → diagram-OA4YK3LP-BMzeJ87A-COSn3f12.js} +1 -1
  45. package/dist/assets/{diagram-WEI45ONY-D_93NKqo-CgZTR0Dm.js → diagram-WEI45ONY-D_93NKqo-Dp8Om4pW.js} +1 -1
  46. package/dist/assets/{dist-uWJ0Qyt6.js → dist-DZD6s32q.js} +2 -2
  47. package/dist/assets/{dist-DmHJIf6m.js → dist-DiEF7DGo.js} +1 -1
  48. package/dist/assets/{dist-Cmej5EB5.js → dist-JstXoCzo.js} +2 -2
  49. package/dist/assets/{dist-CbNR6PAR.js → dist-gls9ajYD.js} +5 -5
  50. package/dist/assets/{ebnfDiagram-CCIWWBDH-CVai1Ii9-DPEx6v7k.js → ebnfDiagram-CCIWWBDH-CVai1Ii9-Di2Rnx6S.js} +1 -1
  51. package/dist/assets/{erDiagram-Q63AITRT-CiABnA0s-CZ8VaEZt.js → erDiagram-Q63AITRT-CiABnA0s-dgCq9gZ-.js} +1 -1
  52. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-D-U4pUhx.js +1 -0
  53. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-CeqmKjiQ.js +1 -0
  54. package/dist/assets/{ganttDiagram-NO4QXBWP-DQZvdFo1-hARAuFGd.js → ganttDiagram-NO4QXBWP-DQZvdFo1-Cia0ajA1.js} +1 -1
  55. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Dv8G0LNT.js +1 -0
  56. package/dist/assets/{gitGraphDiagram-IHSO6WYX-C2ovBouh-GRz42gkQ.js → gitGraphDiagram-IHSO6WYX-C2ovBouh-Dh1y0VXZ.js} +1 -1
  57. package/dist/assets/{index-BqtdqAIq.js → index-BBcSnsqR.js} +303 -303
  58. package/dist/assets/index-Dal2P4Y6.css +1 -0
  59. package/dist/assets/info-DKCQHKI2-Dplx5kMp-Gtb9rU5N.js +1 -0
  60. package/dist/assets/{infoDiagram-FWYZ7A6U-7UnoB5AP-6_ENywpQ.js → infoDiagram-FWYZ7A6U-7UnoB5AP-BqYiXSaj.js} +1 -1
  61. package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByUDM_N2-DcdiqKjY.js → ishikawaDiagram-FXEZZL3T-ByUDM_N2-CcZgH7Ew.js} +1 -1
  62. package/dist/assets/{journeyDiagram-5HDEW3XC-c5xIah9o-Jb5j4dIB.js → journeyDiagram-5HDEW3XC-c5xIah9o-DdArPeNL.js} +1 -1
  63. package/dist/assets/{kanban-definition-HUTT4EX6-Cnt6loYD-QXnU2uHY.js → kanban-definition-HUTT4EX6-Cnt6loYD-DmJ152Sf.js} +1 -1
  64. package/dist/assets/{lib-Jez9I7EX.js → lib-Db3KPEnF.js} +3 -3
  65. package/dist/assets/{line-D7ziSjKi-C6xSc_fT.js → line-D7ziSjKi-B3NE0st9.js} +1 -1
  66. package/dist/assets/{marp-643zocwQ.js → marp-SCu9aKhI.js} +1 -1
  67. package/dist/assets/material-symbols-outlined-Bz-4pmf0.woff2 +0 -0
  68. package/dist/assets/{mermaid-parser.core-DxEa8E3F-CjD-ngh3.js → mermaid-parser.core-DxEa8E3F-nhwF9Zmw.js} +2 -2
  69. package/dist/assets/{mermaid.core-V0OYwIz3-BuB2lfOL.js → mermaid.core-V0OYwIz3-COnZ7RY6.js} +3 -3
  70. package/dist/assets/{mindmap-definition-LN4V7U3C-CexN3O6L-CAqtxvts.js → mindmap-definition-LN4V7U3C-CexN3O6L-DAFbTZnf.js} +1 -1
  71. package/dist/assets/packet-7NZHBO7P-CQI3flND-BlSkfnzl.js +1 -0
  72. package/dist/assets/{pegDiagram-2B236MQR-BiM4G0cW-CpsegN79.js → pegDiagram-2B236MQR-BiM4G0cW-DVdp8F2g.js} +1 -1
  73. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-PTCmAnlr.js +1 -0
  74. package/dist/assets/{pieDiagram-ENE6RG2P-B_fBBS-2-CVj5FmJ-.js → pieDiagram-ENE6RG2P-B_fBBS-2-dOKbqFlT.js} +1 -1
  75. package/dist/assets/{quadrantDiagram-ABIIQ3AL-BigGVxCR-BZdqEKH0.js → quadrantDiagram-ABIIQ3AL-BigGVxCR-B0K7Bi_G.js} +1 -1
  76. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-sOU_Qtf_.js +1 -0
  77. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-CZse0pRF.js +1 -0
  78. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-DD141lVl.js +1 -0
  79. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-6h3qaEy8.js +1 -0
  80. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-D3yOgOUU.js +1 -0
  81. package/dist/assets/{railroadDiagram-RFXS5EU6--lNIlG64-TBu5nqSt.js → railroadDiagram-RFXS5EU6--lNIlG64-IX0DQMzv.js} +1 -1
  82. package/dist/assets/{requirementDiagram-TGXJPOKE-D-B0Y5Wu-D_MdSOAJ.js → requirementDiagram-TGXJPOKE-D-B0Y5Wu-7ePp-6uY.js} +1 -1
  83. package/dist/assets/{sankeyDiagram-HTMAVEWB-DlutaXDv-LV1DgYi4.js → sankeyDiagram-HTMAVEWB-DlutaXDv-DcPeO3wM.js} +1 -1
  84. package/dist/assets/{sequenceDiagram-DBY2YBRQ-BOLgtggH-DbxBoWMe.js → sequenceDiagram-DBY2YBRQ-BOLgtggH-BPKloVvU.js} +1 -1
  85. package/dist/assets/{stateDiagram-2N3HPSRC-jf_dXUEU-BRqp2FEy.js → stateDiagram-2N3HPSRC-jf_dXUEU-Bd6-ZMXE.js} +1 -1
  86. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v--iFusbAN.js +1 -0
  87. package/dist/assets/{swimlanes-5IMT3BWC-CYjtALQH-b1Ce-MJ1.js → swimlanes-5IMT3BWC-CYjtALQH-UC879FAu.js} +1 -1
  88. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-B7GGyqBW.js +8 -0
  89. package/dist/assets/{timeline-definition-FHXFAJF6-DYJ4oUm8-hoe4WWV4.js → timeline-definition-FHXFAJF6-DYJ4oUm8-ByREbTkR.js} +1 -1
  90. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-COPupObu.js +1 -0
  91. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-DebmT2Ye.js +1 -0
  92. package/dist/assets/{vennDiagram-L72KCM5P-CdKAHoek-D05wTugE.js → vennDiagram-L72KCM5P-CdKAHoek-CznsE58R.js} +1 -1
  93. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-CqYCfIUl.js +1 -0
  94. package/dist/assets/{wardleyDiagram-EHGQE667-BF6c4_CW-BfIBktNx.js → wardleyDiagram-EHGQE667-BF6c4_CW-MMgJtZmB.js} +1 -1
  95. package/dist/assets/{xychartDiagram-FW5EYKEG-CGiKngj7-Bn0CDsQy.js → xychartDiagram-FW5EYKEG-CGiKngj7-D9mjkddh.js} +1 -1
  96. package/dist/index.html +2 -2
  97. package/package.json +8 -9
  98. package/server/agents/claude.ts +12 -2
  99. package/server/backends/html.ts +7 -10
  100. package/server/backends/remoteHost/googleCalendar.spec.ts +2 -0
  101. package/server/backends/system-tasks.ts +33 -0
  102. package/server/config/app-config.ts +24 -0
  103. package/server/config/config-body.ts +1 -1
  104. package/server/config/config-routes.ts +12 -1
  105. package/server/config/env.ts +30 -0
  106. package/server/git/issue-work.ts +92 -0
  107. package/server/git/pr-footer.ts +21 -4
  108. package/server/git/repo-dirs.ts +112 -0
  109. package/server/git/worktree-pr.ts +31 -16
  110. package/server/git/worktree-routes.ts +21 -3
  111. package/server/git/worktrees.ts +87 -20
  112. package/server/index.ts +32 -31
  113. package/server/infra/fs-cleanup.ts +83 -3
  114. package/server/infra/tmux.ts +26 -0
  115. package/server/routes/app-routes.ts +16 -1
  116. package/server/routes/dir-routes.ts +2 -2
  117. package/server/routes/hook-routes.ts +4 -1
  118. package/server/routes/issue-work-routes.ts +62 -0
  119. package/server/routes/mcp-routes.ts +40 -9
  120. package/server/routes/plugin-routes.ts +38 -2
  121. package/server/routes/repo-routes.ts +16 -1
  122. package/server/routes/session-routes.ts +44 -4
  123. package/server/routes/tool-routes.ts +24 -13
  124. package/server/routes/ws-routes.ts +126 -61
  125. package/server/session/dir-session.ts +111 -0
  126. package/server/session/draft-injection.ts +86 -6
  127. package/server/session/launcher-gui-mcp.ts +21 -0
  128. package/server/session/lifecycle.ts +10 -5
  129. package/server/session/mcp-config.ts +2 -5
  130. package/server/session/partitionPending.ts +4 -0
  131. package/server/session/provider-env.ts +1 -11
  132. package/server/session/pty-connection.ts +19 -5
  133. package/server/session/pty-scan.ts +43 -0
  134. package/server/session/pty-spawn.ts +1 -35
  135. package/server/session/registry.ts +195 -0
  136. package/server/session/scheduled-chat.ts +54 -0
  137. package/server/session/session-reads.ts +2 -1
  138. package/server/session/session-resolve.ts +12 -0
  139. package/server/session/spawn-claude.ts +55 -34
  140. package/server/session/spawn-codex.ts +7 -0
  141. package/server/session/spawn-deps.ts +1 -1
  142. package/server/session/spawn-shell.ts +5 -1
  143. package/server/session/task-push.ts +5 -2
  144. package/server/session/taskPushRules.ts +12 -2
  145. package/server/session/tmux-size-sync.ts +8 -2
  146. package/server/session/tool-store.ts +13 -1
  147. package/server/session/types.ts +2 -8
  148. package/server/session/worktree-session-limit.ts +75 -0
  149. package/server/session/ws-frames.ts +6 -11
  150. package/server/skills/mulmoterminal-bug-report/SKILL.md +1 -1
  151. package/server/skills/mulmoterminal-model/SKILL.md +0 -2
  152. package/Dockerfile.sandbox +0 -30
  153. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-BREDaItQ.js +0 -1
  154. package/dist/assets/channel-7wUqSdoX-CS5090lk.js +0 -1
  155. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-DrbnF3u0.js +0 -1
  156. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-DrbnF3u0.js +0 -1
  157. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-DX3AmKtr.js +0 -1
  158. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-DEo0X0zh.js +0 -1
  159. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-B1sNFZqk.js +0 -1
  160. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-v8I7oFiq.js +0 -1
  161. package/dist/assets/index-Fs9P1rOl.css +0 -1
  162. package/dist/assets/info-DKCQHKI2-Dplx5kMp-CtJA9Anl.js +0 -1
  163. package/dist/assets/material-symbols-outlined-D4PiVfdc.woff2 +0 -0
  164. package/dist/assets/packet-7NZHBO7P-CQI3flND-CIe7lXlT.js +0 -1
  165. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-XMBuzZo9.js +0 -1
  166. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-BKTDPHbI.js +0 -1
  167. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-BqUDb7_4.js +0 -1
  168. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-mbw2glBD.js +0 -1
  169. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-RcP3qRz4.js +0 -1
  170. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-zyLxt_bL.js +0 -1
  171. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-qCgJwWlN.js +0 -1
  172. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-CjXzlY9S.js +0 -8
  173. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-CXRd3aAt.js +0 -1
  174. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-BgWzZ7nd.js +0 -1
  175. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-DoMXWfpE.js +0 -1
  176. package/server/infra/sandbox.ts +0 -384
package/README.md CHANGED
@@ -81,16 +81,16 @@ turns the movement off). Click a row to swap the enlarged terminal.*
81
81
  ### What it is, under the hood
82
82
 
83
83
  Each session runs as a real PTY on the server (the agent CLI in a pseudo-terminal) and is
84
- streamed to an [xterm.js](https://xtermjs.org/) terminal in the browser over a WebSocket. A
85
- sidebar lists every session for the project and reflects, in real time, which are **working**
84
+ streamed to an [xterm.js](https://xtermjs.org/) terminal in the browser over a WebSocket. The
85
+ **cockpit roster** lists every session and reflects, in real time, which are **working**
86
86
  (the agent is thinking, a spinner), which are **waiting on you** (a permission prompt or a
87
87
  question — an amber dot; nothing proceeds until you answer) and which are **finished with output
88
88
  you haven't seen** (a green dot) — driven by Claude/Codex activity hooks the server injects per
89
89
  spawn. The horizontal tab bar carries the same two dots.
90
90
 
91
- ![Single view — one agent in focus, terminal on the left and a GUI panel on the right](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/single-view.png)
91
+ ![One agent enlarged, with the cockpit roster beside it](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/cockpit-roster.png)
92
92
 
93
- *Besides the grid there's a **single view** for focusing on one agent: the conversation/terminal on the left, and a **GUI panel** ("Canvas") on the right where the agent's tool calls render as documents, forms, charts, images, and HTML not just printed text. Switch between the two with the chat / grid icons in the toolbar. **The app opens on the grid** (`/`); the single view has its own URL, `/chat`, so you can bookmark either.*
93
+ *To focus on one agent, **zoom its cell**: it takes the window, and a pane opens beside it — the **cockpit roster** above, or the **GUI panel** ("Canvas"), where that agent's tool calls render as documents, forms, charts, images, and HTML rather than printed text. **The app opens on the grid** (`/`, settling on `/terminals`), which is the only view; 3.x had a separate single view at `/chat` and 4.0.0 removed it, so that URL now lands on the grid like any other.*
94
94
 
95
95
  **Inserting a file path** — like a native terminal, you can put a file's absolute path into
96
96
  the prompt: **drag a file** onto the terminal, or click the **file button** in the terminal
@@ -102,8 +102,7 @@ A drag inserts the file's **own** path where the browser exposes one via `file:/
102
102
  withholds it — **Chrome**, and every browser when MulmoTerminal is open **from another
103
103
  machine**, where a local path would name nothing on the host — the file's bytes are sent
104
104
  instead, saved to a private per-session directory under the OS temp dir, and *that* path is
105
- inserted. The session is granted that directory at launch (Claude Code's `--add-dir`, bind-mounted
106
- in the sandbox too), so the agent reads it without a permission prompt; the copies are removed
105
+ inserted. The session is granted that directory at launch (Claude Code's `--add-dir`), so the agent reads it without a permission prompt; the copies are removed
107
106
  when the session ends, and any left by a crash are swept at the next start. Up to 110 MiB per
108
107
  file — the same ceiling as a phone attachment. **A session already running when you upgrade
109
108
  was launched without that grant**, so drops into it still prompt; new sessions don't.
@@ -212,7 +211,6 @@ Needs **Node ≥ 22.9**, plus these CLIs on your `PATH`:
212
211
  | **Required** | `gh` | the cross-repo **PRs & Issues** view and one-click PR creation — it uses your `gh` login, so no token is stored | [cli.github.com](https://cli.github.com), then `gh auth login` |
213
212
  | Recommended | `tmux` | [session persistence](#session-persistence-tmux) — terminals survive a server restart | `brew install tmux` · `sudo apt install tmux` · `sudo dnf install tmux` · no native Windows build (falls back to plain PTYs) |
214
213
  | Optional | `codex` | [Codex sessions](#agents-claude--codex) in a cell, alongside Claude | `npm i -g @openai/codex` |
215
- | Optional | `docker` | the experimental [Docker sandbox](#docker-sandbox-experimental-single-view) | [docs.docker.com](https://docs.docker.com/get-started/get-docker/) |
216
214
  | Optional | `ffmpeg` | video rendering from the [mulmo-script panel](#wiki-collections--the-gui-panel) (its plugin ships enabled) | `brew install ffmpeg` · `sudo apt install ffmpeg` · `sudo dnf install ffmpeg` |
217
215
  | Optional | `ollama` | [`claude-ollama`](https://receptron.github.io/mulmoterminal/guide/en/claude-ollama.html) — Claude Code against a fully local model | [ollama.com/download](https://ollama.com/download) |
218
216
 
@@ -294,7 +292,6 @@ The launcher detects it and prints the exact, OS-appropriate removal command; ru
294
292
  - [Why a PTY?](#why-a-pty)
295
293
  - [Agents: Claude & Codex](#agents-claude--codex)
296
294
  - [Session persistence (tmux)](#session-persistence-tmux)
297
- - [Docker sandbox (experimental, single view)](#docker-sandbox-experimental-single-view)
298
295
  - [Tech stack](#tech-stack)
299
296
  - [Configuration](#configuration)
300
297
  - [Running](#running)
@@ -322,6 +319,7 @@ The launcher detects it and prints the exact, OS-appropriate removal command; ru
322
319
  - [Session discovery & titles](#session-discovery--titles)
323
320
  - [Project structure](#project-structure)
324
321
  - [Testing](#testing)
322
+ - [Contributing](#contributing)
325
323
 
326
324
  ---
327
325
 
@@ -387,7 +385,7 @@ today — **Claude Code** (the default), **Codex**, and **Antigravity** (`agy`).
387
385
  [closing summary](#closing-summary) instruction.
388
386
  - **Codex** — spawned as `codex` (override with `CODEX_BIN`; `CODEX_MODEL` sets
389
387
  `--model`). Codex runs on its own WebSocket (`/ws/codex`) and its sessions appear in the
390
- sidebar next to Claude's. Because Codex only mints its rollout id **after** the first
388
+ cockpit roster next to Claude's. Because Codex only mints its rollout id **after** the first
391
389
  turn, the server watches `~/.codex/sessions/**/rollout-*.jsonl` (home overridable via
392
390
  `CODEX_HOME`) and maps the new rollout to the session — attributed only when it's
393
391
  unambiguous, never by "newest wins". Resume reattaches a live PTY, adopts a surviving
@@ -411,13 +409,9 @@ today — **Claude Code** (the default), **Codex**, and **Antigravity** (`agy`).
411
409
  per directory and shared by every session running there — and reaches the bridge through the agy
412
410
  process's own environment instead.
413
411
 
414
- The Docker sandbox does NOT cover agy: it stays claude-only until `buildDockerRunArgs` is
415
- generalized (see `plans/feat-multi-agent-support.md`, PR#5). `agy` also ships as a standalone
416
- binary rather than an npm package, so the sandbox image has nothing to install.
417
-
418
- **Choosing an agent.** The single view has a **New Codex session** button; each grid
419
- cell's launch form carries a **Claude / Codex / Antigravity / Shell** toggle, and the
420
- Collections browser a **Claude / Codex / Antigravity** one (your choice is remembered).
412
+ **Choosing an agent.** Each grid cell's launch form carries a **Claude / Codex /
413
+ Antigravity / Shell** toggle, and the Collections browser a **Claude / Codex /
414
+ Antigravity** one (your choice is remembered).
421
415
  **Shell** is not an agent: it runs your OS default shell (`$SHELL`, or `/bin/sh`) in the
422
416
  chosen directory, with nothing to install and nothing to configure. It starts a launcher
423
417
  cell, so it has no model, no MCP registration, and no worktree — those rows disappear
@@ -431,7 +425,7 @@ serves. A directory sets its default in `.mulmoterminal.json` (`provider` / `mod
431
425
  each grid cell's launch form has a **MODEL** select that overrides it for one session,
432
426
  listing ~27 curated models with the measured pass rate of a real tool-using task beside
433
427
  each. A provider whose token can't be resolved **refuses to start** rather than falling
434
- 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:
428
+ back to Anthropic. Full walkthrough — setup, the measured model list, adding your own models, troubleshooting:
435
429
  [Using another model via OpenRouter](https://receptron.github.io/mulmoterminal/guide/en/providers.html).
436
430
 
437
431
  **Skills for Codex.** Codex has no `/<slug>` slash commands, so on session setup
@@ -470,46 +464,6 @@ detects `tmux` on `PATH` at startup and uses it automatically when present.
470
464
 
471
465
  ---
472
466
 
473
- ## Docker sandbox (experimental, single view)
474
-
475
- Set **`MULMOTERMINAL_SANDBOX=1`** (and have Docker running) to run the **single-view**
476
- Claude session inside a container instead of on the host, while Claude still reaches the
477
- app's GUI MCP + activity hooks over `host.docker.internal`. The `mulmoterminal-sandbox`
478
- image is **built automatically** on first launch from the shipped `Dockerfile.sandbox`
479
- (~1 min, once; rebuilt only when that file changes). Override the name with
480
- `MULMOTERMINAL_SANDBOX_IMAGE`. If the image can't be built (e.g. Docker down), the session
481
- falls back to the host spawn — no cryptic failure.
482
-
483
- This **contains** Claude — it can't reach the host filesystem outside the mounts, host
484
- processes, or arbitrary host ports. It is **not full isolation**: the **workspace** and
485
- **`~/.claude`** are bind-mounted **read-write** by design (so Claude edits your project,
486
- and transcripts interoperate with host sessions), so those specific paths stay mutable
487
- from inside. The sandbox is **non-persistent** (the container is
488
- `--rm`, tied to the session), **opt-in and single-view only** — the grid keeps its host +
489
- tmux path, and with the flag unset (or Docker unavailable) everything runs on the host
490
- exactly as before. **macOS only** for now — on Linux (bind-mount uid ownership) and
491
- Windows (host paths aren't valid Linux container paths) it falls back to the host spawn;
492
- both are follow-ups. Adding arbitrary user MCP servers to the sandbox is in progress
493
- (see #202).
494
-
495
- **Authentication (macOS).** Claude's live login token lives in the macOS **Keychain**,
496
- which the container can't read (mounting `~/.claude` alone isn't enough — its
497
- `.credentials.json` is often absent or stale). On each sandbox spawn MulmoTerminal exports
498
- the current credential to a per-session `~/.mulmoterminal/sandbox/creds-<id>.json`
499
- (mode `0600`, removed when the session ends) and mounts it **read-only** over the
500
- container's `~/.claude/.credentials.json`; your host `~/.claude` is never modified. If
501
- you've never logged in on the host, run `claude` once first — otherwise the server logs a
502
- warning and the container shows "Not logged in".
503
-
504
- **Host credentials (opt-in).** By default the sandbox has no host credentials. To let the
505
- sandboxed Claude use `gh`/`git`, set **`SANDBOX_MOUNT_CONFIGS=gh,gitconfig`** — a **fixed
506
- allowlist** (you pick names, never arbitrary paths): `gh` mounts `~/.config/gh` read-only
507
- and passes a `GH_TOKEN` (from `gh auth token`, since macOS keeps it in the Keychain), and
508
- `gitconfig` mounts `~/.gitconfig` read-only. Set **`SANDBOX_SSH_AGENT_FORWARD=1`** to
509
- forward the SSH agent socket (the keys never enter the container). Both are read only when
510
- building the sandbox spawn, so they have no effect unless `MULMOTERMINAL_SANDBOX` is on.
511
-
512
- ---
513
467
 
514
468
  ## Tech stack
515
469
 
@@ -542,7 +496,7 @@ the `claude` / `codex` sessions themselves.
542
496
  | `PORT` | `34567` | Backend HTTP/WebSocket port (prod: the URL you open). |
543
497
  | `CLIENT_PORT` | `6856` | Vite dev-server port (dev only: the URL you open with `yarn dev`). |
544
498
  | `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. On Windows a bare name is resolved on `PATH` before it reaches the PTY layer (which matches file names exactly): to the `.exe` when there is one, otherwise to the `.cmd` shim an npm-global install leaves, run through `cmd.exe`. |
545
- | `CLAUDE_CWD` | current dir | Working directory each `claude` PTY runs in; determines which project's sessions the sidebar lists. Via `npx mulmoterminal@latest` it defaults to the directory you ran the command from (override with `--cwd <dir>`, relative allowed); when the server is run directly it falls back to `~/mulmoclaude`. A value read from `.env` must be an absolute path (`~` is not expanded). |
499
+ | `CLAUDE_CWD` | current dir | Working directory each `claude` PTY runs in; determines which project's sessions are listed. Via `npx mulmoterminal@latest` it defaults to the directory you ran the command from (override with `--cwd <dir>`, relative allowed); when the server is run directly it falls back to `~/mulmoclaude`. A value read from `.env` must be an absolute path (`~` is not expanded). |
546
500
  | `CLAUDE_PERMISSION_MODE` | `auto` | Permission mode passed to each `claude` spawn. |
547
501
  | `MT_TITLE_MODEL` | `haiku` | Model used for the cell header's AI title (a cheap/fast model summarizing the recent turns). Accepts a `--model` alias or a full model id. |
548
502
  | `CODEX_BIN` | `codex` | The Codex CLI binary to spawn. |
@@ -558,10 +512,8 @@ the `claude` / `codex` sessions themselves.
558
512
  | `GEMINI_IMAGE_MODEL` | `gemini-3.1-flash-image-preview` | Model used for image generation (needs `GEMINI_API_KEY`). The default is a **preview** model Google schedules for retirement around mid-2026, so pin a stable one here (e.g. `gemini-2.5-flash-image`) rather than waiting for a code change. |
559
513
  | `WAIT_REAP_GRACE_MS` | `1800000` | How long a **waiting** background session is kept before it's auto-reaped (`0` or negative = never). |
560
514
 
561
- The Docker-sandbox variables (`MULMOTERMINAL_SANDBOX`, `MULMOTERMINAL_SANDBOX_IMAGE`,
562
- `SANDBOX_MOUNT_CONFIGS`, `SANDBOX_SSH_AGENT_FORWARD`) and the update-check opt-outs
563
- (`MULMOTERMINAL_NO_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`) are covered in
564
- [Docker sandbox](#docker-sandbox-experimental-single-view) and [Install & run](#install--run).
515
+ The update-check opt-outs (`MULMOTERMINAL_NO_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`) are
516
+ covered in [Install & run](#install--run).
565
517
 
566
518
  Example `.env` (gitignored):
567
519
 
@@ -585,9 +537,10 @@ The Settings modal (⚙) persists per-user UI choices to `~/.mulmoterminal/confi
585
537
  | `soundKinds` | Which moments beep — see [Notification sounds](#notification-sounds). Defaults to `["finished","waiting"]`; the other kinds are opt-in. |
586
538
  | `sounds` | Per-kind sound: `{ "waiting": "preset:coin" }`. A `preset:<id>` reference or an absolute path; a kind with no entry falls back to `soundFile`. |
587
539
  | `prRepos` | `owner/repo` entries whose open PRs/issues the cross-repo **PRs & Issues** view aggregates (via your `gh` login). |
540
+ | `repoDirs` | `{ "owner/repo": "/abs/path" }` — which local clone work on a repo starts in, when you keep several side by side. Only the *choice* is stored; which clones exist is re-derived from `cwdPresets` on every read, and an entry that no longer names a clone of that repo is ignored. |
588
541
  | `launchers` | `{ label, command }` entries offered in a grid cell's launcher besides the agents — any interactive command. A plain shell needs no entry: the launch form's **Shell** toggle opens `$SHELL` unconfigured. |
589
542
  | `quickCommands` | `{ label, text, agents? }` phrases the **phone** offers as chips on a session's terminal view. Tapping one puts `text` in the input box; it is not sent until you press send. `agents` (`"claude"` / `"codex"` / `"shell"`) scopes a chip to session kinds — omit it to offer the chip everywhere. Empty by default. |
590
- | `userMcpServers` | `{ id, url }` HTTP MCP servers merged into the **single-view** Claude session's `--mcp-config` (a `localhost` URL is reached over `host.docker.internal` in the Docker sandbox). Takes effect on the next session. |
543
+ | `userMcpServers` | `{ id, url }` HTTP MCP servers merged into the `--mcp-config` of the Claude sessions that carry the full GUI MCP — a cell whose working directory is the **workspace**, and any session the server starts itself (the phone, a scheduled task). A cell in a project directory loads its own MCP config instead. Takes effect on the next session. |
591
544
  | `buttons` | Header action buttons — see [Header buttons](#header-buttons). Omit to keep the defaults; set to replace them. |
592
545
  | `chips` | Header info chips (`dir` / `git` / `work` / `diff` / `ctx` / `usage` / `status` / `tools`, or custom text). Omit to keep the default set; `[]` hides all built-ins. `work` shows which PR / issue the cell is on (`#977 → #966`) and clears itself when the PR merges — see the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#work-chip). |
593
546
  | `pushEnabled` | `true` to send a **Web Push** to your registered devices. Off by default; only sends while the **RemoteHost** channel is connected (see below). The master switch — `pushKinds` picks which moments. |
@@ -680,6 +633,9 @@ disconnected, or with no device registered, the toggle is a no-op.
680
633
  to register a built-in scheduled task. Every `worklogIntervalHours` (default 6) it spawns
681
634
  a Claude session that reviews the work you did across **all your saved working dirs**
682
635
  (`cwdPresets`) since it last ran, and writes it up as a short manager-style report.
636
+ It runs as a **background worker**: behind the Background filter, never bold, and it takes
637
+ no grid cell, so an hourly task cannot fill the grid. Web **Push** still fires for it —
638
+ being quiet means out of the way, not unreachable, and it runs while you are away.
683
639
  Multiple clones/worktrees of the same repo (e.g. `myapp`, `myapp2`) are **merged into one
684
640
  per-repository section**, each covering what problem was addressed, what got solved, what's
685
641
  still in progress, and — mined from the transcripts — decisions that were only *discussed
@@ -735,7 +691,7 @@ malformed file is ignored.
735
691
  | ------------ | ------- |
736
692
  | `name` | Label shown as a badge in the terminal/cell header. |
737
693
  | `badgeColor` | Badge background color (`#rrggbb`); text auto-contrasts. |
738
- | `headerColor` | Header **background** color (`#rrggbb`) — the grid cell's header row and the terminal's own header row (grid row 2 + single view). While a terminal is working/blocked the status tint still shows; the custom color applies when idle. |
694
+ | `headerColor` | Header **background** color (`#rrggbb`) — the grid cell's header row and the terminal's own header row (grid row 2). While a terminal is working/blocked the status tint still shows; the custom color applies when idle. |
739
695
  | `headerTextColor` | Header **text** color (`#rrggbb`) — the dir path, title, and prompt. |
740
696
  | `cellColor` | Cell **body background** color (`#rrggbb`) — the frame around the terminal. |
741
697
  | `cellBorderColor` | Cell **border** color (`#rrggbb`). The status frame (working/blocked) still overrides it while active. |
@@ -749,7 +705,7 @@ malformed file is ignored.
749
705
  | `sound` | Attention sound for this directory's sessions, a path **relative to the directory** (served at `GET /api/dir-sound`). The fallback for every kind. |
750
706
  | `sounds` | Per-kind override of `sound`: `{ "command-failed": "preset:gong" }`. Each value is a `preset:<id>` or a directory-relative path, under the same confinement. |
751
707
  | `appendSystemPrompt` | Whether this directory's Claude sessions are asked to end a reply with a **closing summary** (see [Closing summary](#closing-summary)). Omit to follow the global `appendSystemPrompt`, which is on; `true` / `false` here outranks it. Read per spawn, so a new session in this directory picks up an edit without a restart. |
752
- | `addDirs` | Extra directories this project's Claude sessions may read and edit — the terminal-side equivalent of opening several folders in one VS Code workspace, via Claude Code's `--add-dir`. Relative entries resolve against **this file's directory** (`"../shared-lib"`), a path that doesn't exist is dropped, max 16. In the Docker sandbox each one is bind-mounted too, so the grant is real inside the container — which widens the sandbox on purpose. Claude only: codex has no equivalent flag and ignores the key. |
708
+ | `addDirs` | Extra directories this project's Claude sessions may read and edit — the terminal-side equivalent of opening several folders in one VS Code workspace, via Claude Code's `--add-dir`. Relative entries resolve against **this file's directory** (`"../shared-lib"`), a path that doesn't exist is dropped, max 16. Claude only: codex has no equivalent flag and ignores the key. |
753
709
 
754
710
  **Security.** `sound` and every `sounds` entry are directory-relative paths only — absolute
755
711
  paths and any `../` that escapes the directory are rejected, and the path is never taken from the
@@ -821,11 +777,11 @@ it survives grid page switches and reconnects, and its dot shows running vs. exi
821
777
  has no Claude hooks, so no blocked/done states).
822
778
 
823
779
  Every running terminal's header also has a **▶ Run ▾** dropdown (next to the
824
- connection status), in both the single view and each grid cell — but **only when the
780
+ connection status) — but **only when the
825
781
  open project has scripts** (no `script.json`, no button). It lists the **open
826
782
  project's** `script.json` — the directory that terminal runs in — and launches the
827
- picked script in a **spare grid cell** (reusing an open launcher, else a new one),
828
- switching to the grid from the single view so you can watch it. So you can start a
783
+ picked script in a **spare grid cell** (reusing an open launcher, else a new one), so
784
+ you can watch it. So you can start a
829
785
  dev server or tests for the project you're working in without disturbing the
830
786
  session that's running.
831
787
 
@@ -870,7 +826,7 @@ the last 32 KB of output. See
870
826
  ## Skills (Skill menu)
871
827
 
872
828
  Next to the **▶ Run ▾** dropdown, every running terminal's header has a **⚡ Skill ▾**
873
- dropdown — in both the single view and each grid cell, and **only when the open
829
+ dropdown — and **only when the open
874
830
  project has skills** (nothing discovered, no button). It lists the
875
831
  [Claude skills](https://docs.claude.com/en/docs/claude-code/skills) discoverable for
876
832
  that terminal's directory — both **project scope** (`<dir>/.claude/skills`) and **user
@@ -956,7 +912,38 @@ and the cell launches its agent inside a fresh
956
912
  [git worktree](https://git-scm.com/docs/git-worktree) on a new `agent/<slug>` branch — a
957
913
  separate working tree that shares the repo's `.git`, so several agents can work the same
958
914
  repo without colliding. Worktrees live under `~/.mulmoterminal/worktrees/` (override with
959
- `MULMOTERMINAL_HOME`), and existing ones are listed for reuse.
915
+ `MULMOTERMINAL_HOME`), and existing ones are listed below the field.
916
+
917
+ **One worktree, one session.** A worktree is tied to a branch, so it is never started
918
+ twice: a listed row **resumes** that worktree's session when it has one, and **starts** one
919
+ only when it has none. A row whose session is open in another terminal reads `in use` and
920
+ cannot be clicked — close it there first. The refusal follows the *directory*, not the row:
921
+ the same worktree reached by pasting its path into **WORKING DIRECTORY**, or by a recent-dir
922
+ chip, will not launch either — and the **server** refuses the spawn whichever client asks,
923
+ so a path spelled another way (a trailing slash, a symlink) does not slip past.
924
+
925
+ What the limit covers is an **agent**: Claude, Codex or Antigravity, including an **OR
926
+ LAUNCH** command that runs one of them. A **Shell**, and a launcher that runs anything else
927
+ (`yarn dev`, `lazygit`, `htop`), stays free — a worktree an agent is working in is exactly
928
+ where you want those.
929
+
930
+ The same holds for **OR RESUME HERE**: a session someone is holding is listed with `● open`
931
+ and refused, where before it could be confirmed away — which detached whoever had it.
932
+ "Someone" means any terminal anywhere, including another browser tab and a second
933
+ `mulmoterminal` process on this machine: the server answers from its own PTY table plus
934
+ tmux, not from what one page can see.
935
+
936
+ A worktree started **from an issue** gets an `issue/<N>-<slug>` branch instead. The number
937
+ in the name is what later tells the app which issue the work belongs to: the ⧉ Open PR
938
+ button puts `Fixes #<N>` in the PR body, and the branch chip, the issue work comment and
939
+ the merge-time auto-close all read the same number rather than guessing at it.
940
+
941
+ That path also **fetches first and forks from `origin/<base>`**, because several clones of
942
+ one repo often run side by side and only the one being worked in gets pulled — forking from
943
+ the local branch would start the work on however old that clone happens to be. A local base
944
+ that already contains the remote wins anyway (it is a superset, so nothing is lost), and
945
+ with no remote reachable the local branch is used and the worktree is still created.
946
+ Typing a task name yourself keeps the local base it has always used, with no fetch.
960
947
 
961
948
  ![An empty cell's launch form — choose the agent, working directory, or a worktree](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/grid-launch-form.png)
962
949
 
@@ -980,6 +967,24 @@ PRs show a CI-rollup / review-decision / draft badge; each repo lists its latest
980
967
  issues. Rows are real links, per-repo errors don't sink the view, and the two lists load
981
968
  independently. Backed by `GET /api/prs` and `GET /api/issues`.
982
969
 
970
+ **Starting work from an issue row.** Each issue row carries a **▶** button that does the setup in
971
+ one click: read the issue, cut an `issue/<number>-<slug>` worktree in your clone of that repo, and
972
+ open Claude there as a grid cell with the issue **typed into its input box but not sent**. The
973
+ prompt is seeded server-side as a *draft* (`server/session/draft-injection.ts`), which waits for
974
+ claude's input box to be ready — text pushed in before that lands in the scrollback instead. A repo
975
+ with several clones asks which one the first time and remembers the answer; a repo with no clone
976
+ here disables the button and says why. Backed by `POST /api/issues/start`.
977
+
978
+ **Which clone a repo's work happens in.** `GET /api/repo-dirs` answers the reverse of the
979
+ GitHub link a cell already shows: given `owner/repo`, which of your saved directories are
980
+ clones of it. The candidates are derived from your directory presets by reading each one's
981
+ `origin` — there is no second list to keep in step — and are ordered by each directory's
982
+ `orderPriority`, then by path. Several clones of one repo commonly run side by side, so the
983
+ answer is a choice rather than a lookup; once you make it, `repoDirs` in the config records
984
+ `owner/repo` → the chosen path and it is used from then on. A recording is dropped if the
985
+ directory is no longer a saved clone of that repo, and a repo with no clone here is simply
986
+ absent from the answer — which is how a caller learns work cannot start on it.
987
+
983
988
  ---
984
989
 
985
990
  ## Cost & token usage
@@ -1025,11 +1030,10 @@ generated images, charts, HTML, and collection cards. Each result is drawn by it
1025
1030
  own Vue view inside a Shadow-DOM `PluginFrame` (so a plugin's bundled CSS can't leak),
1026
1031
  mirrors the active session, and replays history on re-select. Plugins reach the agent over
1027
1032
  an **in-process MCP server** served per session at `POST /api/mcp/:sessionId` (server name
1028
- `mulmoterminal-gui`) which works from the host *or* the Docker sandbox (over
1029
- `host.docker.internal`). Which plugins load is gated by `plugins/plugins.json`; the shipped
1033
+ `mulmoterminal-gui`). Which plugins load is gated by `plugins/plugins.json`; the shipped
1030
1034
  set includes markdown, form, image generation (needs `GEMINI_API_KEY`), chart, HTML,
1031
1035
  collection, and mulmoscript (MulmoCast video/slides/PDF playback) views. You can also merge
1032
- your **own HTTP MCP servers** into the single-view session via Settings → `userMcpServers`.
1036
+ your **own HTTP MCP servers** into a workspace session via Settings → `userMcpServers`.
1033
1037
 
1034
1038
  **Wiki.** The toolbar **Wiki** button opens a read-only browser over `<workspace>/data/wiki/`
1035
1039
  — an **index** (tag-filterable page catalog), rendered **pages** with `[[wiki links]]` and
@@ -1063,6 +1067,17 @@ Favorited collections get their own toolbar buttons.
1063
1067
 
1064
1068
  ![Zoom — one agent enlarged, the others as a filmstrip along the bottom](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/grid-zoom.png)
1065
1069
 
1070
+ - **Set a terminal aside** — the moon button in a cell's header **sinks** it: the tile, its
1071
+ filmstrip thumbnail and its cockpit-roster row all fade, and the working dot stops pulsing.
1072
+ The session stays **connected and keeps its whole history** — this is what to reach for
1073
+ instead of `/clear`-ing a cell you are done with for now, which resets the conversation just
1074
+ to change how the cell looks. The setting survives a reload. **Enlarging it keeps it faded** —
1075
+ that is how you read a set-aside session without waking it, and its roster row keeps the blue
1076
+ "you are here" edge either way — while **typing into it wakes it**, so nothing has to be undone
1077
+ by hand. Clicking or scrolling to read it does *not* wake it, even though a mouse-tracking agent
1078
+ receives those as input. A cell that **stops for a permission prompt comes back to full strength
1079
+ on its own**, so setting one aside can never hide a session that is waiting on you; a merely *finished* turn
1080
+ does not, since that is the expected outcome of setting a running agent aside.
1066
1081
  - **Timeline** (🕘) — a read-only per-session activity timeline (tools run, newest first),
1067
1082
  from `GET /api/transcript/timeline`.
1068
1083
  - **Bring another cell's turn here** (💬) — pick another terminal in the grid and its
@@ -1286,8 +1301,10 @@ same-origin-guarded.
1286
1301
  | `GET /api/git-status?cwd=` | `{ repo, branch, detached, dirty, ahead, behind, upstream }`. |
1287
1302
  | `POST /api/git-remote` | The dir's GitHub repo URL (for the header GitHub menu). |
1288
1303
  | `GET /api/worktrees?cwd=` · `GET /api/worktrees/diff?cwd=` | List managed worktrees / diff one vs its base. |
1289
- | `POST /api/worktrees/create` · `/remove` · `/push` · `/pr` | Create on `agent/<slug>`, remove (managed root only), push, open a PR (`gh`, else compare URL). |
1304
+ | `POST /api/worktrees/create` · `/remove` · `/push` · `/pr` | Create on `agent/<slug>` — or, with `issue: <N>`, on `issue/<N>-<slug>` forked from a freshly fetched `origin/<base>`; remove (managed root only), push, open a PR (`gh`, else compare URL). |
1290
1305
  | `GET /api/prs` · `GET /api/issues` | Open PRs / issues across the configured `prRepos` (via `gh`). |
1306
+ | `GET /api/repo-dirs` | Which saved directories clone which GitHub repo, ordered, with the recorded choice per repo. |
1307
+ | `POST /api/issues/start` | Cut an issue's worktree in one of that repo's known clones and spawn a session there, seeded with the issue as a draft. |
1291
1308
  | `GET /api/github/star` · `POST /api/github/star` | Whether you have starred MulmoTerminal, and star it (via `gh`). `starred: null` means `gh` could not answer, and hides the button. |
1292
1309
 
1293
1310
  **Workspace views**
@@ -1341,6 +1358,10 @@ connection (or reattach to an existing background PTY).
1341
1358
  background PTY exists for `<id>`, the socket reattaches to it (and its recent
1342
1359
  output buffer is replayed); otherwise the server spawns
1343
1360
  `claude --resume <id> --settings <hooks>`.
1361
+ - `&cols=<n>&rows=<n>` — the terminal's geometry, on every endpoint that starts a PTY. The
1362
+ PTY is created at it instead of the 120x30 default, so nothing is ever drawn at a size the
1363
+ browser didn't ask for. Out-of-range values are ignored (same bounds as a `resize` frame),
1364
+ and a connection that sends none keeps the default until its first `resize`.
1344
1365
 
1345
1366
  **Server → client** (JSON text frames):
1346
1367
 
@@ -1371,7 +1392,7 @@ Two more raw WebSockets share the `/ws` frame format (`output` / `input` / `resi
1371
1392
  - **`/ws/codex?session=<id>&cwd=<dir>&gui=<0|1>`** — a **Codex** agent PTY (see
1372
1393
  [Agents: Claude & Codex](#agents-claude--codex)). Like `/ws` it sends a `session` frame
1373
1394
  with the id and reattaches to a live or tmux-backed session on resume. `gui=0` (grid
1374
- cells) omits the GUI MCP and keeps the session out of the sidebar.
1395
+ cells) omits the GUI MCP and marks the session a grid terminal.
1375
1396
  - **`/ws/launch?session=<id>&cwd=<dir>&launcher=<index>`** — a **launch command** PTY (a
1376
1397
  plain shell, `codex`, or any command configured in Settings → Launch commands). Unlike a
1377
1398
  Run-menu script it's **persistent and reattachable** (survives page switches /
@@ -1484,15 +1505,17 @@ Key rules:
1484
1505
  duplicate `claude`.
1485
1506
  - **One live viewer per session**: a session is bound to a single socket. Opening
1486
1507
  it in a second place (another tab, or another grid cell pointed at the same dir)
1487
- reattaches there and **supersedes** the first, which detaches. To avoid doing
1488
- this by accident, a grid launcher's resume list **flags rows already open in
1489
- another terminal** (`● open`) and **asks for confirmation** before taking one
1490
- over.
1491
- - Brand-new sessions appear in the sidebar **immediately** (before their `.jsonl`
1508
+ reattaches there and **supersedes** the first, which detaches. So a launcher's
1509
+ resume list **refuses** a session that is open anywhere (`● open`) rather than
1510
+ offering to take it over — and the server answers "anywhere" from its own PTY
1511
+ table plus tmux, so another browser tab and a second `mulmoterminal` process
1512
+ count too.
1513
+ - Brand-new sessions are listed **immediately** (before their `.jsonl`
1492
1514
  exists) via the in-memory `knownSessions` registry + a `created` push; an
1493
1515
  unused one disappears when its PTY is reaped.
1494
1516
  - **Background workers get their own filter.** A session nobody started by hand —
1495
- a collection's scheduled refresh, or a plugin's `spawnBackgroundChat`
1517
+ a collection's scheduled refresh, a **user scheduled task** (the dev worklog and
1518
+ anything else the scheduler runs), or a plugin's `spawnBackgroundChat`
1496
1519
  `hidden: true` — is listed under the **Background** chip instead of among the
1497
1520
  chats, so a refresh schedule doesn't fill the history. It stays openable (a
1498
1521
  MulmoTerminal session is a live terminal, so a row you can't reach is a process
@@ -1554,9 +1577,6 @@ Which sections `--append-system-prompt` ends up carrying is decided in
1554
1577
  `server/agents/appended-prompt.ts`: this one and the `prWorkdirFooter` clone line are separate
1555
1578
  settings on the same flag, and with both off the flag is not passed at all.
1556
1579
 
1557
- Passed inline rather than as `--append-system-prompt-file` for the same reason `--settings`
1558
- is: the sandbox spawn runs in a container that cannot read a host path.
1559
-
1560
1580
  Codex sessions are unaffected — the CLI has no equivalent flag.
1561
1581
 
1562
1582
  ---
@@ -1603,7 +1623,7 @@ Every tier above says what the **agent** said, which stops answering "which cell
1603
1623
  once several sessions are open. So a cell header also takes a **note you write yourself**: the
1604
1624
  pencil button beside the header text opens a one-line box (Enter saves, Esc cancels, clicking
1605
1625
  away saves). While a note is set it *replaces* the header line — the title it displaced stays in
1606
- the tooltip — and it becomes the session's title in the sidebar list and on the phone's roster
1626
+ the tooltip — and it becomes the session's title in the launcher's session list and on the phone's roster
1607
1627
  too, so one session goes by one name everywhere.
1608
1628
 
1609
1629
  Notes are capped at 200 characters and folded to a single line. They are stored per **session
@@ -1635,7 +1655,7 @@ server/
1635
1655
  gh.ts, prs.ts, issues.ts, pr-for-branch.ts, worktrees.ts, worktree-*.ts
1636
1656
  files/ files-browse.ts (contained tree read/write), pick-file.ts,
1637
1657
  open-dir.ts, scripts.ts (Run-menu script.json loader)
1638
- infra/ process/transport/misc: tmux.ts, tmux-routes.ts, sandbox.ts,
1658
+ infra/ process/transport/misc: tmux.ts, tmux-routes.ts,
1639
1659
  pubsub.ts (socket.io /ws/pubsub), spa-fallback.ts, host-tools.ts,
1640
1660
  plugins-registry.ts, web-push.ts, install-bundled-skills.ts, accounting-tool.ts
1641
1661
  mcp/ per-session MCP broker
@@ -1685,7 +1705,23 @@ vitest.config.ts jsdom test environment
1685
1705
  yarn test
1686
1706
  ```
1687
1707
 
1688
- `src/components/Sidebar.spec.ts` covers the sidebar: rendering the server's
1689
- session list, the working dot, the `waiting` bold state, refetching on a pub/sub
1690
- push, and emitting `select` on click. The pub/sub composable and `fetch` are
1691
- mocked so the tests run without a server.
1708
+ `test/src/components/` covers the roster and the launcher's session list:
1709
+ `CockpitHeader.spec.ts`, `rosterPhase.spec.ts` and `rosterAlertClasses.spec.ts` for
1710
+ what a row shows, `CellLaunchForm.spec.ts` for resuming one. The pub/sub composable
1711
+ and `fetch` are mocked so the tests run without a server.
1712
+
1713
+ ---
1714
+
1715
+ ## Contributing
1716
+
1717
+ **Please open an issue rather than a pull request.** Bug reports and feature requests are very
1718
+ welcome and are the way a change gets in; outside pull requests are closed automatically,
1719
+ whatever their size.
1720
+
1721
+ Writing code stopped being the bottleneck — reading it did not, and a large generated diff is
1722
+ hard to audit for a reviewer who did not help shape the design. This app runs coding agents
1723
+ against your real machine and repositories, so we do not merge what we cannot fully review.
1724
+ What is scarce instead is the bug we cannot reach from here and the idea we have not had.
1725
+
1726
+ The full policy, the issue-writing rules and the automated triage: **[CONTRIBUTING.md](CONTRIBUTING.md)**
1727
+ (bilingual).
@@ -87,7 +87,6 @@ const PATH_TOOLS = [
87
87
  { cmd: "gh", versionArg: "--version", required: true, why: "PRs & Issues view + one-click PRs", hint: "https://cli.github.com (then: gh auth login)" },
88
88
  { cmd: "tmux", versionArg: "-V", required: false, why: "sessions survive a restart", hint: "brew install tmux · apt install tmux" },
89
89
  { cmd: "codex", versionArg: "--version", required: false, why: "run OpenAI Codex as an agent", hint: "npm install -g @openai/codex" },
90
- { cmd: "docker", versionArg: "--version", required: false, why: "the experimental Docker sandbox", hint: "https://docs.docker.com/get-started/get-docker/" },
91
90
  {
92
91
  cmd: "ffmpeg",
93
92
  versionArg: "-version",
@@ -0,0 +1,126 @@
1
+ // A chat started from a collection view shows that collection in the Canvas IMMEDIATELY, instead
2
+ // of an empty pane while the agent boots and calls presentCollection itself. The browser seeds a
3
+ // placeholder card at spawn; the agent's real result supersedes it moments later.
4
+ //
5
+ // MulmoClaude ships the same feature (its #1768) — `../mulmoclaude/src/utils/collections/
6
+ // presentSeed.ts` is the authority for the shapes and the supersede rule, and this is its
7
+ // counterpart. Two things are deliberately NOT copied, because the hosts differ:
8
+ //
9
+ // - MulmoClaude keeps tool results in an in-memory ActiveSession and reconciles in
10
+ // eventDispatch. Ours are stored SERVER-side (toolResultsStore, deduped by uuid) and
11
+ // replayed per session, so the supersede has to happen on both sides — the server for what
12
+ // is replayed after a reload, the panel for what is on screen now.
13
+ // - its synthetic marker is client-only; ours round-trips through the store and back out of
14
+ // /api/agent/toolResults, so the flag is part of the stored shape.
15
+ //
16
+ // In common/ because BOTH sides decide from it: the browser builds the placeholder and the
17
+ // server decides which stored card a real result replaces. Two copies of "what counts as
18
+ // synthetic" is exactly the drift this directory exists to prevent.
19
+ import { TOOL_NAME as PRESENT_COLLECTION_TOOL_NAME, type PresentCollectionData } from "@mulmoclaude/core/collection";
20
+ import { isRecord } from "./isRecord";
21
+
22
+ export { PRESENT_COLLECTION_TOOL_NAME };
23
+
24
+ export interface CollectionSlashSeed {
25
+ slug: string;
26
+ itemId?: string;
27
+ }
28
+
29
+ /** Parse a collection chat seed (`/<slug> …`, or `/<slug> id=<itemId> …` for one record) into the
30
+ * addressing a placeholder card needs. These are the shapes `skillCommandSeed` builds — a
31
+ * collection IS a skill, so its slug doubles as a slash command.
32
+ *
33
+ * Returns null for anything else, which is what makes "no subject" fall out rather than needing a
34
+ * case per caller: a FEED's seed is prose (no skill behind it), and the collections index, the
35
+ * template cards, the Settings skill buttons and cron all send no slash command either.
36
+ *
37
+ * A slug may not contain `/`, so a path-like input is not mistaken for a collection. Token-split
38
+ * rather than one regex, to keep away from a ReDoS-flagged pattern (same as MulmoClaude's). */
39
+ export function parseCollectionSlashSeed(message: string): CollectionSlashSeed | null {
40
+ const trimmed = message.trimStart();
41
+ if (!trimmed.startsWith("/")) return null;
42
+ const [slug, second] = trimmed.slice(1).split(/\s+/);
43
+ if (!slug || slug.includes("/")) return null;
44
+ const itemId = second?.startsWith("id=") ? second.slice(3) : "";
45
+ return itemId ? { slug, itemId } : { slug };
46
+ }
47
+
48
+ /** The marker that says a card was seeded by the browser rather than called by the agent. A
49
+ * literal so both sides spell it the same way. */
50
+ export const SYNTHETIC_COLLECTION_KEY = "syntheticCollection";
51
+
52
+ /** Anything with a uuid. Deliberately NOT an index signature: the store's `ToolResult` has one and
53
+ * the panel's own interface does not, and requiring it would exclude the panel — which is half of
54
+ * what this file exists to keep in step. Everything else is read through `isRecord`. */
55
+ type Carded = { uuid: string };
56
+
57
+ /** The placeholder card's own shape, so a caller sees the fields it can send rather than `Carded`. */
58
+ export interface SyntheticCollectionCard extends Carded {
59
+ toolName: string;
60
+ message: string;
61
+ data: PresentCollectionData;
62
+ jsonData: PresentCollectionData;
63
+ /** Spelled out as well as written through {@link SYNTHETIC_COLLECTION_KEY}, because a computed
64
+ * key is not a declaration — the interface has to name it or the literal below is excess. */
65
+ syntheticCollection: true;
66
+ }
67
+
68
+ export const isPresentCollection = (result: unknown): boolean => isRecord(result) && result.toolName === PRESENT_COLLECTION_TOOL_NAME;
69
+
70
+ export const isSyntheticCollection = (result: unknown): boolean => isRecord(result) && result[SYNTHETIC_COLLECTION_KEY] === true;
71
+
72
+ /** Which collection a card presents. Reads `data` then `jsonData` because the tool result carries
73
+ * the payload in both and a partial update (a view persisting its state) may carry only one. */
74
+ export function collectionSlugOf(result: unknown): string | undefined {
75
+ if (!isRecord(result)) return undefined;
76
+ for (const field of [result.data, result.jsonData]) {
77
+ if (isRecord(field) && typeof field.collectionSlug === "string" && field.collectionSlug) return field.collectionSlug;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ /** Build the placeholder card. The payload is only the addressing — the collection View
83
+ * SELF-FETCHES from `collectionSlug`, so seeding needs no collection data at all, and a
84
+ * placeholder is never stale in the way a snapshot would be.
85
+ *
86
+ * `uuid` is a parameter rather than generated here so this stays pure: the value is a caller's
87
+ * `crypto.randomUUID()`, and a test can pin the card without stubbing a global. */
88
+ export function makeSyntheticCollectionResult(uuid: string, collectionSlug: string, itemId?: string): SyntheticCollectionCard {
89
+ const data: PresentCollectionData = itemId ? { collectionSlug, itemId } : { collectionSlug };
90
+ const target = itemId ? `${collectionSlug} / ${itemId}` : collectionSlug;
91
+ return {
92
+ uuid,
93
+ toolName: PRESENT_COLLECTION_TOOL_NAME,
94
+ message: `Presented collection ${target}`,
95
+ data,
96
+ jsonData: data,
97
+ [SYNTHETIC_COLLECTION_KEY]: true,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Fold `incoming` into `list` under the one rule that matters: a placeholder and the agent's real
103
+ * card for the same collection are two renderings of one thing, and the real one wins. Whichever
104
+ * arrives second — the agent can be faster than our own validation fetch.
105
+ *
106
+ * Returns what the caller should do with `incoming`, and MUTATES `list` to drop a superseded
107
+ * placeholder. Both halves are needed: dropping alone would leave the real card unstored, and
108
+ * skipping alone would leave two cards up.
109
+ *
110
+ * Anything that is not a presentCollection result passes straight through, so this is safe to run
111
+ * over every result rather than only the ones a caller thinks are collections.
112
+ */
113
+ export function reconcileCollectionCard<T extends Carded>(list: T[], incoming: unknown): "store" | "skip" {
114
+ const slug = collectionSlugOf(incoming);
115
+ if (!isPresentCollection(incoming) || !slug) return "store";
116
+ const sameCollection = (candidate: T) => isPresentCollection(candidate) && collectionSlugOf(candidate) === slug;
117
+
118
+ if (isSyntheticCollection(incoming)) {
119
+ // The real card is already up: a placeholder now would be a stale duplicate that nothing
120
+ // later removes, since the supersede below has already run with nothing to find.
121
+ return list.some((candidate) => sameCollection(candidate) && !isSyntheticCollection(candidate)) ? "skip" : "store";
122
+ }
123
+ const index = list.findIndex((candidate) => sameCollection(candidate) && isSyntheticCollection(candidate));
124
+ if (index >= 0) list.splice(index, 1);
125
+ return "store";
126
+ }
@@ -0,0 +1,45 @@
1
+ // A directory path reduced to a form two spellings of the same directory share, for comparing a
2
+ // path the USER typed against one a tool reported.
3
+ //
4
+ // Lexical only — no filesystem, because the browser has none. It therefore cannot see through a
5
+ // symlink, and is not what any invariant may rest on: the server decides that with a realpath
6
+ // containment check (git/worktrees.ts). This exists so a control is greyed out BEFORE the click
7
+ // for the spellings a person actually types — a trailing slash, a `.`, a `..` (#1207).
8
+ //
9
+ // Both separators are folded, since the same app runs on Windows and the field takes either.
10
+
11
+ const SEPARATORS = /[/\\]+/;
12
+
13
+ /**
14
+ * The prefix the segment walk below must not eat: a Windows drive root, a UNC share root, or the
15
+ * POSIX root. Anything else is relative and has no root at all.
16
+ *
17
+ * Each of the three is only matched in its ROOTED spelling, so a form that means something else
18
+ * cannot borrow its key (raised by CodeRabbit on #1208): `C:foo` is relative to the current
19
+ * directory ON drive C rather than `C:\foo`, and `\server\share` is a drive-relative path rather
20
+ * than the UNC `\\server\share`. Folding either pair together would let one directory grey out a
21
+ * control belonging to another.
22
+ */
23
+ const rootOf = (path: string): string => {
24
+ if (/^[a-zA-Z]:[/\\]/.test(path)) return `${path.slice(0, 2)}/`;
25
+ if (/^[/\\]{2}/.test(path)) return "//";
26
+ return SEPARATORS.test(path.charAt(0)) ? "/" : "";
27
+ };
28
+
29
+ export function dirPathKey(path: string): string {
30
+ const trimmed = path.trim();
31
+ if (trimmed === "") return "";
32
+ const root = rootOf(trimmed);
33
+ const walked: string[] = [];
34
+ for (const segment of trimmed.slice(root.length).split(SEPARATORS)) {
35
+ if (segment === "" || segment === ".") continue;
36
+ // A `..` above a rooted path has nowhere to go, and dropping the root would turn an absolute
37
+ // path into a relative one that could then match something else.
38
+ if (segment === ".." && walked.length > 0 && walked[walked.length - 1] !== "..") walked.pop();
39
+ else if (segment !== ".." || root === "") walked.push(segment);
40
+ }
41
+ return root + walked.join("/");
42
+ }
43
+
44
+ /** Whether two paths name the same directory, as far as spelling can tell. */
45
+ export const isSameDirPath = (a: string | null | undefined, b: string | null | undefined): boolean => !!a && !!b && dirPathKey(a) === dirPathKey(b);