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.
- package/README.md +77 -89
- package/bin/mulmoterminal.js +0 -1
- package/common/collectionSeed.ts +126 -0
- package/common/dirPathKey.ts +45 -0
- package/common/notifyKinds.ts +4 -1
- package/common/sessionAgent.ts +9 -0
- package/common/sessionOccupancy.ts +45 -0
- package/common/workerStatus.ts +35 -0
- package/common/worktreeSession.ts +37 -0
- package/dist/assets/{abnfDiagram-VRR7QNED-RjiYivmv-D3Xi4MgH.js → abnfDiagram-VRR7QNED-RjiYivmv-Ds795mMl.js} +1 -1
- package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-_YNv0NMb.js +1 -0
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-3nWA91tG--Hhwls1P.js → architectureDiagram-ZJ3FMSHR-3nWA91tG-C_pKLVRy.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-BPuAJQRW-jbpnOYNC.js → blockDiagram-677ZJIJ3-BPuAJQRW-C1XW9rk5.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-C0LAqQso-BehZz0B8.js → c4Diagram-LMCZKHZV-C0LAqQso-B_DJUxYS.js} +1 -1
- package/dist/assets/channel-7wUqSdoX-6DJ89-Qe.js +1 -0
- package/dist/assets/{chunk-32BRIVSS-BRrYpgtb-CW1MLM9R.js → chunk-32BRIVSS-BRrYpgtb-muO9l3Wd.js} +1 -1
- package/dist/assets/{chunk-52WLFC77-BJ-ss3Xr-zXVxTZL9.js → chunk-52WLFC77-BJ-ss3Xr-2G1qxJCe.js} +1 -1
- package/dist/assets/{chunk-C7G6YPKG-BZEucKEL-rXDRBOEz.js → chunk-C7G6YPKG-BZEucKEL-DcP1Ik_Y.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-DLS6FBN1-B_CwjJ-P.js → chunk-EX3LRPZG-DLS6FBN1-DzGx10EI.js} +1 -1
- package/dist/assets/{chunk-FWX5IMBZ-DHLSFw1H-CaXZLVSZ.js → chunk-FWX5IMBZ-DHLSFw1H-C_1-WiA5.js} +2 -2
- package/dist/assets/{chunk-HOUHSVGY-Bhlt8hXJ-4fqsPAd7.js → chunk-HOUHSVGY-Bhlt8hXJ-CNQLLKZo.js} +1 -1
- package/dist/assets/{chunk-ICXQ74PX-DwgHBX_g-Dg1-0lTm.js → chunk-ICXQ74PX-DwgHBX_g-zUyYvMip.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CMZRaeqt-EDD8-kXb.js → chunk-MOJQB5TN-CMZRaeqt-CuddDdck.js} +1 -1
- package/dist/assets/{chunk-OGEWGWER-8Qy4a8b5-CDKuaxoY.js → chunk-OGEWGWER-8Qy4a8b5-OusgHU3N.js} +1 -1
- package/dist/assets/{chunk-PUDLZKDR-DcrWQRYh-CCXDsG2_.js → chunk-PUDLZKDR-DcrWQRYh-CbEu-YVk.js} +1 -1
- package/dist/assets/{chunk-Q4XR5HBZ-ZXVGkG8Z-DGcK-cBH.js → chunk-Q4XR5HBZ-ZXVGkG8Z-C8R8rf8U.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-DmGdheTX-Goxxrrjk.js → chunk-V7JOEXUC-DmGdheTX-CAvev2P-.js} +1 -1
- package/dist/assets/{chunk-VAUOI2AC-CS9QJ4yz-Oly0QbKc.js → chunk-VAUOI2AC-CS9QJ4yz-6Fqcztnb.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-C6a91eNY-DujIpVG1.js → chunk-VR4S4FIN-C6a91eNY-Oo6w4umS.js} +1 -1
- package/dist/assets/{chunk-WYO6CB5R-BlzOfotS-BJCy4_YQ.js → chunk-WYO6CB5R-BlzOfotS-CIUpBpsn.js} +1 -1
- package/dist/assets/{chunk-ZGVPDNZ5-BYwxNFTK-DFr162Oj.js → chunk-ZGVPDNZ5-BYwxNFTK-DJKhMGKs.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-B85sB4KG.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-B85sB4KG.js +1 -0
- package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CVLC7hXj.js +1 -0
- package/dist/assets/{cynefinDiagram-TSTJHNR4-DHA9iPo--YZDZk8o5.js → cynefinDiagram-TSTJHNR4-DHA9iPo--D510DZwZ.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB--oJKqXBZ-BE00-MPM.js → dagre-VKFMJZFB--oJKqXBZ-BqyoygxX.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-CcJCB9bG-DH6VIEkg.js → diagram-FQU43EPY-CcJCB9bG-DfnckLd4.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-C_o-WGG1-D6LkeFBZ.js → diagram-G47NLZAW-C_o-WGG1-BPt0rgWP.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-CXJCYvY--CLWYWMmK.js → diagram-NH7WQ7WH-CXJCYvY--CkJBzBK2.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-BMzeJ87A-BbfOUERw.js → diagram-OA4YK3LP-BMzeJ87A-COSn3f12.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-D_93NKqo-Cc3W59e4.js → diagram-WEI45ONY-D_93NKqo-Dp8Om4pW.js} +1 -1
- package/dist/assets/{dist-BGPxCEcj.js → dist-DZD6s32q.js} +2 -2
- package/dist/assets/{dist-zL7lv5OB.js → dist-DiEF7DGo.js} +1 -1
- package/dist/assets/{dist-BxbGvs8Z.js → dist-JstXoCzo.js} +2 -2
- package/dist/assets/{dist-CON5WUUM.js → dist-gls9ajYD.js} +5 -5
- package/dist/assets/{ebnfDiagram-CCIWWBDH-CVai1Ii9-DU5Uuoui.js → ebnfDiagram-CCIWWBDH-CVai1Ii9-Di2Rnx6S.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-CiABnA0s-BIkgPJwz.js → erDiagram-Q63AITRT-CiABnA0s-dgCq9gZ-.js} +1 -1
- package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-D-U4pUhx.js +1 -0
- package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-CeqmKjiQ.js +1 -0
- package/dist/assets/{ganttDiagram-NO4QXBWP-DQZvdFo1-i0B3574R.js → ganttDiagram-NO4QXBWP-DQZvdFo1-Cia0ajA1.js} +1 -1
- package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Dv8G0LNT.js +1 -0
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-C2ovBouh-DDH8BFah.js → gitGraphDiagram-IHSO6WYX-C2ovBouh-Dh1y0VXZ.js} +1 -1
- package/dist/assets/index-BBcSnsqR.js +616 -0
- package/dist/assets/index-Dal2P4Y6.css +1 -0
- package/dist/assets/info-DKCQHKI2-Dplx5kMp-Gtb9rU5N.js +1 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-7UnoB5AP-DPJR6zHq.js → infoDiagram-FWYZ7A6U-7UnoB5AP-BqYiXSaj.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByUDM_N2-l1ua93u2.js → ishikawaDiagram-FXEZZL3T-ByUDM_N2-CcZgH7Ew.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-c5xIah9o-CBBk1xj0.js → journeyDiagram-5HDEW3XC-c5xIah9o-DdArPeNL.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-Cnt6loYD-DXMToe3A.js → kanban-definition-HUTT4EX6-Cnt6loYD-DmJ152Sf.js} +1 -1
- package/dist/assets/{lib-CRLA4jbF.js → lib-Db3KPEnF.js} +3 -3
- package/dist/assets/{line-D7ziSjKi-Cg_lPLRL.js → line-D7ziSjKi-B3NE0st9.js} +1 -1
- package/dist/assets/{marp-B6XQKNM-.js → marp-SCu9aKhI.js} +1 -1
- package/dist/assets/material-symbols-outlined-Bz-4pmf0.woff2 +0 -0
- package/dist/assets/{mermaid-parser.core-DxEa8E3F-Dit0KGYk.js → mermaid-parser.core-DxEa8E3F-nhwF9Zmw.js} +2 -2
- package/dist/assets/{mermaid.core-V0OYwIz3-B1A61-Tj.js → mermaid.core-V0OYwIz3-COnZ7RY6.js} +3 -3
- package/dist/assets/{mindmap-definition-LN4V7U3C-CexN3O6L-CTkSZLPa.js → mindmap-definition-LN4V7U3C-CexN3O6L-DAFbTZnf.js} +1 -1
- package/dist/assets/packet-7NZHBO7P-CQI3flND-BlSkfnzl.js +1 -0
- package/dist/assets/{pegDiagram-2B236MQR-BiM4G0cW-eXiW6hVB.js → pegDiagram-2B236MQR-BiM4G0cW-DVdp8F2g.js} +1 -1
- package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-PTCmAnlr.js +1 -0
- package/dist/assets/{pieDiagram-ENE6RG2P-B_fBBS-2-BWLd7wxb.js → pieDiagram-ENE6RG2P-B_fBBS-2-dOKbqFlT.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-BigGVxCR-CbHyHiSQ.js → quadrantDiagram-ABIIQ3AL-BigGVxCR-B0K7Bi_G.js} +1 -1
- package/dist/assets/radar-I7S5WNFK-us6x-Z9R-sOU_Qtf_.js +1 -0
- package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-CZse0pRF.js +1 -0
- package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-DD141lVl.js +1 -0
- package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-6h3qaEy8.js +1 -0
- package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-D3yOgOUU.js +1 -0
- package/dist/assets/{railroadDiagram-RFXS5EU6--lNIlG64-DXl7SYGG.js → railroadDiagram-RFXS5EU6--lNIlG64-IX0DQMzv.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-D-B0Y5Wu-YpczTMgx.js → requirementDiagram-TGXJPOKE-D-B0Y5Wu-7ePp-6uY.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-DlutaXDv-Dg-4idXe.js → sankeyDiagram-HTMAVEWB-DlutaXDv-DcPeO3wM.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-BOLgtggH-DrTMC23z.js → sequenceDiagram-DBY2YBRQ-BOLgtggH-BPKloVvU.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-jf_dXUEU-BLcycgBQ.js → stateDiagram-2N3HPSRC-jf_dXUEU-Bd6-ZMXE.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v--iFusbAN.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-CYjtALQH-CQanOdSi.js → swimlanes-5IMT3BWC-CYjtALQH-UC879FAu.js} +1 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-B7GGyqBW.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-DYJ4oUm8-BUgGWs88.js → timeline-definition-FHXFAJF6-DYJ4oUm8-ByREbTkR.js} +1 -1
- package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-COPupObu.js +1 -0
- package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-DebmT2Ye.js +1 -0
- package/dist/assets/{vennDiagram-L72KCM5P-CdKAHoek-CMyJgjIe.js → vennDiagram-L72KCM5P-CdKAHoek-CznsE58R.js} +1 -1
- package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-CqYCfIUl.js +1 -0
- package/dist/assets/{wardleyDiagram-EHGQE667-BF6c4_CW-DC2v_Fw4.js → wardleyDiagram-EHGQE667-BF6c4_CW-MMgJtZmB.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-CGiKngj7-DzBp0TnN.js → xychartDiagram-FW5EYKEG-CGiKngj7-D9mjkddh.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -9
- package/server/agents/claude.ts +12 -2
- package/server/backends/html.ts +7 -10
- package/server/backends/remoteHost/googleCalendar.spec.ts +2 -0
- package/server/backends/system-tasks.ts +33 -0
- package/server/config/env.ts +30 -0
- package/server/git/worktree-routes.ts +10 -1
- package/server/git/worktrees.ts +5 -1
- package/server/index.ts +31 -31
- package/server/infra/fs-cleanup.ts +83 -3
- package/server/infra/tmux.ts +26 -0
- package/server/routes/app-routes.ts +12 -1
- package/server/routes/hook-routes.ts +4 -1
- package/server/routes/mcp-routes.ts +7 -1
- package/server/routes/plugin-routes.ts +38 -2
- package/server/routes/session-routes.ts +44 -4
- package/server/routes/tool-routes.ts +24 -13
- package/server/routes/ws-routes.ts +60 -20
- package/server/session/dir-session.ts +111 -0
- package/server/session/draft-injection.ts +53 -9
- package/server/session/launcher-gui-mcp.ts +21 -0
- package/server/session/lifecycle.ts +10 -5
- package/server/session/mcp-config.ts +2 -5
- package/server/session/partitionPending.ts +4 -0
- package/server/session/provider-env.ts +1 -11
- package/server/session/pty-scan.ts +43 -0
- package/server/session/pty-spawn.ts +1 -35
- package/server/session/registry.ts +195 -0
- package/server/session/scheduled-chat.ts +54 -0
- package/server/session/session-reads.ts +2 -1
- package/server/session/session-resolve.ts +12 -0
- package/server/session/spawn-claude.ts +55 -34
- package/server/session/spawn-codex.ts +7 -0
- package/server/session/spawn-deps.ts +1 -1
- package/server/session/spawn-shell.ts +5 -1
- package/server/session/task-push.ts +5 -2
- package/server/session/taskPushRules.ts +12 -2
- package/server/session/tool-store.ts +13 -1
- package/server/session/types.ts +2 -8
- package/server/session/worktree-session-limit.ts +75 -0
- package/server/skills/mulmoterminal-bug-report/SKILL.md +1 -1
- package/server/skills/mulmoterminal-model/SKILL.md +0 -2
- package/Dockerfile.sandbox +0 -30
- package/dist/assets/architecture-TIHT7OUA-0NsD2xW1-C59c2mZk.js +0 -1
- package/dist/assets/channel-7wUqSdoX-BQFzwCdQ.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-BYlRi6YQ-DpE_W69U.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-BCHaYHxj-DpE_W69U.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CGYr2F6h-CDiQ6X3-.js +0 -1
- package/dist/assets/eventmodeling-45OFAUF4-CWmqhm3a-CZzeP2Do.js +0 -1
- package/dist/assets/flowDiagram-23GEKE2U-DKSB5AiY-BcNja7As.js +0 -1
- package/dist/assets/gitGraph-TEB2WS4Q-BR9qPwdN-Dfl3aEUI.js +0 -1
- package/dist/assets/index-Bfg3eY1L.css +0 -1
- package/dist/assets/index-CqSlsYVG.js +0 -616
- package/dist/assets/info-DKCQHKI2-Dplx5kMp-BIS49gZw.js +0 -1
- package/dist/assets/material-symbols-outlined-D4PiVfdc.woff2 +0 -0
- package/dist/assets/packet-7NZHBO7P-CQI3flND-BLEmgsub.js +0 -1
- package/dist/assets/pie-RZYD4A2V-CnmKE2Wa-xUYJ0MCM.js +0 -1
- package/dist/assets/radar-I7S5WNFK-us6x-Z9R-DuyO7T8W.js +0 -1
- package/dist/assets/railroad-3IZDKUUU-BKdz6pdN-DV-zr5kg.js +0 -1
- package/dist/assets/railroad-abnf-AHOZXSZD-m_CSSIfd-CB_G0fUx.js +0 -1
- package/dist/assets/railroad-ebnf-EBAXGLYW-Nu3nAG7C-DbKGz_AB.js +0 -1
- package/dist/assets/railroad-peg-LSFZ7HO6--Y3mD4R_-C_0ZnTAv.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CuzgCL2v-DvcSup1h.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-3bmSBtKs-f_02wuOM.js +0 -8
- package/dist/assets/treeView-QDETBFTQ-D27HWG6Q-DN8bx6Us.js +0 -1
- package/dist/assets/treemap-6X3UGDF4-CmGIU3H5-CuiA6cSr.js +0 -1
- package/dist/assets/wardley-OPB4EBWU-CbHWsb2k-DvYP8cos.js +0 -1
- 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.
|
|
85
|
-
|
|
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
|
-

|
|
92
92
|
|
|
93
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
|
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
|
|
562
|
-
|
|
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
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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`)
|
|
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
|
|
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
|
|
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.
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
1737
|
-
|
|
1738
|
-
|
|
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).
|
package/bin/mulmoterminal.js
CHANGED
|
@@ -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);
|
package/common/notifyKinds.ts
CHANGED
|
@@ -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
|
|
package/common/sessionAgent.ts
CHANGED
|
@@ -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>;
|