mulmoterminal 3.0.0 → 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 (160) hide show
  1. package/README.md +77 -89
  2. package/bin/mulmoterminal.js +0 -1
  3. package/common/collectionSeed.ts +126 -0
  4. package/common/dirPathKey.ts +45 -0
  5. package/common/notifyKinds.ts +4 -1
  6. package/common/sessionAgent.ts +9 -0
  7. package/common/sessionOccupancy.ts +45 -0
  8. package/common/workerStatus.ts +35 -0
  9. package/common/worktreeSession.ts +37 -0
  10. package/dist/assets/{abnfDiagram-VRR7QNED-RjiYivmv-D3Xi4MgH.js → abnfDiagram-VRR7QNED-RjiYivmv-Ds795mMl.js} +1 -1
  11. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-_YNv0NMb.js +1 -0
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-3nWA91tG--Hhwls1P.js → architectureDiagram-ZJ3FMSHR-3nWA91tG-C_pKLVRy.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-BPuAJQRW-jbpnOYNC.js → blockDiagram-677ZJIJ3-BPuAJQRW-C1XW9rk5.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-C0LAqQso-BehZz0B8.js → c4Diagram-LMCZKHZV-C0LAqQso-B_DJUxYS.js} +1 -1
  15. package/dist/assets/channel-7wUqSdoX-6DJ89-Qe.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-BRrYpgtb-CW1MLM9R.js → chunk-32BRIVSS-BRrYpgtb-muO9l3Wd.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-BJ-ss3Xr-zXVxTZL9.js → chunk-52WLFC77-BJ-ss3Xr-2G1qxJCe.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-BZEucKEL-rXDRBOEz.js → chunk-C7G6YPKG-BZEucKEL-DcP1Ik_Y.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-DLS6FBN1-B_CwjJ-P.js → chunk-EX3LRPZG-DLS6FBN1-DzGx10EI.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DHLSFw1H-CaXZLVSZ.js → chunk-FWX5IMBZ-DHLSFw1H-C_1-WiA5.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-Bhlt8hXJ-4fqsPAd7.js → chunk-HOUHSVGY-Bhlt8hXJ-CNQLLKZo.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-DwgHBX_g-Dg1-0lTm.js → chunk-ICXQ74PX-DwgHBX_g-zUyYvMip.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CMZRaeqt-EDD8-kXb.js → chunk-MOJQB5TN-CMZRaeqt-CuddDdck.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-8Qy4a8b5-CDKuaxoY.js → chunk-OGEWGWER-8Qy4a8b5-OusgHU3N.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-DcrWQRYh-CCXDsG2_.js → chunk-PUDLZKDR-DcrWQRYh-CbEu-YVk.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-ZXVGkG8Z-DGcK-cBH.js → chunk-Q4XR5HBZ-ZXVGkG8Z-C8R8rf8U.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-DmGdheTX-Goxxrrjk.js → chunk-V7JOEXUC-DmGdheTX-CAvev2P-.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CS9QJ4yz-Oly0QbKc.js → chunk-VAUOI2AC-CS9QJ4yz-6Fqcztnb.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C6a91eNY-DujIpVG1.js → chunk-VR4S4FIN-C6a91eNY-Oo6w4umS.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-BlzOfotS-BJCy4_YQ.js → chunk-WYO6CB5R-BlzOfotS-CIUpBpsn.js} +1 -1
  31. package/dist/assets/{chunk-ZGVPDNZ5-BYwxNFTK-DFr162Oj.js → chunk-ZGVPDNZ5-BYwxNFTK-DJKhMGKs.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-B85sB4KG.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-B85sB4KG.js +1 -0
  34. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CVLC7hXj.js +1 -0
  35. package/dist/assets/{cynefinDiagram-TSTJHNR4-DHA9iPo--YZDZk8o5.js → cynefinDiagram-TSTJHNR4-DHA9iPo--D510DZwZ.js} +1 -1
  36. package/dist/assets/{dagre-VKFMJZFB--oJKqXBZ-BE00-MPM.js → dagre-VKFMJZFB--oJKqXBZ-BqyoygxX.js} +1 -1
  37. package/dist/assets/{diagram-FQU43EPY-CcJCB9bG-DH6VIEkg.js → diagram-FQU43EPY-CcJCB9bG-DfnckLd4.js} +1 -1
  38. package/dist/assets/{diagram-G47NLZAW-C_o-WGG1-D6LkeFBZ.js → diagram-G47NLZAW-C_o-WGG1-BPt0rgWP.js} +1 -1
  39. package/dist/assets/{diagram-NH7WQ7WH-CXJCYvY--CLWYWMmK.js → diagram-NH7WQ7WH-CXJCYvY--CkJBzBK2.js} +1 -1
  40. package/dist/assets/{diagram-OA4YK3LP-BMzeJ87A-BbfOUERw.js → diagram-OA4YK3LP-BMzeJ87A-COSn3f12.js} +1 -1
  41. package/dist/assets/{diagram-WEI45ONY-D_93NKqo-Cc3W59e4.js → diagram-WEI45ONY-D_93NKqo-Dp8Om4pW.js} +1 -1
  42. package/dist/assets/{dist-BGPxCEcj.js → dist-DZD6s32q.js} +2 -2
  43. package/dist/assets/{dist-zL7lv5OB.js → dist-DiEF7DGo.js} +1 -1
  44. package/dist/assets/{dist-BxbGvs8Z.js → dist-JstXoCzo.js} +2 -2
  45. package/dist/assets/{dist-CON5WUUM.js → dist-gls9ajYD.js} +5 -5
  46. package/dist/assets/{ebnfDiagram-CCIWWBDH-CVai1Ii9-DU5Uuoui.js → ebnfDiagram-CCIWWBDH-CVai1Ii9-Di2Rnx6S.js} +1 -1
  47. package/dist/assets/{erDiagram-Q63AITRT-CiABnA0s-BIkgPJwz.js → erDiagram-Q63AITRT-CiABnA0s-dgCq9gZ-.js} +1 -1
  48. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-D-U4pUhx.js +1 -0
  49. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-CeqmKjiQ.js +1 -0
  50. package/dist/assets/{ganttDiagram-NO4QXBWP-DQZvdFo1-i0B3574R.js → ganttDiagram-NO4QXBWP-DQZvdFo1-Cia0ajA1.js} +1 -1
  51. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Dv8G0LNT.js +1 -0
  52. package/dist/assets/{gitGraphDiagram-IHSO6WYX-C2ovBouh-DDH8BFah.js → gitGraphDiagram-IHSO6WYX-C2ovBouh-Dh1y0VXZ.js} +1 -1
  53. package/dist/assets/index-BBcSnsqR.js +616 -0
  54. package/dist/assets/index-Dal2P4Y6.css +1 -0
  55. package/dist/assets/info-DKCQHKI2-Dplx5kMp-Gtb9rU5N.js +1 -0
  56. package/dist/assets/{infoDiagram-FWYZ7A6U-7UnoB5AP-DPJR6zHq.js → infoDiagram-FWYZ7A6U-7UnoB5AP-BqYiXSaj.js} +1 -1
  57. package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByUDM_N2-l1ua93u2.js → ishikawaDiagram-FXEZZL3T-ByUDM_N2-CcZgH7Ew.js} +1 -1
  58. package/dist/assets/{journeyDiagram-5HDEW3XC-c5xIah9o-CBBk1xj0.js → journeyDiagram-5HDEW3XC-c5xIah9o-DdArPeNL.js} +1 -1
  59. package/dist/assets/{kanban-definition-HUTT4EX6-Cnt6loYD-DXMToe3A.js → kanban-definition-HUTT4EX6-Cnt6loYD-DmJ152Sf.js} +1 -1
  60. package/dist/assets/{lib-CRLA4jbF.js → lib-Db3KPEnF.js} +3 -3
  61. package/dist/assets/{line-D7ziSjKi-Cg_lPLRL.js → line-D7ziSjKi-B3NE0st9.js} +1 -1
  62. package/dist/assets/{marp-B6XQKNM-.js → marp-SCu9aKhI.js} +1 -1
  63. package/dist/assets/material-symbols-outlined-Bz-4pmf0.woff2 +0 -0
  64. package/dist/assets/{mermaid-parser.core-DxEa8E3F-Dit0KGYk.js → mermaid-parser.core-DxEa8E3F-nhwF9Zmw.js} +2 -2
  65. package/dist/assets/{mermaid.core-V0OYwIz3-B1A61-Tj.js → mermaid.core-V0OYwIz3-COnZ7RY6.js} +3 -3
  66. package/dist/assets/{mindmap-definition-LN4V7U3C-CexN3O6L-CTkSZLPa.js → mindmap-definition-LN4V7U3C-CexN3O6L-DAFbTZnf.js} +1 -1
  67. package/dist/assets/packet-7NZHBO7P-CQI3flND-BlSkfnzl.js +1 -0
  68. package/dist/assets/{pegDiagram-2B236MQR-BiM4G0cW-eXiW6hVB.js → pegDiagram-2B236MQR-BiM4G0cW-DVdp8F2g.js} +1 -1
  69. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-PTCmAnlr.js +1 -0
  70. package/dist/assets/{pieDiagram-ENE6RG2P-B_fBBS-2-BWLd7wxb.js → pieDiagram-ENE6RG2P-B_fBBS-2-dOKbqFlT.js} +1 -1
  71. package/dist/assets/{quadrantDiagram-ABIIQ3AL-BigGVxCR-CbHyHiSQ.js → quadrantDiagram-ABIIQ3AL-BigGVxCR-B0K7Bi_G.js} +1 -1
  72. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-sOU_Qtf_.js +1 -0
  73. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-CZse0pRF.js +1 -0
  74. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-DD141lVl.js +1 -0
  75. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-6h3qaEy8.js +1 -0
  76. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-D3yOgOUU.js +1 -0
  77. package/dist/assets/{railroadDiagram-RFXS5EU6--lNIlG64-DXl7SYGG.js → railroadDiagram-RFXS5EU6--lNIlG64-IX0DQMzv.js} +1 -1
  78. package/dist/assets/{requirementDiagram-TGXJPOKE-D-B0Y5Wu-YpczTMgx.js → requirementDiagram-TGXJPOKE-D-B0Y5Wu-7ePp-6uY.js} +1 -1
  79. package/dist/assets/{sankeyDiagram-HTMAVEWB-DlutaXDv-Dg-4idXe.js → sankeyDiagram-HTMAVEWB-DlutaXDv-DcPeO3wM.js} +1 -1
  80. package/dist/assets/{sequenceDiagram-DBY2YBRQ-BOLgtggH-DrTMC23z.js → sequenceDiagram-DBY2YBRQ-BOLgtggH-BPKloVvU.js} +1 -1
  81. package/dist/assets/{stateDiagram-2N3HPSRC-jf_dXUEU-BLcycgBQ.js → stateDiagram-2N3HPSRC-jf_dXUEU-Bd6-ZMXE.js} +1 -1
  82. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v--iFusbAN.js +1 -0
  83. package/dist/assets/{swimlanes-5IMT3BWC-CYjtALQH-CQanOdSi.js → swimlanes-5IMT3BWC-CYjtALQH-UC879FAu.js} +1 -1
  84. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-B7GGyqBW.js +8 -0
  85. package/dist/assets/{timeline-definition-FHXFAJF6-DYJ4oUm8-BUgGWs88.js → timeline-definition-FHXFAJF6-DYJ4oUm8-ByREbTkR.js} +1 -1
  86. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-COPupObu.js +1 -0
  87. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-DebmT2Ye.js +1 -0
  88. package/dist/assets/{vennDiagram-L72KCM5P-CdKAHoek-CMyJgjIe.js → vennDiagram-L72KCM5P-CdKAHoek-CznsE58R.js} +1 -1
  89. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-CqYCfIUl.js +1 -0
  90. package/dist/assets/{wardleyDiagram-EHGQE667-BF6c4_CW-DC2v_Fw4.js → wardleyDiagram-EHGQE667-BF6c4_CW-MMgJtZmB.js} +1 -1
  91. package/dist/assets/{xychartDiagram-FW5EYKEG-CGiKngj7-DzBp0TnN.js → xychartDiagram-FW5EYKEG-CGiKngj7-D9mjkddh.js} +1 -1
  92. package/dist/index.html +2 -2
  93. package/package.json +8 -9
  94. package/server/agents/claude.ts +12 -2
  95. package/server/backends/html.ts +7 -10
  96. package/server/backends/remoteHost/googleCalendar.spec.ts +2 -0
  97. package/server/backends/system-tasks.ts +33 -0
  98. package/server/config/env.ts +30 -0
  99. package/server/git/worktree-routes.ts +10 -1
  100. package/server/git/worktrees.ts +5 -1
  101. package/server/index.ts +31 -31
  102. package/server/infra/fs-cleanup.ts +83 -3
  103. package/server/infra/tmux.ts +26 -0
  104. package/server/routes/app-routes.ts +12 -1
  105. package/server/routes/hook-routes.ts +4 -1
  106. package/server/routes/mcp-routes.ts +7 -1
  107. package/server/routes/plugin-routes.ts +38 -2
  108. package/server/routes/session-routes.ts +44 -4
  109. package/server/routes/tool-routes.ts +24 -13
  110. package/server/routes/ws-routes.ts +60 -20
  111. package/server/session/dir-session.ts +111 -0
  112. package/server/session/draft-injection.ts +53 -9
  113. package/server/session/launcher-gui-mcp.ts +21 -0
  114. package/server/session/lifecycle.ts +10 -5
  115. package/server/session/mcp-config.ts +2 -5
  116. package/server/session/partitionPending.ts +4 -0
  117. package/server/session/provider-env.ts +1 -11
  118. package/server/session/pty-scan.ts +43 -0
  119. package/server/session/pty-spawn.ts +1 -35
  120. package/server/session/registry.ts +195 -0
  121. package/server/session/scheduled-chat.ts +54 -0
  122. package/server/session/session-reads.ts +2 -1
  123. package/server/session/session-resolve.ts +12 -0
  124. package/server/session/spawn-claude.ts +55 -34
  125. package/server/session/spawn-codex.ts +7 -0
  126. package/server/session/spawn-deps.ts +1 -1
  127. package/server/session/spawn-shell.ts +5 -1
  128. package/server/session/task-push.ts +5 -2
  129. package/server/session/taskPushRules.ts +12 -2
  130. package/server/session/tool-store.ts +13 -1
  131. package/server/session/types.ts +2 -8
  132. package/server/session/worktree-session-limit.ts +75 -0
  133. package/server/skills/mulmoterminal-bug-report/SKILL.md +1 -1
  134. package/server/skills/mulmoterminal-model/SKILL.md +0 -2
  135. package/Dockerfile.sandbox +0 -30
  136. package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-C59c2mZk.js +0 -1
  137. package/dist/assets/channel-7wUqSdoX-BQFzwCdQ.js +0 -1
  138. package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-DpE_W69U.js +0 -1
  139. package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-DpE_W69U.js +0 -1
  140. package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CDiQ6X3-.js +0 -1
  141. package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-CZzeP2Do.js +0 -1
  142. package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-BcNja7As.js +0 -1
  143. package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Dfl3aEUI.js +0 -1
  144. package/dist/assets/index-Bfg3eY1L.css +0 -1
  145. package/dist/assets/index-CqSlsYVG.js +0 -616
  146. package/dist/assets/info-DKCQHKI2-Dplx5kMp-BIS49gZw.js +0 -1
  147. package/dist/assets/material-symbols-outlined-D4PiVfdc.woff2 +0 -0
  148. package/dist/assets/packet-7NZHBO7P-CQI3flND-BLEmgsub.js +0 -1
  149. package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-xUYJ0MCM.js +0 -1
  150. package/dist/assets/radar-I7S5WNFK-us6x-Z9R-DuyO7T8W.js +0 -1
  151. package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-DV-zr5kg.js +0 -1
  152. package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-CB_G0fUx.js +0 -1
  153. package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-DbKGz_AB.js +0 -1
  154. package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-C_0ZnTAv.js +0 -1
  155. package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-DvcSup1h.js +0 -1
  156. package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-f_02wuOM.js +0 -8
  157. package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-DN8bx6Us.js +0 -1
  158. package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-CuiA6cSr.js +0 -1
  159. package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-DvYP8cos.js +0 -1
  160. 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
 
@@ -588,7 +540,7 @@ The Settings modal (⚙) persists per-user UI choices to `~/.mulmoterminal/confi
588
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. |
589
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. |
590
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. |
591
- | `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. |
592
544
  | `buttons` | Header action buttons — see [Header buttons](#header-buttons). Omit to keep the defaults; set to replace them. |
593
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). |
594
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. |
@@ -681,6 +633,9 @@ disconnected, or with no device registered, the toggle is a no-op.
681
633
  to register a built-in scheduled task. Every `worklogIntervalHours` (default 6) it spawns
682
634
  a Claude session that reviews the work you did across **all your saved working dirs**
683
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.
684
639
  Multiple clones/worktrees of the same repo (e.g. `myapp`, `myapp2`) are **merged into one
685
640
  per-repository section**, each covering what problem was addressed, what got solved, what's
686
641
  still in progress, and — mined from the transcripts — decisions that were only *discussed
@@ -736,7 +691,7 @@ malformed file is ignored.
736
691
  | ------------ | ------- |
737
692
  | `name` | Label shown as a badge in the terminal/cell header. |
738
693
  | `badgeColor` | Badge background color (`#rrggbb`); text auto-contrasts. |
739
- | `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. |
740
695
  | `headerTextColor` | Header **text** color (`#rrggbb`) — the dir path, title, and prompt. |
741
696
  | `cellColor` | Cell **body background** color (`#rrggbb`) — the frame around the terminal. |
742
697
  | `cellBorderColor` | Cell **border** color (`#rrggbb`). The status frame (working/blocked) still overrides it while active. |
@@ -750,7 +705,7 @@ malformed file is ignored.
750
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. |
751
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. |
752
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. |
753
- | `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. |
754
709
 
755
710
  **Security.** `sound` and every `sounds` entry are directory-relative paths only — absolute
756
711
  paths and any `../` that escapes the directory are rejected, and the path is never taken from the
@@ -822,11 +777,11 @@ it survives grid page switches and reconnects, and its dot shows running vs. exi
822
777
  has no Claude hooks, so no blocked/done states).
823
778
 
824
779
  Every running terminal's header also has a **▶ Run ▾** dropdown (next to the
825
- connection status), in both the single view and each grid cell — but **only when the
780
+ connection status) — but **only when the
826
781
  open project has scripts** (no `script.json`, no button). It lists the **open
827
782
  project's** `script.json` — the directory that terminal runs in — and launches the
828
- picked script in a **spare grid cell** (reusing an open launcher, else a new one),
829
- 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
830
785
  dev server or tests for the project you're working in without disturbing the
831
786
  session that's running.
832
787
 
@@ -871,7 +826,7 @@ the last 32 KB of output. See
871
826
  ## Skills (Skill menu)
872
827
 
873
828
  Next to the **▶ Run ▾** dropdown, every running terminal's header has a **⚡ Skill ▾**
874
- dropdown — in both the single view and each grid cell, and **only when the open
829
+ dropdown — and **only when the open
875
830
  project has skills** (nothing discovered, no button). It lists the
876
831
  [Claude skills](https://docs.claude.com/en/docs/claude-code/skills) discoverable for
877
832
  that terminal's directory — both **project scope** (`<dir>/.claude/skills`) and **user
@@ -957,7 +912,26 @@ and the cell launches its agent inside a fresh
957
912
  [git worktree](https://git-scm.com/docs/git-worktree) on a new `agent/<slug>` branch — a
958
913
  separate working tree that shares the repo's `.git`, so several agents can work the same
959
914
  repo without colliding. Worktrees live under `~/.mulmoterminal/worktrees/` (override with
960
- `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.
961
935
 
962
936
  A worktree started **from an issue** gets an `issue/<N>-<slug>` branch instead. The number
963
937
  in the name is what later tells the app which issue the work belongs to: the ⧉ Open PR
@@ -1056,11 +1030,10 @@ generated images, charts, HTML, and collection cards. Each result is drawn by it
1056
1030
  own Vue view inside a Shadow-DOM `PluginFrame` (so a plugin's bundled CSS can't leak),
1057
1031
  mirrors the active session, and replays history on re-select. Plugins reach the agent over
1058
1032
  an **in-process MCP server** served per session at `POST /api/mcp/:sessionId` (server name
1059
- `mulmoterminal-gui`) which works from the host *or* the Docker sandbox (over
1060
- `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
1061
1034
  set includes markdown, form, image generation (needs `GEMINI_API_KEY`), chart, HTML,
1062
1035
  collection, and mulmoscript (MulmoCast video/slides/PDF playback) views. You can also merge
1063
- 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`.
1064
1037
 
1065
1038
  **Wiki.** The toolbar **Wiki** button opens a read-only browser over `<workspace>/data/wiki/`
1066
1039
  — an **index** (tag-filterable page catalog), rendered **pages** with `[[wiki links]]` and
@@ -1419,7 +1392,7 @@ Two more raw WebSockets share the `/ws` frame format (`output` / `input` / `resi
1419
1392
  - **`/ws/codex?session=<id>&cwd=<dir>&gui=<0|1>`** — a **Codex** agent PTY (see
1420
1393
  [Agents: Claude & Codex](#agents-claude--codex)). Like `/ws` it sends a `session` frame
1421
1394
  with the id and reattaches to a live or tmux-backed session on resume. `gui=0` (grid
1422
- 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.
1423
1396
  - **`/ws/launch?session=<id>&cwd=<dir>&launcher=<index>`** — a **launch command** PTY (a
1424
1397
  plain shell, `codex`, or any command configured in Settings → Launch commands). Unlike a
1425
1398
  Run-menu script it's **persistent and reattachable** (survives page switches /
@@ -1532,15 +1505,17 @@ Key rules:
1532
1505
  duplicate `claude`.
1533
1506
  - **One live viewer per session**: a session is bound to a single socket. Opening
1534
1507
  it in a second place (another tab, or another grid cell pointed at the same dir)
1535
- reattaches there and **supersedes** the first, which detaches. To avoid doing
1536
- this by accident, a grid launcher's resume list **flags rows already open in
1537
- another terminal** (`● open`) and **asks for confirmation** before taking one
1538
- over.
1539
- - 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`
1540
1514
  exists) via the in-memory `knownSessions` registry + a `created` push; an
1541
1515
  unused one disappears when its PTY is reaped.
1542
1516
  - **Background workers get their own filter.** A session nobody started by hand —
1543
- 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`
1544
1519
  `hidden: true` — is listed under the **Background** chip instead of among the
1545
1520
  chats, so a refresh schedule doesn't fill the history. It stays openable (a
1546
1521
  MulmoTerminal session is a live terminal, so a row you can't reach is a process
@@ -1602,9 +1577,6 @@ Which sections `--append-system-prompt` ends up carrying is decided in
1602
1577
  `server/agents/appended-prompt.ts`: this one and the `prWorkdirFooter` clone line are separate
1603
1578
  settings on the same flag, and with both off the flag is not passed at all.
1604
1579
 
1605
- Passed inline rather than as `--append-system-prompt-file` for the same reason `--settings`
1606
- is: the sandbox spawn runs in a container that cannot read a host path.
1607
-
1608
1580
  Codex sessions are unaffected — the CLI has no equivalent flag.
1609
1581
 
1610
1582
  ---
@@ -1651,7 +1623,7 @@ Every tier above says what the **agent** said, which stops answering "which cell
1651
1623
  once several sessions are open. So a cell header also takes a **note you write yourself**: the
1652
1624
  pencil button beside the header text opens a one-line box (Enter saves, Esc cancels, clicking
1653
1625
  away saves). While a note is set it *replaces* the header line — the title it displaced stays in
1654
- 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
1655
1627
  too, so one session goes by one name everywhere.
1656
1628
 
1657
1629
  Notes are capped at 200 characters and folded to a single line. They are stored per **session
@@ -1683,7 +1655,7 @@ server/
1683
1655
  gh.ts, prs.ts, issues.ts, pr-for-branch.ts, worktrees.ts, worktree-*.ts
1684
1656
  files/ files-browse.ts (contained tree read/write), pick-file.ts,
1685
1657
  open-dir.ts, scripts.ts (Run-menu script.json loader)
1686
- infra/ process/transport/misc: tmux.ts, tmux-routes.ts, sandbox.ts,
1658
+ infra/ process/transport/misc: tmux.ts, tmux-routes.ts,
1687
1659
  pubsub.ts (socket.io /ws/pubsub), spa-fallback.ts, host-tools.ts,
1688
1660
  plugins-registry.ts, web-push.ts, install-bundled-skills.ts, accounting-tool.ts
1689
1661
  mcp/ per-session MCP broker
@@ -1733,7 +1705,23 @@ vitest.config.ts jsdom test environment
1733
1705
  yarn test
1734
1706
  ```
1735
1707
 
1736
- `src/components/Sidebar.spec.ts` covers the sidebar: rendering the server's
1737
- session list, the working dot, the `waiting` bold state, refetching on a pub/sub
1738
- push, and emitting `select` on click. The pub/sub composable and `fetch` are
1739
- 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);
@@ -9,10 +9,13 @@
9
9
  // command-failed — a Run cell's command exited non-zero.
10
10
  // session-exited — a session's PTY ended. Closing a cell yourself goes through the same
11
11
  // path, so this one fires on a deliberate close too.
12
+ // worker-failed — a hidden background worker ended without ever completing a turn. The one
13
+ // kind raised for a session with no cell on screen: a worker is invisible by
14
+ // design, so nothing else would ever say so.
12
15
  // pr-ci-failed — a directory's PR phase became ci-failing. The phase poll runs only
13
16
  // while the roster is on screen, so a failure that lands while you are
14
17
  // in another view is not seen.
15
- export const NOTIFY_KINDS = ["finished", "waiting", "command-done", "command-failed", "session-exited", "pr-ci-failed"] as const;
18
+ export const NOTIFY_KINDS = ["finished", "waiting", "command-done", "command-failed", "session-exited", "worker-failed", "pr-ci-failed"] as const;
16
19
 
17
20
  export type NotifyKind = (typeof NOTIFY_KINDS)[number];
18
21
 
@@ -18,6 +18,15 @@ export type TerminalAgent = (typeof TERMINAL_AGENTS)[number];
18
18
  // persisted cell (written before the field existed) means.
19
19
  export const asTerminalAgent = (value: unknown): TerminalAgent => (TERMINAL_AGENTS.some((agent) => agent === value) ? (value as TerminalAgent) : "claude");
20
20
 
21
+ // Narrowing rather than coercing, for the callers that must be able to say "this is not an agent
22
+ // at all": a shell is a session kind but not something a cell can be relaunched AS, and reading
23
+ // one as Claude (which asTerminalAgent does, correctly, for a remembered value) would offer to
24
+ // resume a shell as a conversation.
25
+ //
26
+ // Takes a plain string so the same question can be asked of a PROGRAM NAME — a launcher runs the
27
+ // user's own command line, and whether that command line is an agent is the same list.
28
+ export const isTerminalAgent = (agent: string): agent is TerminalAgent => TERMINAL_AGENTS.some((known) => known === agent);
29
+
21
30
  export interface AgentBadge {
22
31
  /** Where there is room for it — a sidebar row, a header bar. */
23
32
  full: string;
@@ -0,0 +1,45 @@
1
+ // Whether a session is HELD by a terminal right now — the one fact a second terminal has to know
2
+ // before it offers to open the same session.
3
+ //
4
+ // It lives in `common/` because both sides decide from it and neither can derive it alone: the
5
+ // server is the only place that can see the other holders (its own pty table, plus tmux for the
6
+ // holders belonging to another mulmoterminal process), and the browser is where the row that must
7
+ // refuse to be clicked is drawn. Answering it from the current page's grid — the way the
8
+ // launcher's `● open` badge used to — is blind to a second browser tab and to a second process,
9
+ // which is exactly how a running session gets taken over (#1207).
10
+
11
+ export interface OccupancyFacts {
12
+ /** A pty in THIS process whose browser socket is still open. */
13
+ viewedHere: boolean;
14
+ /** Clients tmux reports on the session, or null when tmux could not answer. */
15
+ tmuxClients: number | null;
16
+ /** This process holds one of those clients: our pty IS a tmux client, so it must not count as
17
+ * somebody else. */
18
+ holdsTmuxClient: boolean;
19
+ }
20
+
21
+ /**
22
+ * An unreadable tmux answer reads as NOT attached.
23
+ *
24
+ * The two failures are not symmetric: refusing a worktree its owner is alone in — because a probe
25
+ * failed, or because tmux is not installed at all — is a dead end with nothing to click, while the
26
+ * collision it would have prevented cannot happen without tmux anyway (no tmux, no session that
27
+ * outlives this process for a second one to attach to).
28
+ */
29
+ export function isSessionAttached({ viewedHere, tmuxClients, holdsTmuxClient }: OccupancyFacts): boolean {
30
+ if (viewedHere) return true;
31
+ return (tmuxClients ?? 0) > (holdsTmuxClient ? 1 : 0);
32
+ }
33
+
34
+ /** What the server puts on a session row. */
35
+ export interface SessionOccupancy {
36
+ attached: boolean;
37
+ }
38
+
39
+ /**
40
+ * The same field as the CLIENT may receive it — optional, like `PartialWorkerStatus` next door and
41
+ * for the same reason: a page left open across an upgrade parses rows from a server that never
42
+ * said. Absent must read as "not attached", so an older server keeps offering its rows rather
43
+ * than disabling every one of them.
44
+ */
45
+ export type PartialSessionOccupancy = Partial<SessionOccupancy>;