{"slug": "show-hn-mulmoterminal-run-many-claude-code-sessions-see-which-needs-you", "title": "Show HN: MulmoTerminal – Run many Claude Code sessions, see which needs you", "summary": "MulmoTerminal, a browser terminal for running multiple Claude Code and Codex sessions in parallel, was launched on Hacker News by receptron. The tool displays each session in a color-coded grid (blue for working, green for done, amber for needs you), supports git worktrees, phone push notifications, and runs via a single npx command. It requires Node ≥ 22.9 and the Claude CLI, and was built by the same team behind GraphAI.", "body_md": "**Run multiple Claude Code and Codex sessions in parallel — and see which one needs you.**\n\nA **browser terminal** for **parallel AI coding agents**: several **Claude Code** and **Codex**\nsessions side by side, each in its own cell, with the one that needs you marked in colour. Vibe\ncoding with a single agent needs nothing but a shell — this is for when you run several and lose\ntrack of which is waiting. Sessions survive a reload (tmux), work isolates in **git worktrees**,\nand a **phone push** reaches you when a turn finishes.\n\n**Every cell is a real pty.** `htop`\n\n, `lazygit`\n\n, a dev server and Claude Code are the same kind\nof object here — which is why the one-session-per-worktree limit applies to **agents only**, and\na shell or a `yarn dev`\n\nlauncher can sit in the same worktree an agent is working in.\n\n*The grid, live — each cell coloured working, done or needs you.*\n\n## mulmoterminal-launch-v8_en.mp4\n\n*90 seconds, with sound: one agent, then a grid of them. Zoom into one and the roster still holds what every other session asked, answered and did, so you go to whichever is lit and lose nothing catching up.*\n\n## Transcript of the narration\n\nWhen you ran one coding agent, the slowest thing in the room was the agent.\n\nNow that you run five, the slowest thing in the room is probably you.\n\nOne of them is always stopped. A permission prompt. A question. Until you notice, it does nothing at all.\n\nMulmoTerminal puts every session on one screen. Blue is working. Green is done. Amber is waiting on you.\n\nYou stop hunting. You go where the light is.\n\nThe other kind of slow is: what did I even ask this one? The roster keeps one line per session — what you asked, and what came back. Nothing left to remember.\n\nWhen one is done, you don't go looking for its window. Click its row — the next order goes in right there.\n\nThen you pick the next one from whatever is lit. Click, answer, move on. You never go looking — the roster tells you.\n\nWe built MulmoTerminal for exactly that: not to watch agents, but to triage them.\n\nThat is the whole install.\n\nMulmoTerminal turns [Claude Code](https://claude.com/claude-code) (and OpenAI's **Codex**)\ninto a parallel, observable workspace: many agent sessions at once in a grid, each one\ncolor-coded so you see at a glance which are **working**, which **need you**, and which are\n**done** — plus rich GUI output, git worktrees with one-click PRs, cost readouts, and a\nping to your phone when a task finishes. One `npx`\n\ncommand, no Electron, no config.\n\n```\nnpx mulmoterminal@latest        # starts on http://localhost:34567 and opens your browser\n```\n\nRequires **Node ≥ 22.9** and the [ claude](https://claude.com/claude-code) CLI on your\n\n`PATH`\n\n, already logged in. `npx mulmoterminal@latest init`\n\nreports what it can't find.Running agents in parallel was never the hard part — tmux does that fine. What gets lost\nis **which** of the five is waiting for you. A pane is opaque: working, finished and\nblocked-on-a-permission all look the same until you read it. Here every cell reports its\nstate back to one grid — working (blue), done (green), **needs you** (amber) — with a chime\nwhen one goes amber off-screen, and a [cockpit roster](#why-youll-want-it) of one line per\nsession so you can answer one without losing your place in the other four. It runs *on*\ntmux when you have it, for [persistence across restarts](#session-persistence-tmux).\n\nBuilt by ** receptron** —\n\n**, software architect for**\n\n[Satoshi Nakajima](https://x.com/snakajima)**Windows 95** at Microsoft, and\n\n**; the same two behind**\n\n[Isamu Arimoto](https://github.com/isamu)**.**\n\n[GraphAI](https://github.com/receptron/graphai)[More ↓](#who-builds-this)\n\n**See every agent at once.** A grid of live sessions, each cell color-coded by state —**working**(blue),** blocked / needs a permission**(amber),** done, unreviewed**(blue),** idle**— with an attention chime and a toolbar tally, so an off-screen agent that's stuck never slips past you. Stop babysitting one terminal; supervise ten. Zoom into one and the**cockpit roster** keeps everyone else in view — one text row per session with its AI summary, last prompt, latest reply, and the branch's**PR phase**(draft / CI fail / ready / merged).** A GUI for your agents, not just a terminal.**Beside the terminal, a** Canvas**panel renders what an agent produces over MCP —** documents, forms, charts, generated images, HTML, collection cards**— each drawn by its own plugin. The agent doesn't just print text; it hands you an interface.** Get pulled back from anywhere.**A finished — or input-waiting — task sends a** Web Push to your phone**, and the** RemoteHost**companion lets you watch sessions and answer with a tap (** yes / no / continue**) from the phone itself — walk away, get pinged, jump back in.** Nothing is lost on a restart.**With`tmux`\n\n, every session survives a server crash, restart, or`node --watch`\n\nreload — a mid-turn agent, a long build, a dev server all keep running and reattach when you come back.**Ship without leaving the grid.** Each repo cell shows a**git branch chip**, isolates work in a one-click** git worktree**, opens a** diff**panel, and does** commit / push / open PR**— so several agents can work the same repo without colliding.** Know what it's costing.**Per-session** context %**,** token**, and** estimated $**readouts, an** activity timeline**of tool calls, and** AI-summarized**cell titles and command-output explanations — so a wall of parallel agents stays legible.** Make it yours.**Per-directory** themes, colors, and name badges**(`prod`\n\nin red,`staging`\n\nin amber), a configurable header (buttons + info chips), custom attention sounds, and Run / Skill menus to launch a project's scripts and`.claude/skills`\n\nright inside a cell.\n\n*The grid is a cockpit for parallel agents — here, four live Claude sessions, each in its own color-coded project. Every cell's header carries what you need to triage at a glance: model · context %, token counts (*\n\n`⇡in ⇣out`\n\n), the git branch / changes chip, and an AI summary of what the agent is doing. A cell's border color signals state — working (blue), done (green), needs-you (amber — e.g. waiting on a permission), idle — with an attention chime so a stuck cell off-screen still pulls you back. Supervise many; only step in where you're called.*Zoomed in, the cockpit roster replaces thumbnails with information: every session as a\ntext row — directory, AI summary, your last prompt, the agent's latest reply, a status word,\nand the branch's PR phase badge. A row whose agent is waiting on you rings amber and\nblinks; one that has merely finished rings green and stays still (Settings → Waiting rows\nturns the movement off). Click a row to swap the enlarged terminal.*\n\nEach session runs as a real PTY on the server (the agent CLI in a pseudo-terminal) and is\nstreamed to an [xterm.js](https://xtermjs.org/) terminal in the browser over a WebSocket. The\n**cockpit roster** lists every session and reflects, in real time, which are **working**\n(the agent is thinking, a spinner), which are **waiting on you** (a permission prompt or a\nquestion — an amber dot; nothing proceeds until you answer) and which are **finished with output\nyou haven't seen** (a green dot) — driven by Claude/Codex activity hooks the server injects per\nspawn. The horizontal tab bar carries the same two dots.\n\n*To focus on one agent, zoom its cell: it takes the window, and the GUI panel (\"Canvas\") opens beside it, where that agent's tool calls render as documents, forms, charts, images, and HTML rather than printed text. The app opens on the grid (*\n\n`/`\n\n, settling on `/terminals`\n\n), which is the only view; 3.x had a separate single view at `/chat`\n\nand 4.0.0 removed it, so that URL now lands on the grid like any other.**Inserting a file path** — like a native terminal, you can put a file's absolute path into\nthe prompt: **drag a file** onto the terminal, or click the **file button** in the terminal\nheader, which asks the local server to open the OS file dialog and inserts the chosen path. The\npath is inserted at the cursor — it is not submitted, so you can review it first.\n\nA drag inserts the file's **own** path where the browser exposes one via `file://`\n\n(Firefox/Safari), so editing it afterwards edits the file you dropped. Where the browser\nwithholds it — **Chrome**, and every browser when MulmoTerminal is open **from another\nmachine**, where a local path would name nothing on the host — the file's bytes are sent\ninstead, saved to a private per-session directory under the OS temp dir, and *that* path is\ninserted. The session is granted that directory at launch (Claude Code's `--add-dir`\n\n), so the agent reads it without a permission prompt; the copies are removed\nwhen the session ends, and any left by a crash are swept at the next start. Up to 110 MiB per\nfile — the same ceiling as a phone attachment. **A session already running when you upgrade\nwas launched without that grant**, so drops into it still prompt; new sessions don't.\n\n**Pasting a screenshot** — take a screenshot and paste it straight into the terminal\n(`Cmd`\n\n/`Ctrl`\n\n+`V`\n\n). The image is saved to the session's own drop directory — the same place a\ndropped file goes, with the same grant, the same 110 MiB ceiling and the same cleanup when the\nsession ends — and its **absolute path** is inserted at the cursor, so the agent can read it.\nUnlike a drop, this does not need the browser to expose a path — the bytes are on the\nclipboard — so it also covers Chrome, where dropping a file cannot insert a path. It works\nwherever the browser puts the image on the clipboard as `image/png`\n\n, `image/jpeg`\n\n,\n`image/gif`\n\n, or `image/webp`\n\n. Anything else is left to the terminal's own paste handling,\nexactly as before — including a paste that carries **plain text** next to the image, which\ncopying from a web page usually does, so that pasting text keeps working.\n\n**Clicking a file path** — the other direction. A path an agent *prints* becomes a link, and\n**what it opens is chosen by its extension**, so each kind arrives as the thing it is rather\nthan as bytes (files within the session's working directory only):\n\n| A clicked … | opens as |\n|---|---|\n`.md` `.markdown` |\nrendered markdown in a new tab — the same sandboxed `…/md` HTML the Files preview uses. It follows your system light/dark setting, since under the sandbox CSP it can't ask the app which theme is on |\n`.json` |\nindented in a new tab (Chrome and Safari otherwise show one long line) |\n`.csv` `.tsv` |\na table in a new tab, with a sticky header that scrolls inside its own box |\nsource, config, logs, and `.txt` — 46 extensions |\nthe app's own Files view (`/files?path=` ), where CodeMirror highlights it, the tree is right there, and it can be edited |\n| everything else — images, PDF, SVG, HTML, video | raw bytes in a new tab, which the browser renders better than an editor would |\n\n**While a grid cell is enlarged, the Files pane takes the click first** — every\nrow above except the last one, since the pane is the same editor plus a Markdown preview. The\nfile opens\n\n*beside*the terminal that printed it, and the pane opens itself if it was closed. It declines, leaving the routing above untouched, when nothing is enlarged, when the path is not under that cell's own directory (the pane cannot walk above its root), or for the raw-bytes row, where it would only show an empty editor.\n\nHighlighting in the Files view covers the JS/TS family, JSON and Markdown (the modes\n`cmEditor.ts`\n\nbundles); other languages open as plain text.\n\nThis set is **deliberately asymmetric** with the set the server serves as viewable text —\n`.md`\n\ngoes to the rendered viewer rather than the Files view, `.txt`\n\ndoes the opposite, and\ndotfiles are server-only. The 45 extensions both sides agree on live in\n`common/sourceExtensions.ts`\n\n, each side adds its own extras, and\n`test/common/sourceExtensions.spec.ts`\n\npins the asymmetry so it isn't \"fixed\" into symmetry.\n\nChanging this?The routing table is`ROUTE_BY_EXTENSION`\n\n/`IN_APP_EXTENSIONS`\n\nin`src/composables/terminalFilePathLinkProvider.ts`\n\n. Update this section, the`docs/guide/{en,ja}/features.md`\n\nrow, and the link table in`docs/terminal-notes.md`\n\ntogether — all three went stale once already (#834).\n\nThese are experiences reported by users who moved over from an IDE or a split terminal — not benchmarks, and not claims we measured. Your setup may differ.\n\nKeeping several agents apart by opening several IDE windows is expensive: each one brings its own\neditor, language server, extensions and file watchers. One user reported a **64 GB machine\nstuttering** under that load, and running smoothly after moving over — here the agents are PTYs on\na server and the UI is browser tabs.\n\nSix panes of scrolling text look identical. Users have described **typing a reply into another\nagent's terminal**, and losing track of what they had asked in the first place. As one put it, the\nwindows all look the same, so switching between them costs time just to work out what you are\nlooking at.\n\nThe problem isn't attention — it's that N identical panes means holding N contexts in your head. Colour-coded state, a name badge and a per-directory colour move that onto the screen instead.\n\nSplitting a terminal six ways leaves every pane too small to read a long answer without constant scrolling and resizing — one user described exactly that with a 4,000-character reply. So you quietly accept worse reading every time you add an agent.\n\n**Grid ↔ enlarge removes that.** Watch all of them, then blow one up and read it properly — the\ncockpit roster keeps the rest in view as text while you do.\n\nSessions resume as-is — same `claude --resume`\n\n, same transcripts. Point it at a directory you\nalready work in and your history is there. Nothing to migrate, nothing to redo. One user said this\nalone made the switch worth it, having previously lost context to killed sessions.\n\n**You don't need ten agents for this to pay off.** Users have reported the switch being worth it at\n**one to three** parallel sessions. The wins above are about not losing track, not about running\nmore.\n\n### 📖 Documentation — [receptron.github.io/mulmoterminal](https://receptron.github.io/mulmoterminal/)\n\n[receptron.github.io/mulmoterminal](https://receptron.github.io/mulmoterminal/)\n\n**User guide:**[English](https://receptron.github.io/mulmoterminal/guide/en/)— the grid view, everyday workflows, the full feature list, configuration, and mobile push notifications.**ユーザーガイド:**[日本語](https://receptron.github.io/mulmoterminal/guide/ja/)— グリッドの使い方・日々のワークフロー・機能一覧・設定・スマホ通知の設定はこちら。** Updates / アップデート情報:**new releases and features are announced** in Japanese**on X — 新バージョンや新機能のお知らせは X の[Singularity Society (@SingularitySoci)](https://x.com/SingularitySoci)で。\n\nNeeds **Node ≥ 22.9**, plus these CLIs on your `PATH`\n\n:\n\nNever installed any of this before?The guide walks it end to end, macOS and Windows, assuming no command-line experience:[Getting started]·[はじめに — 起動するまで]\n\n| Tool | What it gives you | Install | |\n|---|---|---|---|\nRequired |\n`claude` |\n\n`npm i -g @anthropic-ai/claude-code`\n\n, then run `claude`\n\nonce to log in**Required**`git`\n\n[worktree isolation](#git-worktrees--pull-requests), each cell's branch / unsaved-dot / diff readout, the PR footer`brew install git`\n\n· `sudo apt install git`\n\n· `sudo dnf install git`\n\n· Windows: [git-scm.com](https://git-scm.com/download/win)**Required**`gh`\n\n**PRs & Issues** view and one-click PR creation — it uses your`gh`\n\nlogin, so no token is stored[cli.github.com](https://cli.github.com), then`gh auth login`\n\n`glab`\n\n**GitLab** projects (#981) — gitlab.com, and a self-hosted instance you declare in`gitlabHosts`\n\n(#1332). Same arrangement: the CLI holds the credentials, this app stores no token`brew install glab`\n\n, then `glab auth login`\n\n(self-hosted: `glab auth login --hostname gitlab.example.com`\n\n)`tmux`\n\n[session persistence](#session-persistence-tmux)— terminals survive a server restart`brew install tmux`\n\n· `sudo apt install tmux`\n\n· `sudo dnf install tmux`\n\n· no native Windows build (falls back to plain PTYs)`codex`\n\n[Codex sessions](#agents-claude--codex)in a cell, alongside Claude`npm i -g @openai/codex`\n\n`ffmpeg`\n\n[mulmo-script panel](#wiki-collections--the-gui-panel)(its plugin ships enabled)`brew install ffmpeg`\n\n· `sudo apt install ffmpeg`\n\n· `sudo dnf install ffmpeg`\n\n`ollama`\n\n[— Claude Code against a fully local model](https://receptron.github.io/mulmoterminal/guide/en/claude-ollama.html)`claude-ollama`\n\n[ollama.com/download](https://ollama.com/download)**Choose a folder / Insert a file path** buttons, which open an OS dialog on the machine the server runs on. macOS and Windows have one built in;**WSL** uses the Windows one over interop and needs nothing installed. A Linux desktop needs one of these — without any, the buttons say so and you type the path instead (#1447)`sudo apt install zenity`\n\n· `sudo dnf install zenity`\n\n· `kdialog`\n\n, `qarma`\n\nand `yad`\n\nalso workThe server starts without any of the non-required rows; you just lose that row's feature,\nand the header/panel for it says so. `git`\n\nand `gh`\n\nare marked required because losing them\ncosts whole views rather than one button. `npx mulmoterminal@latest init`\n\n(below) reports which of\nthese it can find.\n\n```\nnpx mulmoterminal@latest           # start on http://localhost:34567 and open the browser\n# or install globally:\nnpm install -g mulmoterminal\nmulmoterminal\n```\n\n**Stopping it.** `Ctrl+C`\n\nin the terminal that started it — or, if you can no longer find that\nterminal, **Settings → Quit MulmoTerminal** in the browser, or ** npx mulmoterminal@latest stop**\nfrom any terminal (installed globally, just\n\n`mulmoterminal stop`\n\n). All three run the same shutdown:\nwith `tmux`\n\ninstalled the agent sessions survive and come back under **Settings → Sessions that survived a restart**; without it they end with the server.\n\n**First-run setup (optional).** `npx mulmoterminal@latest init`\n\nchecks your environment (Node ≥ 22.9\nand every CLI in the table above), seeds the launcher's **directory\npresets** from the projects in your Claude Code history, and writes `~/.mulmoterminal/config.json`\n\n.\nIt's **idempotent** — re-run it any time to refresh the presets; it overwrites the managed parts\nand keeps your other settings. When `claude`\n\nis installed it can hand off to the\n`/mulmoterminal-config`\n\nskill for interactive tweaks — it routes to the one that owns what you\nwant to change. Once the app is up you can also reach them from **Settings**: each section that a\nskill can write ends in a button that starts that skill in a new session, which is how the settings\nwith no UI (a theme of your own, `keymap`\n\n) get written without hand-editing JSON.\n\n**Google account (optional).** Link a Google account to enable the chat's `google`\n\ntool and the\nphone's `google.calendar.*`\n\ncommands: read/create events on any calendar (not just your primary),\nlist the calendars you've subscribed to, and read the colour palettes. Sign in from\n**Settings → Google account**, or run `npx mulmoterminal@latest google login`\n\n— the CLI is the fallback\nfor when you're driving MulmoTerminal from another machine, since consent finishes on a loopback\nlistener and needs a browser **on the host**. Either way it needs a Desktop OAuth client JSON saved\nas `~/.secrets/client_secret_*.json`\n\n; the refresh token lands in `~/.config/mulmo/google-token.json`\n\nand is **shared with MulmoClaude**, so one link per machine covers both apps.\n\n**Local models (optional).** The package also ships `claude-ollama`\n\n— a one-command launcher that\nruns Claude Code **fully locally against an Ollama model** (no cloud, no API\nkey). It starts a large-context Ollama server and launches\n\n`claude`\n\nwith a minimal system prompt so\nsmall models aren't drowned:\n\n```\nollama pull qwen3:4b\nnpx -p mulmoterminal claude-ollama qwen3:4b   # or, if installed globally: claude-ollama qwen3:4b\n```\n\nSee [Local models with claude-ollama](https://receptron.github.io/mulmoterminal/guide/en/claude-ollama.html)\nfor the details and model notes.\n\nAlready linked before the calendar-list / colour features?They need a read scope your existing link doesn't have, so`listCalendars`\n\n(and, in practice,`colors`\n\n) fail with an insufficient-scope 403 until you re-authorize:Settings → Google account → Unlink, then sign in again (or re-run`google login`\n\n). Reading/creating events on your primary calendar keeps working without re-linking.\n\nA global install isn't auto-updated, so on startup MulmoTerminal checks npm and\nprints a one-line notice when a newer version is available — and the web toolbar shows a\nclickable **update badge** with the exact command for your install (`npm i -g mulmoterminal`\n\n,\nor `git pull`\n\nfor a clone). The server repeats that check every few hours, so a release that\nships while it is running still reaches the badge; the startup console notice is printed once\nand is not repeated. Disable with `MULMOTERMINAL_NO_UPDATE_CHECK=1`\n\n(or `NO_UPDATE_NOTIFIER=1`\n\n).\n\nOptions: `--cwd <dir>`\n\n(working directory — relative paths allowed; defaults to the\ndirectory you run the command from), `--port <n>`\n\n(default 34567), `--no-open`\n\n,\n`--version`\n\n, `--help`\n\n.\n\n```\nnpx mulmoterminal@latest --cwd ./my-project   # work in a specific directory\n```\n\nThe published package ships the server (run via `tsx`\n\n) plus the pre-built web UI;\n`npx mulmoterminal@latest`\n\nchecks for the `claude`\n\nCLI, picks a free port, starts the\nserver, and opens the browser. For local development from a clone, see\n[Running](#running).\n\n**Won't start with ERR_MODULE_NOT_FOUND?** If a first\n\n`npx`\n\nrun was interrupted, a half-unpacked `~/.npm/_npx/<hash>`\n\ncache can remain and a later run fails at startup — a corrupted npx cache, not a bug in the published package.\nThe launcher detects it and prints the exact, OS-appropriate removal command; run that, then `npx mulmoterminal@latest`\n\nagain.\n\nSomething looks wrong?Type`/mulmoterminal-bug-report`\n\nin any MulmoTerminal session. The bundled skill hears the symptom out, checks yourrealconfig, schema and version to see whether the behaviour is configuration or by design, searches the existing issues — and only helps you file one if none of that explains it, with the environment collected and secrets masked. Getting you unstuck is the goal; an issue is what is left when the first three steps fail.\n\n[Architecture](#architecture)[Why a PTY?](#why-a-pty)[Agents: Claude & Codex](#agents-claude--codex)[Session persistence (tmux)](#session-persistence-tmux)[Tech stack](#tech-stack)[Configuration](#configuration)[Running](#running)[Scripts (Run menu)](#scripts-run-menu)[Skills (Skill menu)](#skills-skill-menu)[Files view (browse & edit)](#files-view-browse--edit)[Git worktrees & pull requests](#git-worktrees--pull-requests)[Cost & token usage](#cost--token-usage)[Wiki, Collections & the GUI panel](#wiki-collections--the-gui-panel)[More features](#more-features)[Server API specification](#server-api-specification)[Session model](#session-model)[Session lifecycle](#session-lifecycle)[Claude hook injection](#claude-hook-injection)[Closing summary](#closing-summary)[Session discovery & titles](#session-discovery--titles)[Project structure](#project-structure)[Testing](#testing)[Contributing](#contributing)\n\n```\n┌──────────────────────────────────────┐         ┌─────────────────────────────────────────────┐\n│ Browser (Vue 3 + xterm.js)            │         │ Server (Express + Node)                       │\n│                                       │         │                                               │\n│  App.vue ──────subscribe(\"sessions\")──┼──SIO───►│  socket.io  /ws/pubsub   ── publish ──┐       │\n│      ▲  refetch on any push (favicon) │         │                                       │       │\n│      └──── GET /api/sessions ─────────┼──HTTP──►│  Express   /api/sessions              │       │\n│                                       │         │            /api/hook  ◄──curl── hooks │       │\n│  Terminal.vue ── ws JSON msgs ────────┼──WS────►│  ws        /ws  ──► node-pty ─► `claude`──hooks┘\n│      (input / resize / output)        │         │                     (one PTY per session)     │\n└──────────────────────────────────────┘         └─────────────────────────────────────────────┘\n```\n\n**Terminal I/O** flows over a raw WebSocket (`/ws`\n\n), one PTY per session.**Session list** is fetched over HTTP (`/api/sessions`\n\n) — by`App.vue`\n\nfor the tab favicon, and by an empty cell's launch form (`?cwd=`\n\n) for its resume rows.**Live activity** is pushed over a Socket.IO pub/sub channel (`/ws/pubsub`\n\n); the server learns of activity from**Claude hooks** that POST to`/api/hook`\n\n.**Other terminals** run on their own raw WebSockets:**Codex** sessions on`/ws/codex`\n\n, persistent**launch commands** on`/ws/launch`\n\n, and one-off**script commands**(`yarn dev`\n\n, tests, …) on`/ws/run`\n\n. Only Claude/Codex are agent sessions with hooks; see[Agents: Claude & Codex](#agents-claude--codex)and[Scripts (Run menu)](#scripts-run-menu).- In dev (\n`yarn dev`\n\n) the Vite dev server runs on its own port (`CLIENT_PORT`\n\n, default`6856`\n\n) and proxies`/ws`\n\n(a prefix covering`/ws/codex`\n\n,`/ws/launch`\n\n, and`/ws/run`\n\n),`/ws/pubsub`\n\n,`/api`\n\n,`/artifacts`\n\n, and`/htmlfile`\n\nto the backend (`PORT`\n\n, default`34567`\n\n) — so you open the Vite port (e.g.`http://localhost:6856`\n\n). In production the backend serves the built client from`dist/`\n\non`PORT`\n\n, and you open that.\n\nClaude Code's interactive mode renders its UI with [Ink](https://github.com/vadimdemedes/ink)\n(a React-based TUI framework), which requires a real **TTY** to be attached. A\nplain `child_process.spawn()`\n\nprovides no TTY, so interactive Claude won't start\n(it stays silent). [node-pty](https://github.com/microsoft/node-pty) allocates a\nreal **pseudo-terminal** at the OS level, so from Claude's point of view it's\nrunning in an ordinary terminal — full TUI rendering, cursor movement, colors,\nand tool-approval prompts all work. We don't use `-p`\n\n/headless mode or the Agent\nSDK; we drive the real interactive CLI and relay its TTY over the WebSocket.\n\nmacOS note:node-pty's bundled`spawn-helper`\n\nbinary ships without the execute bit (mode 644), which causes a`posix_spawnp failed`\n\nerror. The`postinstall`\n\nscript (`server/fix-pty-perms.js`\n\n) fixes it to 755 automatically.\n\nMulmoTerminal drives **interactive coding-agent CLIs**, not just Claude. An\n`AgentAdapter`\n\nseam abstracts the per-agent bits (which binary to spawn, how it resumes)\nso the PTY, grid, persistence, and GUI-panel plumbing stay shared. Four adapters ship\ntoday — **Claude Code** (the default), **Codex**, **Antigravity** (`agy`\n\n), and **Grok**.\n\n-\n**Claude**— spawned as`claude`\n\n(override with`CLAUDE_BIN`\n\n). The server passes`--session-id <uuid>`\n\n, so it knows the live session's id even before its transcript file exists, and injects activity hooks per spawn (see[Claude hook injection](#claude-hook-injection)) plus the[closing summary](#closing-summary)instruction. The**whole** GUI MCP (`--mcp-config`\n\n, on one all-tools URL) goes only to a session that is not a grid cell, or to a grid cell whose cwd IS the workspace —`claimFullGuiMcp`\n\nin`server/session/registry.ts`\n\n, which is what gives a workspace cell the tools the single view had before 4.0.0 removed it. That equivalence is about what the session*carries*: a workspace cell is still a grid cell in every other respect. A cell in a project directory attaches none of ours, so its GUI tools come from whichever[Canvas switches](#wiki-collections--the-gui-panel)are registered for it.**Either way, Claude Code loads your own MCP servers normally**— the directory's local scope, any`.mcp.json`\n\nup the tree, your global ones and your claude.ai connectors. It did not always:`--strict-mcp-config`\n\nused to ride along with`--mcp-config`\n\n, which hid all of that from the very sessions meant to be the most capable ([#1338](https://github.com/receptron/mulmoterminal/issues/1338),[#1385](https://github.com/receptron/mulmoterminal/issues/1385)). -\n**Codex**— spawned as`codex`\n\n(override with`CODEX_BIN`\n\n;`CODEX_MODEL`\n\nsets`--model`\n\n). Codex runs on its own WebSocket (`/ws/codex`\n\n) and its sessions appear in the cockpit roster next to Claude's. Because Codex only mints its rollout id**after** the first turn, the server watches`~/.codex/sessions/**/rollout-*.jsonl`\n\n(home overridable via`CODEX_HOME`\n\n) and maps the new rollout to the session — attributed only when it's unambiguous, never by \"newest wins\". That mapping is appended to`~/.mulmoterminal/codex-rollouts.jsonl`\n\n, so a conversation is still resumable after the server restarts — without it a session whose tmux is also gone came back as a fresh codex. Resume reattaches a live PTY, adopts a surviving tmux session, or cold-resumes the rollout id. -\n**Antigravity**— spawned as`agy`\n\n(override with`ANTIGRAVITY_BIN`\n\n;`ANTIGRAVITY_MODEL`\n\nsets`--model`\n\n). Antigravity runs on its own WebSocket (`/ws/antigravity`\n\n). Like Codex it mints its own conversation id, so the server watches`~/.gemini/antigravity-cli/brain/`\n\n(home overridable via`ANTIGRAVITY_HOME`\n\n) for the directory the new conversation creates — attributed only when unambiguous — and cold-resumes it with`--conversation <id>`\n\n. That mapping is appended to`~/.mulmoterminal/antigravity-conversations.jsonl`\n\n, so a conversation is still resumable after the server restarts — the same log Codex keeps, in the same format.Its\n\n**GUI tools work differently — in the workspace too**, because`agy`\n\ntakes no MCP flag: it reads its servers from`.agents/mcp_config.json`\n\nin the working directory. So it never gets the workspace's \"every tool automatically\"; register what it needs with the Canvas switches, wherever it runs (see[MCP server ids](#mcp-server-ids-why-a-workspace-cell-and-a-project-cell-disagree)). MulmoTerminal writes that file from the directory's[Canvas switches](#wiki-collections--the-gui-panel)— the same switches Claude's cells read — so one switch serves every agent, and rewrites it whenever a switch flips or an agy session starts. Servers in it that MulmoTerminal did not write are left alone, the file is removed once no group is on, and it is kept out of your`git status`\n\nthrough`.git/info/exclude`\n\n— a local switch on a local machine, so it never reaches a diff or your team. The entry runs`server/mcp/bridge.mjs`\n\n, a stdio-to-HTTP shim onto the same in-process GUI MCP server the other agents call. The**session id is never written into that file**— it is per directory and shared by every session running there — and reaches the bridge through the agy process's own environment instead. -\n**Grok**— spawned as`grok`\n\n(override with`GROK_BIN`\n\n;`GROK_MODEL`\n\nsets`--model`\n\n), on its own WebSocket (`/ws/grok`\n\n). It resumes the way**Claude** does rather than the way Codex and Antigravity do:`grok --session-id <uuid>`\n\nstarts a conversation under an id MulmoTerminal minted, so there is no watcher, no attribution guess, and no mapping log — the id the browser holds is grok's own. A reconnect passes`--resume <id>`\n\ninstead, but only once a conversation by that name exists on disk: grok writes one under`~/.grok/sessions/<url-encoded cwd>/<id>/`\n\n(home overridable via`GROK_HOME`\n\n) after the first turn, and re-using a`--session-id`\n\nthat already exists is a hard error, which is why the two flags are never sent together.Its\n\n**GUI tools work like Antigravity's — in the workspace too** and for the same reason:`grok`\n\ntakes no MCP flag, so it never gets the workspace's \"every tool automatically\" either, and MulmoTerminal registers the bridge in`.grok/config.toml`\n\n(grok's project-scope config) from the directory's[Canvas switches](#wiki-collections--the-gui-panel), wherever it runs (see[MCP server ids](#mcp-server-ids-why-a-workspace-cell-and-a-project-cell-disagree)). That file is TOML and yours, so — unlike agy's JSON — MulmoTerminal never rewrites it directly: it drives`grok mcp add -s project`\n\n/`grok mcp remove -s project`\n\n, and only for the server ids it wrote itself. Nothing else in the file is touched, a directory already in the right state runs no command at all, and the file is added to`.git/info/exclude`\n\nonly when MulmoTerminal created it. As with agy, the**session id is never written into that file**— it reaches the bridge through the grok process's own environment.\n\n**Choosing an agent.** Each grid cell's launch form carries the **Agent Picker** — a\n**Claude / Codex / Antigravity / Grok / Shell** toggle — and the Collections browser a **Claude /\nCodex / Antigravity / Grok** one (your choice is remembered).\n**Shell** is not an agent: it runs your OS default shell (`$SHELL`\n\n, or `/bin/sh`\n\n) in the\nchosen directory, with nothing to install and nothing to configure. It starts a launcher\ncell, so it has no model, no MCP registration, and no worktree — those rows disappear\nwhile it is picked.\n\n**Other models.**\nClaude Code can run against any **Anthropic-compatible** backend (OpenRouter, Moonshot, a\nLiteLLM gateway). Backends are listed in `~/.mulmoterminal/config.json`\n\nunder `providers`\n\n,\nand their **keys are read from the server's environment** — never from a file the app\nserves. A directory sets its default in `.mulmoterminal.json`\n\n(`provider`\n\n/ `model`\n\n), and\neach grid cell's launch form has a **MODEL** select that overrides it for one session,\nlisting ~27 curated models with the measured pass rate of a real tool-using task beside\neach. A provider whose token can't be resolved **refuses to start** rather than falling\nback to Anthropic. Full walkthrough — setup, the measured model list, adding your own models, troubleshooting:\n[Using another model via OpenRouter](https://receptron.github.io/mulmoterminal/guide/en/providers.html).\n\n**Skills for Codex.** Codex has no `/<slug>`\n\nslash commands, so on session setup\nMulmoTerminal **mirrors the workspace's .claude/skills into ~/.codex/skills** (each\nmirrored directory carries a\n\n`.mt-mirror`\n\nmarker so a re-sync overwrites what MulmoTerminal\nowns and never clobbers Codex's own skills), and rewrites a collection's `/<slug> …`\n\nseed\ninto a plain `Use the \"<slug>\" skill.`\n\ninstruction. The same skills Claude uses then show\nup for Codex, loaded by description.If ** tmux is installed**, MulmoTerminal runs each Claude session and launcher inside\na tmux session, so\n\n**a server crash or restart doesn't kill your terminals**— the processes keep running and reattach when the server comes back (like\n\n`screen`\n\n/`tmux`\n\n).\nA long build, a dev server, or a mid-turn Claude session all survive `node --watch`\n\nreloads and crashes. It uses its **own** tmux server (\n\n`-L mulmoterminal`\n\n) and config, so\nit never touches your personal tmux sessions or keybindings.**No tmux? No problem** — terminals fall back to plain (non-persistent) PTYs, exactly as\nbefore. An explicit close (a cell's ✕) ends the tmux session; a machine reboot does not\nsurvive (tmux itself is gone). Command-cell scripts are ephemeral and not persisted.\n\n**Installing tmux** (optional):\n\n```\nbrew install tmux            # macOS (Homebrew)\nsudo apt install tmux        # Debian / Ubuntu\nsudo dnf install tmux        # Fedora\n```\n\nOn Windows there's no native tmux, so sessions use the non-persistent fallback — run the\nserver under **WSL** if you want persistence. Nothing else is required: MulmoTerminal\ndetects `tmux`\n\non `PATH`\n\nat startup and uses it automatically when present.\n\n| Layer | Technology |\n|---|---|\n| Frontend | Vue 3 (`<script setup>` + TypeScript), Vue Router, Vite, xterm.js (`@xterm/*` ), CodeMirror 6, socket.io-client |\n| Backend | Node (ESM, TypeScript run via `tsx` ), Express 5, `ws` (terminal WebSocket), `node-pty` , socket.io, `@modelcontextprotocol/sdk` (in-process GUI MCP) |\n| Plugins | GUI-protocol Vue plugins (`@mulmoclaude/*` , `@mulmochat-plugin/*` ): markdown, form, image, chart, HTML, collection, accounting, mulmoscript (MulmoCast video/slides), google |\n| Tests | Vitest + @vue/test-utils + jsdom |\n\nRequires **Node ≥ 22.9** (uses `node --env-file-if-exists`\n\n) and the `claude`\n\nCLI on `PATH`\n\n.\n\nThe server is configured entirely through environment variables, optionally\nloaded from a `.env`\n\nfile. `npx mulmoterminal@latest`\n\nreads the `.env`\n\n**in the\ndirectory you run it from**; the npm scripts read the one in the repo root. The\n`.env`\n\nis optional — every variable below has a default, so the server runs\nwithout one.\n\nA variable already set in your shell wins over the same name in `.env`\n\n, so\nadding a file never overrides what you exported. The server's environment is\ninherited by every terminal it starts, so anything in `.env`\n\nis also visible to\nthe `claude`\n\n/ `codex`\n\nsessions themselves.\n\n| Variable | Default | Description |\n|---|---|---|\n`PORT` |\n`34567` |\nBackend HTTP/WebSocket port (prod: the URL you open). |\n`CLIENT_PORT` |\n`6856` |\nVite dev-server port (dev only: the URL you open with `yarn dev` ). |\n`CLAUDE_BIN` |\n`claude` |\nThe 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` . |\n`CLAUDE_CWD` |\ncurrent 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). |\n`CLAUDE_PERMISSION_MODE` |\n`auto` |\nPermission mode passed to each `claude` spawn. |\n`MT_TITLE_SOURCE` |\n`transcript` |\nWhere the cell header's AI title comes from. `transcript` reads the title Claude Code writes into its own transcript — no extra process. `headless` restores the old behaviour of summarizing the recent turns with `claude -p` , which costs a model call but follows a session whose topic drifts (Claude's own title is written once and never revised). |\n`MT_TITLE_MODEL` |\n`haiku` |\nModel used for the cell header's AI title. Only read when `MT_TITLE_SOURCE=headless` . Accepts a `--model` alias or a full model id. |\n`CODEX_BIN` |\n`codex` |\nThe Codex CLI binary to spawn. |\n`CODEX_MODEL` |\ncodex default | Model passed to Codex as `--model` (unset = Codex's own default). |\n`CODEX_HOME` |\n`~/.codex` |\nCodex home — where its session rollouts and MulmoTerminal-mirrored skills live. |\n`ANTIGRAVITY_BIN` |\n`agy` |\nThe Antigravity CLI binary to spawn. |\n`ANTIGRAVITY_MODEL` |\nagy default | Model passed to Antigravity as `--model` (unset = agy's own default). |\n`ANTIGRAVITY_HOME` |\n`~/.gemini/antigravity-cli` |\nAntigravity home directory containing session brain storage. |\n`GROK_BIN` |\n`grok` |\nThe Grok CLI binary to spawn. |\n`GROK_MODEL` |\ngrok default | Model passed to Grok as `--model` (unset = grok's own default). |\n`GROK_HOME` |\n`~/.grok` |\nGrok home directory containing its per-directory session store. |\n`MULMOTERMINAL_HOME` |\n`~/.mulmoterminal` |\nRoot for managed git worktrees. |\n`CLAUDE_CONFIG_DIR` |\n`~` |\nClaude Code's own config directory. `.claude.json` lives inside it, so relocating your Claude Code config moves that file too — MulmoTerminal reads it to tell whether the per-project GUI MCP server is registered (`server/infra/gui-mcp-registration.ts` ). Leave it unset and `~/.claude.json` is used. |\n`MULMOCLAUDE_WORKSPACE_PATH` |\n`~/mulmoclaude` |\nWhere the managed MulmoClaude workspace lives. MulmoTerminal seeds presets/helps only into this directory, so launching in an arbitrary project never writes them there (`server/backends/workspaceSetup.ts` ). Set it to the same value MulmoClaude uses. |\n`MULMOTERMINAL_NO_SKILL_INSTALL` |\nunset | Set to any value to skip installing the bundled skills (`mulmoterminal-config` and the `-dirs` / `-theme` / `-header` / `-keys` / `-model` / `-notify` / `-bug-report` / `-decisions` family) into `~/.claude/skills/` and the Codex skills root on startup. |\n`GEMINI_IMAGE_MODEL` |\n`gemini-3.1-flash-image-preview` |\nModel 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. |\n`WAIT_REAP_GRACE_MS` |\n`1800000` |\nHow long a waiting background session is kept before it's auto-reaped (`0` or negative = never). |\n\nThe update-check opt-outs (`MULMOTERMINAL_NO_UPDATE_CHECK`\n\n, `NO_UPDATE_NOTIFIER`\n\n) are\ncovered in [Install & run](#install--run).\n\nExample `.env`\n\n(gitignored):\n\n```\nCLAUDE_CWD=/Users/you/my-project\n```\n\nThe Settings modal (the gear button) persists per-user UI choices to `~/.mulmoterminal/config.json`\n\n(read/written via `GET`\n\n/`POST /api/config`\n\n):\n\n*Open it from the gear button in the toolbar. The sidebar groups the sections — Appearance, Projects, Header & launch, Input, Models & servers, Notifications, Integrations, Sessions, Help — and one is on screen at a time; on a phone the sidebar becomes a picker above the section. Settings is available in English and Japanese: it follows your browser's language unless you pick one under Language (per browser, like the theme). Only this modal is translated so far. Under the title, a Version row shows what is running: the version from the shipped *\n\n`package.json`\n\n, plus a `commit <sha>`\n\nchip on a git checkout — there the version is only whatever was last released, so the commit is what identifies the build. When something newer exists, the row is followed by the header badge's update notice, command included. Pick a theme, set the terminal font size, font and scroll speed (and whether sending returns to the latest output), set a custom attention sound, list the repos the cross-repo PRs & Issues view should aggregate, add launch commands for grid cells, register your own MCP servers, and turn on the switches for what this app writes on your behalf — issue work comments, the PR clone footer, the closing summary, the decision digest, the dev worklog — no need to hand-edit the config file. Four settings stay with their skill because a form would be the wrong tool for them (`keymap`\n\n, `themes`\n\n, `providers`\n\n, `buttons`\n\n/`chips`\n\n); Settings shows what each is doing now and launches that skill. Note that theme, font size, scroll speed and the return-to-latest switch are stored per browser (they're display preferences, so a phone and a desktop keep their own); the rest live in `~/.mulmoterminal/config.json`\n\nand are shared by every client.| Field | Meaning |\n|---|---|\n`cwdPresets` |\nQuick-pick directories offered when launching a terminal. |\n`soundFile` |\nAbsolute path to a custom attention sound, the fallback for every kind. Empty/unset uses the built-in synthesized chime. |\n`soundKinds` |\nWhich moments beep — see\n`[\"finished\",\"waiting\"]` ; the other kinds are opt-in. |\n\n`sounds`\n\n`{ \"waiting\": \"preset:coin\" }`\n\n. A `preset:<id>`\n\nreference or an absolute path; a kind with no entry falls back to `soundFile`\n\n.`prRepos`\n\n`owner/repo`\n\nentries whose open PRs/issues the cross-repo **PRs & Issues** view aggregates, using whichever CLI the host needs — your own`gh`\n\nor `glab`\n\nlogin, so no token is stored here. An entry may name its host — `gitlab.com/group/project`\n\nis read with `glab`\n\n, and work can be started on it, commented on and turned into a merge request. A host that is neither shows a row saying so.`gitlabHosts`\n\n**self-hosted GitLab**, e.g.`[\"gitlab.example.com\"]`\n\n. Nothing in a URL says which forge a host runs, so declaring it is what lets `prRepos`\n\nentries on that host be read with `glab`\n\n— everything gitlab.com can do, it can do. Needs `glab auth login --hostname <host>`\n\n. Editable in Settings → **GitHub and GitLab**; either way it takes effect on the next server start.`repoDirs`\n\n`{ \"owner/repo\": \"/abs/path\" }`\n\n— 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`\n\non every read, and an entry that no longer names a clone of that repo is ignored.`launchers`\n\n`{ label, command }`\n\nentries offered in a grid cell's launcher besides the agents — any interactive command. A plain shell needs no entry: the Agent Picker's **Shell** option opens`$SHELL`\n\nunconfigured.`customAgents`\n\n`{ id, label, agent, command }`\n\nentries offered in the **Agent Picker**— your own way of starting Claude Code (`ollama launch claude --model … --`\n\n, a wrapper script). Unlike a launcher, Claude Code's own argv is **appended** to`command`\n\n, so the cell is a real session: resume, cost, context, GUI tools. `agent`\n\nsays which agent's arguments to append and is required (`\"claude\"`\n\nis the only value today); `command`\n\nmust stop taking arguments where Claude Code's begin — hence the trailing `--`\n\nabove. Up to 8.`quickCommands`\n\n`{ label, text, agents? }`\n\nphrases the **phone** offers as chips on a session's terminal view. Tapping one puts`text`\n\nin the input box; it is not sent until you press send. `agents`\n\n(`\"claude\"`\n\n/ `\"codex\"`\n\n/ `\"shell\"`\n\n) scopes a chip to session kinds — omit it to offer the chip everywhere. Empty by default.`userMcpServers`\n\n`{ id, url }`\n\nHTTP MCP servers merged into the `--mcp-config`\n\nof the **Claude** sessions that carry the full GUI MCP (codex is handed the GUI server alone,`codexGuiMcpServers`\n\n) — a cell whose working directory is the **workspace**, and a session the server starts itself (the phone, a scheduled task) unless it asks for a grid cell's shape, as an issue's seed session does (`issueSpawnOptions`\n\n). A cell in a project directory does not get this merge; the MCP config the user wrote is read either way. Takes effect on the next session.`buttons`\n\n[Header buttons](#header-buttons). Omit to keep the defaults; set to replace them.`chips`\n\n`dir`\n\n/ `git`\n\n/ `work`\n\n/ `diff`\n\n/ `ctx`\n\n/ `usage`\n\n/ `status`\n\n/ `tools`\n\n/ `env`\n\n, or custom text). `env`\n\nshows what this working tree was reserved by [(](#per-directory-settings-projectmulmoterminaljson)`worktreeEnv`\n\n`:3010`\n\n, clickable) and draws nothing where none is declared. Omit to keep the default set; `[]`\n\nhides all built-ins. `work`\n\nshows which PR / issue the cell is on (`#977 → #966`\n\n) and clears itself when the PR merges — see the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#work-chip).`pushEnabled`\n\n`true`\n\nto 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`\n\npicks which moments.`pushKinds`\n\n`\"finished\"`\n\n(a turn ended, ✅) and/or `\"waiting\"`\n\n(the agent stopped to ask — a permission prompt or a question, ❓, **once per prompt**). Omit to keep both;`[]`\n\nfor none. A kind added in a later version stays off until you tick it.`worklogEnabled`\n\n`true`\n\nto run the built-in **dev worklog** batch (see below). Off by default (each run spawns an LLM session, so it costs tokens). Editable in Settings →**Sessions and background tasks**.`worklogIntervalHours`\n\n`6`\n\n, clamped to `1`\n\n–`168`\n\n). A stepper in the same Settings section covers the range.`terminalSubmit`\n\n**submit** vs**newline**:`\"cr\"`\n\n(default — Enter submits, Shift+Enter makes a newline) or `\"esc-cr\"`\n\n(for a Claude Code rebound the other way). Applies to the keyboard **and** the phone remote-view submit, for**Claude sessions only**(shell/codex keep plain Enter). See the[Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#terminal-submit). Settings →**Terminal keys** offers both, worded as behaviour.`copyOnSelect`\n\n`true`\n\nputs a **mouse selection on the clipboard the moment it settles**, with no key pressed (the PuTTY / iTerm2 behaviour).** Off by default**— it changes the clipboard when you may only have meant to highlight something. There is a checkbox in Settings →** Terminal keys**, applied at once; a hand edit of the file needs a** server restart, then a tab reload**(the server reads this file once at startup, and the browser reads the value from it on load). Composes with the`copy`\n\nkeymap action rather than replacing it. Over plain `http://`\n\nthe browser gives a page no clipboard access, so a fallback asks xterm to copy instead; see the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#copy-on-select).`questionPaneEnabled`\n\n`true`\n\noffers a Claude session's **in a pane beside the enlarged terminal.**`AskUserQuestion`\n\nchoices as buttons**The terminal's own dialog stays** and the pane drives it — a click presses the arrow keys and Enter in the real dialog, so either end can answer and the first one wins. Claude sessions only (the choices arrive on Claude Code's hooks). A**single** question can also be answered in your own words — a text box under the buttons writes into the dialog's own`Type something`\n\nrow; several questions at once, or a multi-select one, get buttons only, and `Chat about this`\n\nstays in the terminal. **Off by default**— it lets a pane type into your terminal. Checkbox in Settings →** Terminal keys**, applied at once (the server re-reads the file per question). See the[Feature reference](https://receptron.github.io/mulmoterminal/guide/en/features.html#question-pane).`decisionDigest`\n\n**Markdown digest of the decisions this project's sessions asked for**, refreshed at startup and every few hours, so an agent can read what has already been decided before asking something similar. Written to`~/.mulmoterminal/decisions/<project>.md`\n\n(never into your repository) and served to agents by the bundled `mulmoterminal-decisions`\n\nskill. **Off by default**— it is a vision-stage idea, and it writes a file that would otherwise not exist. The digest holds dated facts, never inferred rules. Settings →**Sessions and background tasks** has the switch.`issueWorkComments`\n\n**comment on the issue it is working on**:** one comment**, posted when the work starts and then** edited**as the PR opens and merges (closing the issue if the forge has not already), each milestone stamped in UTC. The comment names the working**directory** it happened in — the folder name only, never the path — so a reader can tell which clone, and two terminals do not start the same issue twice. It says it came from MulmoTerminal. CI is deliberately not reported: it is on the PR already, and it flaps.**Off by default**; it writes to the forge, often on somebody else's issue. Needs the matching CLI logged in —`gh`\n\nfor GitHub, `glab`\n\nfor gitlab.com and any host declared in `gitlabHosts`\n\n. See the [Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#issue-work-comments). Editable in Settings →**GitHub and GitLab**.`prWorkdirFooter`\n\n`work in <clone>`\n\n— the directory name of the clone the work happened in, so a PR says which of several side-by-side checkouts produced it. Applies to **both** paths that open PRs here:**⧉ Open PR** appends it to the PR it creates, and every Claude session is told to end the bodies it writes with the same line (the name is resolved by the server, so a session inside a managed worktree still names the main checkout).**On by default**; set`false`\n\nto opt out, from Settings → **GitHub and GitLab** or the file — read per PR and per session spawn, so no restart is needed, and a second MulmoTerminal beside this one sees the change too. Appending is idempotent: an existing PR never gets a second copy.`appendSystemPrompt`\n\n**closing summary**— what was asked, what was achieved, what was not (see[Closing summary](#closing-summary)).** On by default**; set`false`\n\nto opt out, and a directory's `.mulmoterminal.json`\n\noutranks this. Settings → **Sessions and background tasks** has the switch. Read per spawn, so no restart is needed, though a session already running keeps what it was launched with.`true`\n\n/ `false`\n\nonly.`autoDirIcon`\n\n`icon`\n\nshows the favicon its repository already ships (`public/favicon.svg`\n\n, `apple-touch-icon.png`\n\n, a web manifest — first hit wins, ordered by how the image survives at 14px). **On by default**; Settings →** Directory appearance**has the switch. A single project opts out with`\"icon\": false`\n\nin its own `.mulmoterminal.json`\n\n, which this does not override. A key that was written and got it wrong shows nothing rather than falling back — a broken setting has to look broken. `true`\n\n/ `false`\n\nonly.`cockpitLines`\n\n`{ summary, prompt, response }`\n\n— how many lines each **cockpit-roster** row shows before it clamps (default`2`\n\n/ `2`\n\n/ `3`\n\n, each clamped to `1`\n\n–`20`\n\n). Raising them trades how many sessions fit on screen for reading a long one in place. Three steppers in Settings → **Waiting rows**.`showLoadAverage`\n\n**load average** beside the 5h / 7d usage windows, as a percentage of its cores (`load 334%`\n\n= a 20-core machine with 66.8 runnable processes). **On by default**; amber at 100%, red at 200%, hover for the raw 1 / 5 / 15-minute figures. Settings →** Grid header read-outs**has the switch. A host that keeps no load average (Windows) shows nothing rather than`0%`\n\n, whatever this says. `true`\n\n/ `false`\n\nonly.`fontFamily`\n\n**terminal font** every session renders in — a CSS font-family stack, e.g.`\"'Cica', 'MS Gothic', monospace\"`\n\n. Set it in Settings → **Terminal font**, applied at once; editing the file instead needs a** restart**(this config is read once at startup). Unset uses the built-in stack (JetBrains Mono / Fira Code / Menlo / Consolas, then CJK faces for Japanese, Korean and Chinese). Unlike the per-browser font**size**, this is one value for the whole host — it names fonts, and which fonts exist is a property of the machine. A directory can override it. See the[Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#font-family).Every MulmoTerminal on the machine shares this one file, so an older build could save over a key a\nnewer one wrote. It doesn't: a **top-level key this version doesn't recognise is written back\nuntouched**, which is what makes running two versions side by side — or downgrading for a while —\nsafe. A mistyped key survives on the same rule, which is deliberate: a line you can still see is\neasier to debug than one that silently vanished. See the\n[Configuration guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#unknown-keys).\n\nEach terminal header shows configurable **action buttons**. Omitting `buttons`\n\n(globally or per-dir)\nkeeps the built-in **starter set**: a file-path picker (📎), an OS file-manager reveal (📂), an in-app\nfile explorer (📁), a new terminal here (🖥), this branch's PR (🔗, git repos, only when a PR exists),\nand open-on-GitHub (🌐, git repos). Setting `buttons`\n\n(at either level) **replaces the whole default\nset** with your list (it is not merged on top), so listing your own — even a **shorter** one — is how\nyou drop, reorder, or swap them.\nA button has an `id`\n\n, `label`\n\n, and a `run`\n\nof `\"shell\"`\n\n(run a command), `\"input\"`\n\n(send text to the\nagent), or `\"open\"`\n\n. An `open`\n\nbutton targets one of `url`\n\n/ `reveal`\n\n(OS file manager) / `files`\n\n(in-app explorer) / `view`\n\n(a built-in overlay) / `terminal`\n\n(a dir → a new cell running `$SHELL`\n\n,\nopened next to the current one) / `pr: true`\n\n(open the current branch's PR — the button is hidden when\nthere's no open PR) / `pickFile: true`\n\n(OS file dialog → insert the path).\n`${dir}`\n\n, `${branch}`\n\n, `${repo}`\n\n, … substitute live context, and `when`\n\n(e.g. `\"isGitRepo\"`\n\n) gates\nvisibility. The `/mulmoterminal-header`\n\nskill writes a valid config interactively; per-dir buttons\nmerge over the global ones by `id`\n\n, while `chips`\n\nreplace the global list wholesale.\n\nSix moments can beep, each with its own sound and its own on/off switch. Running many\nagents at once is what turns notifications into noise, so **only the first two are on by\ndefault** — the rest are opt-in from Settings.\n\n| Kind | When | Default |\n|---|---|---|\n`finished` |\nthe turn ended and the output is unread | on |\n`waiting` |\nit stopped to ask — a permission prompt or a question | on |\n`command-done` |\na Run cell's command exited 0 |\noff |\n`command-failed` |\na Run cell's command exited non-zero, or never started |\noff |\n`session-exited` |\na session's terminal ended — including when you close the cell yourself |\noff |\n`pr-ci-failed` |\na directory's PR went red. Only seen while the roster is on screen, since that is what polls the phase |\noff |\n\nA **Run cell** is the one-shot cell a `script.json`\n\nentry or a `run:\"shell\"`\n\nheader button\nopens — not a shell launcher cell. A launcher runs an interactive shell that stays alive, so\nnothing marks where one command inside it ended; only the one-shot cell reports an exit code.\n\n`finished`\n\nand `waiting`\n\nreach the phone too (`pushKinds`\n\n); the other four are seen only in\nthe browser — a Run PTY never enters the session registry, and a PR phase is something the\npage polls — so Web Push cannot raise them.\n\n**What each one plays.** The default chime is generated with the Web Audio API — **no audio\nfile is bundled**, so the npm package stays light and has no media-licensing concerns. Beyond\nit there are two options:\n\n**Presets**— seven sounds hosted in the[ownplate](https://github.com/Nakajima-Foundation/ownplate)repo (MIT), referenced as`preset:<id>`\n\n:`chime`\n\n`coin`\n\n`cheep`\n\n`door`\n\n`gong`\n\n`magic`\n\n`meow`\n\n. The first play downloads one into`~/.mulmoterminal/sounds/`\n\n; every later play reads that file, so a preset keeps working offline. A failed download is not remembered as one — you get the chime that time and the next play retries. That holds on both sides: the server caches no failure, and it answers**503**(not 404) for a preset it could not fetch, because the browser remembers a 404 for the life of the page and only retries a 5xx.**Your own file**— an absolute path, per kind in`sounds`\n\nor as the all-kind`soundFile`\n\n.\n\nResolution per kind, nearest first: the session directory's `sounds[kind]`\n\n, its `sound`\n\n, your\n`sounds[kind]`\n\n, your `soundFile`\n\n, then the chime. The server streams whichever applies at\n`GET /api/sound?kind=`\n\n/ `GET /api/dir-sound?cwd=&kind=`\n\n, and the client falls back to the\nchime if it's missing or not audio.\n\n**Web Push on task finish.** Enable `pushEnabled`\n\nin Settings to have the server send a\npush (title = the project dir, body = the last prompt) to your registered devices each\ntime a **background** task finishes — the same signal as the attention chime, but for the\npanes you're not watching. Delivery is handled by the separate `mulmoserver`\n\n`sendPush`\n\nCloud Function; MulmoTerminal only makes the call, and only while the **RemoteHost**\nchannel is connected (its Google sign-in supplies the notification auth). With RemoteHost\ndisconnected, or with no device registered, the toggle is a no-op.\n\n**Dev worklog (cross-clone).** Set `worklogEnabled: true`\n\nin\n`~/.mulmoterminal/config.json`\n\n(and **restart** — the scheduler reads its tasks at boot)\nto register a built-in scheduled task. Every `worklogIntervalHours`\n\n(default 6) it spawns\na Claude session that reviews the work you did across **all your saved working dirs**\n(`cwdPresets`\n\n) since it last ran, and writes it up as a short manager-style report.\nIt runs as a **background worker**: behind the Background filter, never bold, and it takes\nno grid cell, so an hourly task cannot fill the grid. Web **Push** still fires for it —\nbeing quiet means out of the way, not unreachable, and it runs while you are away.\nMultiple clones/worktrees of the same repo (e.g. `myapp`\n\n, `myapp2`\n\n) are **merged into one\nper-repository section**, each covering what problem was addressed, what got solved, what's\nstill in progress, and — mined from the transcripts — decisions that were only *discussed\nand not built*. The window is **since the last run** (tracked in\n`config/scheduler/worklog-state.json`\n\n), not a fixed 6 h, so a missed/slept run doesn't drop\nwork. It reads and reconciles progress against `vision.md`\n\n/ `milestones.md`\n\n(creating\nempty ones if absent) so a long-running goal isn't forgotten.\n\nA run due while the server was **off** is not lost: built-in scheduled tasks record their\nruns in `config/scheduler/state.json`\n\nand **catch up at startup**, each by its own missed-run\npolicy. The worklog's is `run-once`\n\n— a single run covering everything since the last, so\nseveral missed windows don't become several batches summarising the same period. Whether\nthey ran, and when they run next, is in `GET /api/scheduler/tasks`\n\n; the history is in\n`GET /api/scheduler/logs`\n\n(`?taskId=&since=&limit=`\n\n, newest first) and on disk under\n`data/scheduler/logs/`\n\n.\n\nOutput lands in the wiki: one **weekly page** per ISO week\n(`data/wiki/pages/dev-log-YYYY-www.md`\n\n— filenames are lowercase, or the wiki can't open\nthem), each tagged `worklog`\n\n. To browse them, open the **作業ログ 一覧** hub page\n(`worklog`\n\n), which links every week, or click the ** #worklog** tag in the wiki index.\n\nOff by default because each run costs tokens — watch the cost readout and tune the cadence.\nRun it on a single \"hub\" instance; running it in several instances sharing one workspace\ndouble-fires it. The batch treats everything it reads (transcripts, git, wiki) as untrusted\ndata and only writes the worklog / hub / `vision`\n\n/ `milestones`\n\npages.\n\nDrop a `.mulmoterminal.json`\n\nin a project directory to give terminals opened **in\nthat directory** their own look and sound. It applies per terminal (per grid cell) —\nthe rest of the app keeps your chosen theme — and a directory's theme overrides your\nmanual theme pick for that terminal only. Every field is optional; a missing or\nmalformed file is ignored.\n\n```\n{\n  \"name\": \"PROD · payments\",            // badge shown on this directory's terminals\n  \"icon\": \"docs/logo.png\",              // image on this dir's cells (path here, URL, or data:); omit to use the repo's favicon\n  \"badgeColor\": \"#cf222e\",              // badge color (hex #rrggbb)\n  \"headerColor\": \"#190a23\",             // cell header background (hex #rrggbb)\n  \"headerTextColor\": \"#ffffff\",         // cell header text color while idle (hex #rrggbb)\n  \"headerStatusColors\": {               // what the header shows once a status takes the background over\n    \"working\": \"#6d28d9\",               //   just the background — the text colour is derived from it\n    \"done\": { \"background\": \"#166534\" },\n    \"blocked\": { \"background\": \"#7c2d12\", \"text\": \"#ffe8a3\" }\n  },\n  \"headerStatusTint\": \"background\",     // \"none\" keeps headerColor while working/done (not blocked)\n  \"cellColor\": \"#101014\",               // cell body background (hex #rrggbb)\n  \"cellBorderColor\": \"#2a2a4e\",         // cell border color (hex #rrggbb)\n  \"dotColor\": \"#00e676\",                // idle status dot (hex #rrggbb)\n  \"buttonColor\": \"#c7cdf0\",             // header icon buttons (hex #rrggbb)\n  \"theme\": \"nord\",                      // terminal palette: midnight | nord | daylight | solarized\n  \"colors\": { \"background\": \"#190a23\", \"cursor\": \"#ff2e63\" }, // per-key palette overrides\n  \"fontSize\": 16,                       // terminal font size in px (8–32); overrides Settings\n  \"fontFamily\": \"'Cica', monospace\",    // terminal font stack; overrides the global config\n  \"orderPriority\": 10,                  // rank in the grid's \"priority\" order and the launcher chips (lowest first)\n  \"sound\": \"./.mulmoterminal/alert.mp3\", // attention sound, RELATIVE to this directory\n  \"sounds\": { \"command-failed\": \"preset:gong\" }, // per-notification-kind override\n  \"appendSystemPrompt\": false,          // no closing summary here; omit to follow the global setting\n  \"worktreeEnv\": {                      // a port / database name of its own per git worktree\n    \"PORT\": { \"kind\": \"port\", \"base\": 3000 },\n    \"DB_NAME\": { \"kind\": \"slug\", \"prefix\": \"myapp_\" }\n  }\n}\n```\n\n**Already have a repo.json?** MulmoTerminal reads it. It is an\n\n[open repository-metadata format](https://receptron.github.io/mulmoterminal/repo-json.html)— one small file any tool can read — and a project that ships one gets a coloured, named, icon-bearing cell without knowing this app exists:\n\n```\n{ \"name\": \"diffusion-lab\", \"icon\": \"docs/logo.png\", \"color\": \"#7c3aed\" }\n```\n\nOne colour becomes all seven: the header is it exactly, the badge/border/dot/button/body are\nderived from its hue, and the header text is derived for contrast. Anything this app understands\nbut the open format doesn't goes under `extensions.mulmoterminal`\n\n.\n\nThe three files layer, general to specific — ** repo.json → .mulmoterminal.json →\n.mulmoterminal.local.json** — replacing whatever keys the one below it set.\n\n**Several clones of one repository?** Drop a `.mulmoterminal.local.json`\n\nbeside it. It is read\nafter `.mulmoterminal.json`\n\nand **replaces whatever keys it names**, so the shared file holds what\nthe project is — name, theme, a colour — and each checkout's local file holds only what makes it\nrecognisable:\n\n```\n// .mulmoterminal.json — the project. Complete on its own, so one clone needs nothing else.\n{ \"name\": \"acme-web\", \"theme\": \"nord\", \"badgeColor\": \"#1b3479\", \"headerColor\": \"#2d4ea9\", \"orderPriority\": 30 }\n\n// .mulmoterminal.local.json — this checkout only. Gitignore it.\n{ \"badgeColor\": \"#27b4a8\", \"headerColor\": \"#4ed0c5\", \"orderPriority\": 65 }\n```\n\nWhole keys, not a deep merge: a `colors`\n\nblock in the local file replaces the shared one entirely.\nSettings → Directory settings names both files and lists which keys the local one took over.\n\n*As cells pile up it gets hard to tell which project is which. Give each repo a name badge and its own colors in .mulmoterminal.json and they're unmistakable — headerColor/badgeColor tint the frame, while colors reaches all the way into the terminal's own background and text. (The example above dresses four repos in Mondrian / van Gogh / Picasso / Matisse palettes.)*\n\n| Field | Meaning |\n|---|---|\n`name` |\nLabel shown as a badge in the terminal/cell header. |\n`icon` |\nAn image marking this directory — shown in the cell header, the cockpit roster, the filmstrip thumbnails, the launcher's directory chips, and the phone's terminal list and terminal screen. Either a path relative to this directory (an absolute path, or a `../` that escapes it, is rejected), an `http(s)://` URL, or a `data:image/…` URI. PNG / JPEG / GIF (animated plays) / WebP / AVIF / SVG / ICO / BMP. Not to be confused with a header button's `icon` , which is a Material Symbols name. Omit it and the repository's own favicon is used (`public/favicon.svg` , `apple-touch-icon.png` , a web manifest — see `autoDirIcon` ); `false` means no icon here and stops that search. |\n`badgeColor` |\nBadge background color (`#rrggbb` ); text auto-contrasts. |\n`headerColor` |\nHeader 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. |\n`headerTextColor` |\nHeader text color (`#rrggbb` ) — everything written on the header: the dir path, title and prompt, plus the model/context badge, the token counts and any custom chip. Omit it and a readable colour is derived from It applies while that colour is what shows: a working/done/blocked cell paints the theme's own status tint, so its text returns to the theme's too — an ink chosen for your header colour is not readable on a tint the theme mixed. Recolour those states with `headerColor` .`headerStatusColors` instead. |\n`headerStatusColors` |\nWhat the header shows once a status owns the background: an object keyed by `working` / `done` / `blocked` (there is no `idle` — `headerColor` is idle). Each value is a background `#rrggbb` , or `{ \"background\": …, \"text\": … }` . Omit , so naming one colour can never come out unreadable. A status you don't name keeps the theme's tint.`text` and a readable one is derived from the background |\n`headerStatusTint` |\n`\"background\"` (default) lets a status replace the header background. `\"none\"` keeps `headerColor` while working and done — the status still reads from the cell border, the status dot and the pill. It deliberately does not reach `blocked` , the one state where nothing proceeds until you answer; give that state a colour of its own in `headerStatusColors` if you want one. |\n`cellColor` |\nCell body background color (`#rrggbb` ) — the frame around the terminal. |\n`cellBorderColor` |\nCell border color (`#rrggbb` ). The status frame (working/blocked) still overrides it while active. |\n`dotColor` |\nIdle status-dot color (`#rrggbb` ). The working/waiting colors are unchanged so the activity signal stays intact. |\n`buttonColor` |\nHeader icon button color (`#rrggbb` ) — expand / close / attach / folder / etc., across both header rows. |\n`theme` |\nxterm palette for terminals in this directory (one of the built-in theme ids). |\n`colors` |\nPer-key xterm palette overrides applied on top of `theme` (or the app theme when `theme` is unset). Keys are xterm `ITheme` names (`background` , `foreground` , `cursor` , `selectionBackground` , the 16 ANSI colors, …); values are hex (`#rgb` / `#rrggbb` / `#rrggbbaa` ). Unknown keys / bad values are dropped. |\n`fontSize` |\nTerminal font size in px for this directory (8–32), overriding the Settings value. A size outside the range is clamped; a non-number is ignored. Changing it re-fits the terminal, so the PTY learns the new width — unlike browser zoom, which leaves the two disagreeing. |\n`orderPriority` |\nThis directory's rank in the grid's priority ordering — the third mode on the toolbar's ordering button, next to auto (attention-first) and manual (the move buttons). Any integer, lowest first; negatives are allowed. Directories that set nothing sort last, keeping their existing order, so adding the key to one project doesn't shuffle the rest. The grid reads it in priority mode only; the launcher's directory chips sort by it, so a project sits in the same place on both. The one exception is the workspace chip, which always leads the launcher's row regardless of any rank — it is not one of the directories being ranked against each other, and it is the one place a claude or codex session reaches every GUI tool without registering anything (agy and grok get what the directory registered wherever they run — see\n|\n`fontFamily` |\nCSS font-family stack for this directory's terminals, overriding the global `fontFamily` . Use the names as your OS lists them (`\"'Cica', 'MS Gothic', monospace\"` ). An unusable stack is ignored whole rather than half-applied; `monospace` is appended if you name no generic family. Prefer fonts whose fullwidth glyphs are exactly twice the Latin width, or box-drawing frames tear. |\n`sound` |\nAttention sound for this directory's sessions, a path relative to the directory (served at `GET /api/dir-sound` ). The fallback for every kind. |\n`sounds` |\nPer-kind override of `sound` : `{ \"command-failed\": \"preset:gong\" }` . Each value is a `preset:<id>` or a directory-relative path, under the same confinement. |\n`appendSystemPrompt` |\nWhether this directory's Claude sessions are asked to end a reply with a closing summary (see\n`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. |\n`worktreeEnv` |\nValues every working tree of this project needs its own of — the port its dev server binds, the database its migrations touch. A worktree isolates files, not ports: two trees running `yarn dev` both reach for 3000 and the second one dies. Each variable is `{ \"kind\": \"port\", \"base\": <1024–65215> }` (a free port, `base` + a multiple of 10 — the checkout keeps `base` , its worktrees take the numbers above it) or `{ \"kind\": \"slug\", \"prefix\": \"…\" }` (a `[a-z0-9_]` name from the tree's task name, ≤ 63 chars, for a database / schema / container). Up to 16, under whatever names the project reads (`PORT` , `VITE_PORT` , …). A value is reserved once and kept (`~/.mulmoterminal/worktree-env.jsonl` ) for as long as its declaration is unchanged, so a running dev server's port never moves under it; editing `base` re-allocates, renaming or dropping a variable releases what it held, and removing the worktree releases all of them. Set on every terminal in the directory — agent cell, Shell, launcher, Run command — and shown on the header's `env` chip, where a port is a link to `http://localhost:<port>` . MulmoTerminal hands out the name; creating the database is the project's own job. |\n`addDirs` |\nExtra 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. |\n\n**Security.** `sound`\n\nand every `sounds`\n\nentry are directory-relative paths only — absolute\npaths and any `../`\n\nthat escapes the directory are rejected, and the path is never taken from the\nHTTP request, so an opened project can't point the player at arbitrary files.\n**When changes take effect.** A write made *through Claude's tools* — which includes the\n`mulmoterminal-dirs`\n\nskill — applies **live**: the tool hook that reports the write doubles\nas the reload signal, so colors, palette, font size and grid order update without reopening\nanything. There is no filesystem watcher, so an edit made **outside** a session (your own\neditor) is picked up when the terminal is next opened.\n\n**Checking what took effect.** Settings → **Directory settings** lists your recent directories\nand expands each one to the values in force, with a swatch per color and the path of the file\nthey came from. It also names the keys it **dropped** (a color that isn't `#rrggbb`\n\n, a size out\nof range) and the keys it doesn't read at all (`badgeColour`\n\n, a global-only setting) — which is\nwhat tells \"I never set that\" apart from \"I set it and it didn't take\".\n\n```\nyarn install            # postinstall fixes node-pty prebuilt binary perms\n\nyarn dev                # backend (:34567) + Vite UI (:6856), concurrently — open http://localhost:6856\n# or individually:\nyarn dev:server         # backend only  (node --import tsx --env-file-if-exists=.env server/index.ts)\nyarn dev:client         # Vite dev server only\n\nyarn typecheck          # type-check everything (vue-tsc -b)\nyarn build              # type-check + vite build -> dist/\nyarn server             # run backend; serves dist/ + the APIs on :34567\nyarn test               # vitest run\n```\n\n`yarn typecheck`\n\ncovers the whole repo. The root `tsconfig.json`\n\nis a solution\nfile that references all five projects, so one `vue-tsc -b`\n\nbuilds them:\n`tsconfig.app.json`\n\n(client), `tsconfig.node.json`\n\n(vite config),\n`tsconfig.server.json`\n\n(backend, run directly via `tsx`\n\nwith no build step),\nplus `tsconfig.test.json`\n\nand `tsconfig.test-server.json`\n\nfor the specs — which\nneed checking of their own because vitest strips types rather than checking\nthem. They exist as separate projects because each has its own compiler options\n(the client ones DOM + `.vue`\n\n, the server ones node, the specs with\n`noUncheckedIndexedAccess`\n\noff).\n\nIn dev, open the Vite URL; its proxy forwards `/ws`\n\n, `/ws/pubsub`\n\n, and `/api`\n\nto\n`:34567`\n\n, plus the two mounts that serve a presentHtml page to the Canvas iframe,\n`/artifacts`\n\nand `/htmlfile`\n\n— a page path missing from that list is answered by\nVite's SPA catch-all with `index.html`\n\n, which renders as a blank iframe rather\nthan an error. In production, run `yarn build`\n\nthen `yarn server`\n\nand open\n`http://localhost:34567`\n\n.\n\nAn empty grid cell's launcher sets the **Working directory** by typing, by a preset\nchip, or with the **📁 folder button** (a native OS folder dialog). The preset chips are\nthe directories you have launched in — **worktrees excluded**, since one is a single task's\nbranch that is deleted with the task, not a place to launch in again; the\n**workspace** leads them always, labelled\n**WORKSPACE** and marked with an icon, whether or not you have ever launched there — it is\nthe one directory where a claude or codex session reaches every GUI tool (agy and grok get\nwhat the directory registered, there as anywhere — see\n[MCP server ids](#mcp-server-ids-why-a-workspace-cell-and-a-project-cell-disagree)), so it is never a click you can\nlose. It has no remove button for the same reason. It is named for its role rather than its\nfolder, because the folder name (`~/mulmoclaude`\n\nby default, or wherever `CLAUDE_CWD`\n\npoints) says the least interesting true thing about it; the real path is on its hover.\n\n**The launcher is shorter in the workspace**, because two of its choices do not apply\nthere. The per-directory **Canvas switches** are replaced by a line saying every GUI tool\nis already available — a session there is handed the whole GUI MCP at spawn, so a switch\nwould register a group URL that then has nothing left to serve. **With Antigravity or Grok\npicked they stay**, in the workspace as everywhere else: neither is handed anything at spawn\nand both read their servers from the directory's file, so the switches are their only route\nto a GUI tool and hiding them would leave the session with none (see\n[MCP server ids](#mcp-server-ids-why-a-workspace-cell-and-a-project-cell-disagree)).\nAnd the **worktree**\nsection is hidden: a worktree isolates work on one codebase onto a branch, while the\nworkspace is what a session works *from* (the shared wiki, collections and accounting\nlive there), which is precisely what a detached branch would cut it off from. Both come\nback the moment you point the field at a project directory.\n\nIt also offers a\n**run a script** row\nthat launches project scripts (a dev server, tests, a build, …) **in that cell, in\nthe directory the cell is pointed at** — so a whole workflow lives in one window\nalongside the Claude sessions. Scripts are **per-directory**: the cell reads the\n`script.json`\n\nof whatever directory you select, so different cells can offer\ndifferent projects' scripts.\n\nThe same launcher also has an **or launch** row for your configured **launch commands**\n— any interactive command — set in Settings (the gear button) → **Launch commands** as\n`{ label, command }`\n\n(e.g. `htop`\n\n→ `htop`\n\n, `Codex`\n\n→ `codex`\n\n). A plain shell needs no\nentry here: the Agent Picker's **Shell** option already opens `$SHELL`\n\n. Unlike\na one-shot script, a launcher runs as a **persistent terminal in the cell's directory**:\nit survives grid page switches and reconnects, and its dot shows running vs. exited (it\nhas no Claude hooks, so no blocked/done states).\n\nEvery running terminal's header also has a **▶ Run ▾** dropdown (next to the\nconnection status) — but **only when the\nopen project has scripts** (no `script.json`\n\n, no button). It lists the **open\nproject's** `script.json`\n\n— the directory that terminal runs in — and launches the\npicked script in a **spare grid cell** (reusing an open launcher, else a new one), so\nyou can watch it. So you can start a\ndev server or tests for the project you're working in without disturbing the\nsession that's running.\n\nThe list is populated from a ** script.json** at the chosen directory's root. It's\noptional; a directory without one simply shows no scripts.\n\n```\n// <dir>/script.json\n{\n  \"scripts\": [\n    { \"label\": \"Dev server\", \"command\": \"yarn dev\" },\n    { \"label\": \"Unit tests\", \"command\": \"yarn test\" },\n    { \"label\": \"Build\", \"command\": \"yarn build\" },\n    // optional per-script working dir (relative to this file, or absolute):\n    { \"label\": \"Sub server\", \"command\": \"yarn serve\", \"cwd\": \"packages/server\" }\n  ]\n}\n```\n\n| Field | Required | Meaning |\n|---|---|---|\n`label` |\nyes | What the launcher shows. |\n`command` |\nyes | Shell command, run via the login shell (`$SHELL -lc \"<command>\"` ). |\n`cwd` |\nno | Working dir, relative to `script.json` or absolute. Defaults to the cell's directory. |\n\nA command terminal is **not** a Claude session: it has no session id, no hooks, no\ntranscript, and **isn't persisted** — it's ephemeral, so a page reload drops it and\nclosing the cell (or reloading) kills the process. When the command exits, the cell\noffers a **↻ re-run**. The browser only ever sends the script's **index** + its\ndirectory; the server reads that directory's `script.json`\n\nand resolves the\ncommand, so the file is the allowlist of what can run.\n\nEach command cell also has a **✦ Summarize** button: click it to send the cell's\ncaptured output to `claude -p`\n\n(headless) and get a short **Errors / Warnings /\nlikely cause / suggested fix** note in a panel — handy when a build or install\nburies the one failing line in thousands. It's manual (never auto-runs) and analyzes\nthe last 32 KB of output. See\n[ POST /api/command/summarize](#http-post-apicommandsummarize).\n\nNext to the **▶ Run ▾** dropdown, every running terminal's header has a **⚡ Skill ▾**\ndropdown — and **only when the open\nproject has skills** (nothing discovered, no button). It lists the\n[Claude skills](https://docs.claude.com/en/docs/claude-code/skills) discoverable for\nthat terminal's directory — both **project scope** (`<dir>/.claude/skills`\n\n) and **user\nscope** (`~/.claude/skills`\n\n), the same skills Claude sees — and, on pick, **runs the\nskill in that session**: it types the skill's invocation into the terminal and submits\nit (for Claude, its `/<slug>`\n\ncommand; for Codex, which has no slash command, a plain\n`Use the \"<slug>\" skill.`\n\ninstruction). Unlike **▶ Run** — which launches a\n`script.json`\n\nshell command in a spare cell — a skill runs **in the session you\npicked it from**, continuing that conversation.\n\n**Ordering:** working-dir (project) skills come **first**, then user-scope ones,\nalphabetical within each group; a project skill of the same slug shadows the user one.\n\n**Filtering:** add a `skills`\n\narray to the directory's\n[ .mulmoterminal.json](#per-directory-settings-projectmulmoterminaljson) to narrow the menu —\nan allowlist of slugs that also sets the order (only those show, in that order). Omit\nit to show everything.\n\n```\n// <dir>/.mulmoterminal.json\n{ \"skills\": [\"review-diff\", \"commit-msg\"] }\n```\n\nEach menu item shows the skill's id, with its `SKILL.md`\n\n`description`\n\nas the hover\ntooltip. A directory (or workspace) without any `.claude/skills`\n\nsimply shows no\nbutton. Skills are discovered read-only; the menu never creates or edits them.\n\nA terminal header can carry a **📁 Files** button — add it as a [header button](#header-buttons)\n(`\"open\": { \"files\": \"${dir}\" }`\n\n) — that opens a full-screen file explorer\nrooted at **that terminal's project directory** — so after Claude says \"wrote `foo.md`\n\n\"\nyou can jump straight there to read or edit it. The left pane is a lazy-loaded directory\ntree; clicking a file opens it in a **CodeMirror** editor (Markdown / JS-TS / JSON\nhighlighting, everything else as plain text). Markdown files get a **Preview** toggle\nthat renders via the server's sandboxed `…/md`\n\nHTML. **Save** (or ⌘/Ctrl-S) writes back.\n\n**Beside an enlarged terminal, not only full-screen.** Expand a grid cell (**⤢**) and its\nheader gains a **folder** toggle that splits the enlarged area in two: terminal on the left,\nthe same explorer + editor on the right, rooted at that cell's directory. Drag the divider\n(or focus it and use ←/→, Home, End) to resize — the terminal keeps a floor, so a squeeze\nshrinks the pane rather than reflowing xterm into garbage. It works in both zoomed layouts\n(cockpit roster and thumbnail filmstrip), the pane re-roots as you walk the zoom between\nterminals, and whether it's open plus how wide it is are remembered per browser.\n\nThe toggle is not the only way in: while a cell is enlarged, **clicking a file path the agent\nprinted** opens it here too, rather than in a new tab or full-screen — see\n[Clicking a file path](#clicking-a-file-path).\n\nAll reads and writes go through `GET/PUT /api/files/browse/*?cwd=&path=`\n\n, and every\n`path`\n\nis **contained within the project root** (server-side) — `..`\n\n/absolute escapes\nare rejected for reads and writes alike, so editing can't reach outside the directory\nthe terminal is pointed at. A save sends the version the file had when it was opened, so\nit is **refused (409) rather than silently overwriting** an agent that edited the same\nfile meanwhile; the editor then offers to reload or to overwrite deliberately.\n\nYou usually hear about it before that. An open file that changes on disk is picked up from\nClaude's own write hook (immediately) and from a 30-second version check (which catches Codex,\ngit, builds and other editors too). A **clean** buffer just takes the new content — the pane\nreads as a live view — while a **dirty** one raises the same banner rather than choosing for you.\n\n**Leaving an open file saves it** — switching files, moving the enlargement to another\nterminal, closing the pane, navigating away. No dialog interrupts you mid-flow, because\nopening a file, and replacing one, keep a copy under `~/.mulmoterminal/backups/`\n\n— **three\ngenerations per file**, outside the project so they never reach `git status`\n\nor the agent's\nview of its own repo. A parting save that loses the version race banks your version there\ninstead of overwriting the other writer. Re-opening unchanged content doesn't rotate one in, and a backup that\ncan't be written never blocks the read or the save it was taken for.\n\nWhen a terminal's directory is a git repo, its header shows a **branch chip**\n(`⎇ <branch>`\n\nwith dirty / ahead / behind counts), fed by `GET /api/git-status`\n\n(polled\nwhile the view is visible). A **GitHub** menu links straight to the repo, its issues, and\nits pull requests.\n\n**Worktree isolation.** A grid cell's launch form offers **＋ New worktree**: name a task\nand the cell launches its agent inside a fresh\n[git worktree](https://git-scm.com/docs/git-worktree) on a new `agent/<slug>`\n\nbranch — a\nseparate working tree that shares the repo's `.git`\n\n, so several agents can work the same\nrepo without colliding. Worktrees live under `~/.mulmoterminal/worktrees/`\n\n(override with\n`MULMOTERMINAL_HOME`\n\n), and existing ones are listed below the field.\n\n**A worktree inherits the project's settings.** A fresh worktree used to have none — no colours,\nno name, no model, no grid rank. It is now given its own copy derived from the project's, written\nto ** .mulmoterminal.local.json** so it layers over whatever the repository committed:\n\n`name`\n\n/\n`theme`\n\n/ `colors`\n\n/ `fontSize`\n\n/ `fontFamily`\n\n/ `provider`\n\n/ `model`\n\n/ `worktreeEnv`\n\nas written\n(the last is a declaration rather than a value — the worktree resolves its own values from it), the\nseven chrome colours **rotated 12 degrees further around the hue wheel per worktree**(so a project's trees read as a gradient; a grey like\n\n`#ffffff`\n\nhas no hue to move and stays put), and\n`orderPriority`\n\nat the project's rank **+ 1**, so the worktree sorts directly after it.\n\n`sound`\n\n/\n`sounds`\n\n/ `addDirs`\n\nare not carried — they name paths inside the project directory. Written only\nwhere git would **ignore what it writes**: an untracked file in a worktree's\n\n`git status`\n\nwould\nmake it count as dirty, and a dirty worktree is one MulmoTerminal refuses to remove. The local\noverride is preferred; a repo that ignores `.mulmoterminal.json`\n\ninstead (the setup this feature\nshipped with) still gets its colours there. A committed shared config is never written to. A local\nfile the worktree already has is never overwritten.**One worktree, one session.** A worktree is tied to a branch, so it is never started\ntwice: a listed row **resumes** that worktree's session when it has one, and **starts** one\nonly when it has none. A row whose session is open in another terminal reads `in use`\n\nand\ncannot be clicked — close it there first. The refusal follows the *directory*, not the row:\nthe same worktree reached by pasting its path into **WORKING DIRECTORY**, or by a recent-dir\nchip, will not launch either — and the **server** refuses the spawn whichever client asks,\nso a path spelled another way (a trailing slash, a symlink) does not slip past.\n\nWhat the limit covers is an **agent**: Claude, Codex, Antigravity or Grok, including an **OR\nLAUNCH** command that runs one of them. A **Shell**, and a launcher that runs anything else\n(`yarn dev`\n\n, `lazygit`\n\n, `htop`\n\n), stays free — a worktree an agent is working in is exactly\nwhere you want those. A project that declares `worktreeEnv`\n\nalso gets **its own value per\nworktree** for each variable it declares there — so two dev servers given a port of their own do\nnot both reach for 3000 (see\n[ worktreeEnv](https://receptron.github.io/mulmoterminal/guide/en/config.html#worktree-env)).\n\nThe same holds for **OR RESUME HERE**: a session someone is holding is listed with `● open`\n\nand refused, where before it could be confirmed away — which detached whoever had it.\n\"Someone\" means any terminal anywhere, including another browser tab and a second\n`mulmoterminal`\n\nprocess on this machine: the server answers from its own PTY table plus\ntmux, not from what one page can see.\n\n**Change the directory and those lists empty immediately**, replaced by a single\n`Loading this directory's sessions, worktrees and scripts…`\n\nrow until the new ones arrive.\nEverything the launch form offers below the field — **OR RESUME HERE**, the worktrees and\n**OR RUN A SCRIPT** — belongs to the directory it was read for, and reading it costs a\ndebounce plus a round trip. Rows left standing through that wait would be the previous\ndirectory's, listed under the new directory's name, and clicking one resumes exactly the\nsession it offers.\n\nA worktree started **from an issue** gets an `issue/<N>-<slug>`\n\nbranch instead. The number\nin the name is what later tells the app which issue the work belongs to: the ⧉ Open PR\nbutton puts `Fixes #<N>`\n\nin the PR body, and the branch chip, the issue work comment and\nthe merge-time auto-close all read the same number rather than guessing at it.\n\nThat path also **fetches first and forks from origin/<base>**, because several clones of\none repo often run side by side and only the one being worked in gets pulled — forking from\nthe local branch would start the work on however old that clone happens to be. A local base\nthat already contains the remote wins anyway (it is a superset, so nothing is lost), and\nwith no remote reachable the local branch is used and the worktree is still created.\nTyping a task name yourself keeps the local base it has always used, with no fetch.\n\n*Every empty grid cell shows this launch form: pick an agent in the Agent Picker (Claude / Codex / Antigravity / Grok / Shell), type a working directory (frequent ones autocomplete from your presets), or — in a git repo — name a task under OR ISOLATE IN A WORKTREE and hit ＋ New worktree to start the agent on its own isolated branch. Shell runs your OS default shell there instead of an agent; OR LAUNCH runs one of your configured launch commands.*\n\nA worktree cell's header carries a **diff badge** (`+<commits> ●<dirty>`\n\n); click it for a\n**Changes vs <base>** panel (file list + patch) with actions:\n\n**✓ Commit**— hands the cell's own session a canned commit prompt.**⬆ Push**—`git push -u origin <branch>`\n\n(`POST /api/worktrees/push`\n\n).**⧉ Open PR**— pushes, then`gh pr create … --fill`\n\n; if`gh`\n\nis missing or unauthed it falls back to opening the GitHub**compare** URL (`POST /api/worktrees/pr`\n\n).\n\nClosing a worktree cell asks whether to **keep** the worktree or **discard & remove** it\n(a dirty worktree is never removed unless you confirm).\n\n**PRs & Issues (cross-repo).** The toolbar's **Pull requests** button opens a full-screen\nview that aggregates open PRs **and** issues across the repos listed in Settings →\n**Pull request repos** (`prRepos`\n\n, `owner/repo`\n\nentries, or `gitlab.com/group/project`\n\n— plus any host declared in `gitlabHosts`\n\n) via your server-side `gh`\n\n/ `glab`\n\nlogin.\nPRs show a CI-rollup / review-decision / draft badge; each repo lists its latest open\nissues. Rows are real links, per-repo errors don't sink the view, and the two lists load\nindependently. Backed by `GET /api/prs`\n\nand `GET /api/issues`\n\n.\n\n**Starting work from an issue row.** Each issue row carries a **▶** button that does the setup in\none click: read the issue, cut an `issue/<number>-<slug>`\n\nworktree in your clone of that repo, and\nopen Claude there as a grid cell with the issue **typed into its input box but not sent**. The\nprompt is seeded server-side as a *draft* (`server/session/draft-injection.ts`\n\n), which waits for\nclaude's input box to be ready — text pushed in before that lands in the scrollback instead. A repo\nwith several clones asks which one the first time and remembers the answer; a repo with no clone\nhere disables the button and says why. Backed by `POST /api/issues/start`\n\n.\n\n**Which clone a repo's work happens in.** `GET /api/repo-dirs`\n\nanswers the reverse of the\nGitHub link a cell already shows: given `owner/repo`\n\n, which of your saved directories are\nclones of it. The candidates are derived from your directory presets by reading each one's\n`origin`\n\n— there is no second list to keep in step — and are ordered by each directory's\n`orderPriority`\n\n, then by path. Several clones of one repo commonly run side by side, so the\nanswer is a choice rather than a lookup; once you make it, `repoDirs`\n\nin the config records\n`owner/repo`\n\n→ the chosen path and it is used from then on. A recording is dropped if the\ndirectory is no longer a saved clone of that repo, and a repo with no clone here is simply\nabsent from the answer — which is how a caller learns work cannot start on it.\n\nEach grid cell's header shows two badges for its session, refreshed when a turn finishes\n(from `GET /api/session/:id`\n\n):\n\n*Both badges, live on a real Claude session: *\n\n`Sonnet · ctx 9%`\n\n(model family + how full its context window is) and `⇡1.8M ⇣6.9k`\n\n(cumulative input / output tokens for the session). They sit in the header's first row alongside the status dot and the git chip (`⎇ main ●2`\n\n), with what the agent is doing to the right; the working directory, the icon buttons and the timeline (🕘) of tool calls are on the second row.**Context badge**— e.g.`Opus · ctx 35%`\n\n: the model family plus how full its context window is (the*last*turn's input + cache tokens ÷ the model's window —**1M** for current-gen Opus / Sonnet / Fable / Mythos,**200k** otherwise). A session running on a[provider model](#agents-claude--codex)shows that model's name and its published window (`Kimi K2.7 Code · ctx 12%`\n\n); a model in neither list keeps the label and hides the %, since the window is never guessed. A reading**past 100%** shows`ctx ?`\n\ninstead of the number: the window is a hard cap, so an impossible percentage means the built-in window table is out of date for that model rather than that the session is over-full.**Token badge**—`⇡<in> ⇣<out>`\n\n: cumulative input (fresh + cache-read + cache-creation) and output tokens for the session, k/M-formatted, with a full breakdown in the tooltip.\n\n**Both badges are read from the agent's own log**, so what each agent can show differs by\nwhat it writes down (`?agent=`\n\non the route picks the reader):\n\n| Agent | Context badge | Token badge |\n|---|---|---|\nClaude |\n`Opus · ctx 35%` — window from the table above |\nfull |\nCodex |\n`gpt-5.5 · ctx 21%` — window from codex's own `model_context_window` , so no table to be out of date |\nfull |\nGrok |\n`grok-4.5 · ctx 33%` — window from grok's own `contextWindowTokens` , so no table either |\nfull |\nAntigravity |\n`Gemini 3.6 Flash · ctx 78%` — the model from the first step of the conversation's transcript, the reading from agy's own per-generation accounting (a real 256k window, not a table) |\nfull |\n\nThe token badge hides itself when nothing has been counted, and the context badge shows\nthe model alone unless it has **both** a *current-context* token count from the agent and a\ncontext window — agent-reported (codex, Grok, Antigravity) or resolved from the built-in table\nabove (Claude, provider models). Either one missing means a name and no percentage. The two\nbadges are independent: an Antigravity session whose accounting cannot be read still shows\nits model, and one whose cumulative totals are zero still shows a percentage. The context badge is absent\nentirely until an agent has named a model: codex and Antigravity file their logs under an id\nthe agent mints *after* the session starts, so a brand-new cell shows no model badge until it\nhas been prompted once — a few seconds, not the rest of the session.\n\nAntigravity's numbers are the one case read from a store with **no published format**: agy keeps\nits per-generation accounting as protobuf in `~/.gemini/antigravity-cli/conversations/<id>.db`\n\n,\nwith no schema on disk, so the fields are identified by measurement (see\n`server/agents/antigravity-usage.ts`\n\n). Every layer of that reader is built to answer *nothing*\nrather than a number it is unsure of, so if a future agy release moves those fields, an\nAntigravity cell falls back to showing its model alone — it will not show a wrong percentage.\n\nThe **Settings** modal (the gear button) shows an **estimated $ cost** — Session / Today / Month — from\n`GET /api/cost`\n\n, using a built-in public per-model price table (cache reads billed at\n0.1×, cache writes at 1.25× input). It's an estimate: real billing differs, **flat-plan\n(Max) usage isn't reflected**, and turns on unpriced models are flagged and excluded.\n\nA separate, full **double-entry accounting** book (the `account_balance`\n\ntoolbar button →\n`/accounting`\n\n) is provided by the bundled `@mulmoclaude/accounting-plugin`\n\nand stores its\nbooks under `<workspace>/data/accounting`\n\n. It's a bookkeeping app — unrelated to the LLM\ncost estimate above — and is also exposed to Claude as the `manageAccounting`\n\nGUI tool.\n\nMulmoTerminal is also a **live view over the shared workspace** (`CLAUDE_CWD`\n\n, default\n`~/mulmoclaude`\n\n) that agents author into — never a snapshot, so it re-reads on entry.\n\n**GUI panel.** Beside the terminal, a **GUI panel** (\"Canvas\") renders the rich results of\nGUI-protocol tools the agent calls — documents (`presentDocument`\n\n), forms (`presentForm`\n\n),\ngenerated images, charts, HTML, and collection cards. Each result is drawn by its plugin's\nown Vue view inside a Shadow-DOM `PluginFrame`\n\n(so a plugin's bundled CSS can't leak),\nmirrors the active session, and replays history on re-select. Plugins reach the agent over\nan **in-process MCP server** served per session at `POST /api/mcp/:sessionId`\n\n. Which plugins\nload is gated by `plugins/plugins.json`\n\n; the shipped set includes markdown, form, image\ngeneration (needs `GEMINI_API_KEY`\n\n), chart, HTML, collection, and mulmoscript (MulmoCast\nvideo/slides/PDF playback) views. You can also merge your **own HTTP MCP servers** into a\nworkspace session via Settings → `userMcpServers`\n\n.\n\nA tool is never called by its own name. Every MCP client prefixes it with the id of the server\nit came from — `mcp__<id>__presentChart`\n\nin Claude Code, `mcp-<id>-presentChart`\n\nin Codex (which\nalso rewrites `-`\n\nin the id to `_`\n\n). So the id you register under is repeated on every tool, in\nevery listing, for the life of the session.\n\nMulmoTerminal delivers the GUI MCP by **three different routes**, and they do not share an id:\n\n| Workspace cell / single view | Project-directory grid cell | |\n|---|---|---|\n| How it arrives | generated per spawn into `--mcp-config` (Claude) or `-c mcp_servers.<id>.url=` (Codex) |\nthe user's OWN per-folder config — `.mcp.json` , `claude mcp add -s local` |\n| Server id | `mt` |\n, `mulmoterminal-render` `-data` , `-media` , `-external` — one per tool group |\n| Tools carried | all of them, on one URL | only the groups that directory registered |\n| Tool name looks like | `mcp__mt__presentChart` |\n`mcp__mulmoterminal-render__presentChart` |\n\nThe third is **Muse**, which reads neither a flag nor a file in the directory: its MCP servers are\ndeclared by an installed **plugin**, and `muse plugins install`\n\nrecords one per MACHINE. So\nMulmoTerminal registers a single `mulmoterminal`\n\nplugin holding all four group servers, and each\nsession is narrowed back to what its own directory switched on — the bridge asks the server which\nsession it belongs to (by its process tree) and is told which groups that session may reach. The\nservers are named by group alone, because Muse composes the tool name out of both ids:\n\n| Muse cell (anywhere) | |\n|---|---|\n| How it arrives | a `mulmoterminal` plugin installed for the machine, re-registered whenever the bridge path or port changes |\n| Server id | , `render` `data` , `media` , `external` — inside the `mulmoterminal` plugin |\n| Tools carried | only the groups that directory registered; the rest serve an empty toolset |\n| Tool name looks like | `mcp__plugin_mulmoterminal_render__presentChart` |\n\nMuse's plugin support is behind its own experimental flag (`MUSE_EXPERIMENTAL_PLUGINS`\n\n), which\nMulmoTerminal sets on the sessions it starts. A Muse build without it simply has no GUI tools —\nthe registration fails with one warning and the session starts anyway.\n\n**A Muse session picks its plugins up when its own process starts, and MulmoTerminal's sessions\noutlive the server.** So a Muse cell that was already running when you switched a group on — or\nwhen you first upgraded to a version that has this — keeps no tools until that CELL is started\nagain. Restarting the server is not enough: the session is still there in tmux and gets reattached,\nexactly as it was. Close the cell and open a new one (or `Stop`\n\nit in Settings → Surviving sessions),\nand it comes back with the tools its directory registered.\n\nWhich route a session takes is decided by `carriesFullGuiMcp()`\n\nin\n`server/session/mcp-config.ts`\n\n— the single view, a cell-less chat, or anything whose cwd **is**\nthe workspace take the first; anything in a project directory takes the second.\n\n**The workspace is agent-agnostic for the agents that can RECEIVE a per-spawn config** — claude and\ncodex ask the same predicate, so two terminals in the workspace reach the same tools no matter which\nof the two started them. **Antigravity, Grok and Muse cannot, and that is the exception you will\nmeet first:**\n\n| Started as | In the workspace | In a project directory |\n|---|---|---|\nclaude cell (including `?gui=0` ) |\n`mt` , every tool |\nthe directory's registered groups |\n| codex cell | `mt` , every tool |\nthe directory's registered groups |\n| antigravity cell | the directory's registered groups — nothing registered means no GUI tools at all |\nthe directory's registered groups |\n| grok cell | the directory's registered groups — nothing registered means no GUI tools at all |\nthe directory's registered groups |\n| muse cell | the directory's registered groups — nothing registered means no GUI tools at all |\nthe directory's registered groups |\n| any launcher chip | untouched | untouched |\n\nNone of the three takes an MCP flag: `agy`\n\nreads `.agents/mcp_config.json`\n\n, `grok`\n\nreads\n`.grok/config.toml`\n\nin the working directory, and `muse`\n\nreads a plugin installed for the whole\nmachine — and neither a file shared by every session in a directory nor a machine-wide plugin can be\nhanded to one session and not another — so there is nothing for \"this cwd is the workspace\" to change. The\nmembership is `FULL_GUI_MCP_AGENTS`\n\nin `common/guiMcpAgents.ts`\n\n, in `common/`\n\nprecisely so the\nlauncher form and the spawn cannot disagree about it\n([#1423](https://github.com/receptron/mulmoterminal/issues/1423)).\n\nThe consequence is easy to hit and hard to guess: `presentDocument`\n\nworks in a project you once\nflipped **Canvas** on for, and is missing in the workspace where everything else is automatic. Fix it\nthe same way anywhere — pick **Antigravity** or **Grok**, point WORKING DIRECTORY at that directory,\nflip the **Canvas** switch (it stays visible for both), and start a **new** session; the switch\nregisters the directory, never a session already running. Full procedure:\n[Antigravity and Grok register everywhere](https://receptron.github.io/mulmoterminal/guide/en/basics.html#antigravity-gui-tools)\n· [日本語](https://receptron.github.io/mulmoterminal/guide/ja/basics.html#antigravity-gui-tools).\n\n**A launcher chip is not an agent session — it is a command.** Whatever the command line names,\nit runs exactly as written: nothing is inserted, and no GUI MCP is attached. A chip running\n`claude`\n\ntherefore reads only its directory's own `.mcp.json`\n\n, and a chip running `codex`\n\nhas no\nCanvas. If you want a chip to reach the GUI tools, put the flags in the command yourself.\n\nEarlier releases did rewrite a `claude`\n\nor `codex`\n\nchip to match the cell beside it. That was\nremoved: a chip that silently runs something other than what it says is indistinguishable from\nthe Agent Picker, which is the confusion the two controls exist on either side of. Use the Agent\nPicker for an agent session — it is also the only one of the two that gives you a resumable\ntranscript, cost and context, and a \"waiting for you\" status.\n\n**The asymmetry is deliberate.** `mt`\n\nis ours to name: nothing on disk holds it, it is\nregenerated on every spawn, so it was shortened to stop paying 17 characters per tool name. The\ngroup ids are the opposite — they are keys in config files **users wrote**, they are what the\nlauncher's per-group switch reads back, and they are documented in the setup guide. Renaming\nthem breaks working setups silently, and would need a migration that rewrites existing\nper-folder configs. Both live in [ common/toolGroups.ts](/receptron/mulmoterminal/blob/main/common/toolGroups.ts), where the\nconstants carry the same warning.\n\n**Wiki.** The toolbar **Wiki** button opens a read-only browser over `<workspace>/data/wiki/`\n\n— an **index** (tag-filterable page catalog), rendered **pages** with `[[wiki links]]`\n\nand\nbacklinks, a **graph** view (pages ranked by references), and a **lint** report (orphans /\nbroken links / tag drift) whose `[[links]]`\n\nare clickable too. Read-only endpoints:\n`GET /api/wiki`\n\n, `/api/wiki/graph`\n\n, `/api/wiki/lint`\n\n.\n\n**Collections.** The toolbar **Collections** button browses the workspace's collection\n\"cards\" (`@mulmoclaude/collection-plugin`\n\n). Running a collection **action** fetches a seed\nprompt and spawns a fresh agent session for it — the **Launch with Claude / Codex** toggle\ndecides which agent (and whether the seed auto-runs or drops in as an editable draft).\nFavorited collections get their own toolbar buttons.\n\n**Grid of parallel sessions**— the ＋ Terminal / grid view runs many sessions at once, auto-sizing by count across pages. Cell borders signal state at a glance —**working**(pulsing blue),** blocked**(amber — needs a permission / answer),** done**(blue — finished, output unreviewed), and** idle**— and the toolbar shows a tally across all pages so you notice an off-screen cell that needs you.** Zoom & filmstrip**— a cell's**⤢** enlarges one agent while the rest shrink to thumbnails in a bottom**filmstrip**; click a thumbnail to switch,**⤡** to return to the grid — so you can flip between \"see everything\" and \"focus on one\" in a click. While zoomed, keys you bind walk the enlargement along the on-screen order without reaching for the mouse —**opt-in, nothing is bound by default**, since any bound key is taken from the terminal underneath. Add a`keymap`\n\nto`~/.mulmoterminal/config.json`\n\n; see the[guide](https://receptron.github.io/mulmoterminal/guide/en/config.html#keymap)for the syntax, the action list, and combinations a browser can never bind.\n\n**Set a terminal aside**— the moon button in a cell's header** sinks**it: the tile, its filmstrip thumbnail and its cockpit-roster row all fade, and the working dot stops pulsing. The session stays**connected and keeps its whole history**— this is what to reach for instead of`/clear`\n\n-ing a cell you are done with for now, which resets the conversation just to change how the cell looks. The setting survives a reload.**Enlarging it keeps it faded**— that is how you read a set-aside session without waking it, and its roster row keeps the blue \"you are here\" edge either way — while**typing into it wakes it**, so nothing has to be undone by hand. Clicking or scrolling to read it does*not*wake it, even though a mouse-tracking agent receives those as input. A cell that**stops for a permission prompt comes back to full strength on its own**, so setting one aside can never hide a session that is waiting on you; a merely*finished*turn does not, since that is the expected outcome of setting a running agent aside.**Timeline**(🕘) — a read-only per-session activity timeline (tools run, newest first), from`GET /api/transcript/timeline`\n\n.**Bring another cell's turn here**(💬) — pick another terminal in the grid and its** last completed turn**is pasted into*this*cell's input box, so you can have Claude and Codex look at each other's work (or pull in a session running in a different repo). The excerpt comes from the agent's own log, not the screen buffer, so it carries no ANSI debris and nothing lost to scrollback. It is**pasted, never sent**— you read what arrived and press Enter, in the cell you were already in. A turn still running isn't available yet (Codex writes its rollout only once the turn ends).**Tools pane**— the available GUI tools plus a live tool-call history for the active session.** Prompts pane**— the prompts*you*sent the enlarged cell's session, newest first, from`GET /api/transcript/prompts`\n\n. The mirror of the Timeline above: that one is what the agent ran, this one is what you asked it for — for when several cells are running and you can no longer remember which one you told what. Read-only; click a long prompt to open it in place. Left open, it keeps up by itself. Claude and Codex; it reads what you TYPED (claude's own prompt history, codex's rollout), so a prompt sent mid-turn is there and text a skill injected is not. A`/clear`\n\ndraws a line: the pane shows what you have asked*since*, the same way the header, the title and the last reply all stop describing the ended conversation.**Notifications**(🔔) — a toolbar bell with an unread badge and a dropdown of active notifications; click a row to jump to its session.** Star MulmoTerminal**— a star button in the grid toolbar that stars the project on GitHub through your own`gh`\n\nlogin, in one click. It is a one-time ask: once the repo is starred the button is gone for good and stops calling the server at all. It shows**only when**— with no`gh`\n\ncan answer`gh`\n\n, no login, or no network, one click couldn't star anything, so nothing is shown and nothing is recorded. Set`gh`\n\nup later and the button appears by itself.**Voice input**— dictate a prompt via on-device Whisper (`POST /api/transcribe`\n\n, macOS only; the model downloads on first use). Settings picks**the language you dictate in**(per browser): your browser's, whisper's own per-clip detection, or a fixed one. Worth setting — speech in a language the mic is not expecting comes back*translated*into the one it is, so an English browser silently turned Japanese dictation into English.**Remote host**— link MulmoTerminal to the companion phone client (Google sign-in) to watch and start sessions from your phone.** Themes**— four terminal palettes (midnight / nord / daylight / solarized), your pick remembered; a project's`.mulmoterminal.json`\n\ncan override per directory.**Editing niceties**—** Shift+Enter**inserts a newline in the prompt, and on macOS** Option**is treated as Meta so Claude's Alt-key bindings work. If your Claude Code is rebound so Enter and Shift+Enter behave backwards, flip them with.`terminalSubmit`\n\n**Scroll speed**— one wheel notch or trackpad swipe moves the terminal the same distance whether you're reading a shell's scrollback or a full-screen app like Claude Code. If a two-finger scroll on a Mac trackpad flies past what you were reading, turn**terminal scroll speed** down in Settings (0.25×–3×, per browser — it's a property of the pointing device).**No accidental page zoom**—`Ctrl`\n\n+wheel and a trackpad pinch would rescale the whole page and drag the layout and the terminal's fit along with it, so both are ignored. Keyboard zoom (`Cmd`\n\n/`Ctrl`\n\n`+`\n\n/`-`\n\n) still works when you mean it, and a phone's finger pinch is untouched. To make terminal text bigger for real, use the font size in Settings (or a directory's`fontSize`\n\n) — that re-fits the PTY instead of leaving it disagreeing.\n\nBase URL: `http://localhost:$PORT`\n\n(default `http://localhost:34567`\n\n).\n\nLists the most-recent chat sessions for the current project (`CLAUDE_CWD`\n\n),\nnewest first, including freshly-created sessions that aren't yet written to disk.\n\n**Response 200 application/json**\n\n```\n{\n  \"cwd\": \"/Users/you/my-project\",\n  \"sessions\": [\n    {\n      \"id\": \"d16f43f3-ef63-4a5e-b273-debaccb3522a\", // session UUID (= .jsonl basename)\n      \"title\": \"Review available skills list\",        // see \"Session discovery & titles\"\n      \"mtime\": 1781471064511.22,                       // last-modified, ms epoch (sort key)\n      \"working\": false,                                // Claude is mid-turn (blue dot)\n      \"waiting\": false                                 // needs attention (bold)\n    }\n    // ...\n  ]\n}\n```\n\n- Sessions are read from\n`~/.claude/projects/<encoded CLAUDE_CWD>/*.jsonl`\n\nand merged with in-memory sessions started this run but not yet persisted (those have`title: \"New session\"`\n\nand`mtime`\n\n= creation time). - Sorted by\n`mtime`\n\ndescending and capped at the**50** most recent. Files are ranked by a cheap`stat`\n\n-only pass; only the top 50 are read and parsed for titles, so the endpoint stays cheap regardless of how many sessions exist. `500 { \"error\": string }`\n\non an unexpected filesystem error. A missing project directory is**not** an error — it yields an empty`sessions`\n\narray.\n\nEvery `?cwd=`\n\n— on the terminal sockets and on the read routes alike — names the directory\nthe request is about. When one is named and cannot be used, the server says so instead of\nquietly answering about the **default workspace** (#1151):\n\n| Where | What happens |\n|---|---|\n`/ws` , `/ws/codex` , `/ws/antigravity` , `/ws/grok` , `/ws/launch` , `/ws/run` |\nThe socket is closed with `{ type: \"error\", message }` , which the terminal shows as a red banner and does not retry. |\nA session that is still running (`?session=` names a live PTY or a surviving tmux session) |\nAttaches anyway, with a warning in the server log. Moving or renaming a directory must not shut you out of an agent that is still working in it — and the cwd reported back comes from the running PTY, not from the request. |\n`GET /api/scripts` , `/api/skills` , `/api/dir-config` , `/api/dir-sound` , `/api/git-status` , `/api/pr-phase` , `/api/header` , `/api/sessions` , `/api/codex/sessions` , `/api/antigravity/sessions` , `/api/grok/sessions` , `/api/session/:id` , `/api/transcript/*` , `/api/cost` |\n`404 { error, cwd }` — a directory that is not there. |\nA `?cwd=` that cannot name a directory at all (relative, or repeated as `?cwd=a&cwd=b` ) |\n`400 { error, cwd }` . |\n\nA request that names **no** directory is unaffected: `CLAUDE_CWD`\n\nis then the answer it\nasked for. The wording of the refusal is the one a refused spawn already uses, so the same\ncondition reads the same whether it is caught here or by `ptySpawn`\n\nitself.\n\nThe runnable entries from `<cwd>/script.json`\n\nfor a cell's chosen directory\n(`?cwd=<dir>`\n\n, or `CLAUDE_CWD`\n\nwhen none is named); see\n[Scripts (Run menu)](#scripts-run-menu). The resolved `cwd`\n\nis echoed back, and each\nentry carries its `index`\n\n(the position the client sends back to `/ws/run`\n\n). A `?cwd=`\n\nthat names a directory the server cannot enter is answered `404 { error, cwd }`\n\nrather\nthan with the default workspace's scripts — see\n[Directories that cannot be used](#directories-that-cannot-be-used).\n\n```\n// GET /api/scripts?cwd=/Users/me/proj\n{\n  \"cwd\": \"/Users/me/proj\",\n  \"scripts\": [\n    { \"index\": 0, \"label\": \"Dev server\", \"command\": \"yarn dev\" },\n    { \"index\": 1, \"label\": \"Sub server\", \"command\": \"yarn serve\", \"cwd\": \"packages/server\" }\n  ]\n}\n```\n\nA missing or invalid `script.json`\n\nis **not** an error — it yields an empty\n`scripts`\n\narray.\n\nThe Claude skills discoverable for a terminal's chosen directory (`?cwd=<dir>`\n\n, or\n`CLAUDE_CWD`\n\nwhen none is named) — project scope (`<cwd>/.claude/skills`\n\n) plus user scope\n(`~/.claude/skills`\n\n), deduped by slug (project shadows user), **working-dir skills\nfirst**; see [Skills (Skill menu)](#skills-skill-menu). A `skills`\n\nallowlist in that\ndirectory's `.mulmoterminal.json`\n\nnarrows and reorders the result; absent → all. The\nresolved `cwd`\n\nis echoed back. Each entry carries its `slug`\n\n(the skill invoked as\n`/<slug>`\n\n) and the `SKILL.md`\n\n`description`\n\n(the menu tooltip).\n\n```\n// GET /api/skills?cwd=/Users/me/proj\n{\n  \"cwd\": \"/Users/me/proj\",\n  \"skills\": [\n    { \"slug\": \"commit\", \"description\": \"Write a commit message\" },\n    { \"slug\": \"review\", \"description\": \"Review the current diff\" }\n  ]\n}\n```\n\nA directory without any discoverable skills is **not** an error — it yields an empty\n`skills`\n\narray.\n\nRuns `claude -p`\n\n**headless** over a command cell's captured terminal output and\nreturns a short summary (Errors / Warnings / likely cause / suggested fix). Backs the\n**✦ Summarize** button on a Run cell (see [Scripts (Run menu)](#scripts-run-menu)).\nThe browser sends the cell's xterm buffer as `log`\n\n; the server truncates it to the\nlast **32 KB** (the tail, where errors + the exit line live), runs the CLI with the\nlog piped on stdin (argv — no shell), and returns its answer. Same-origin guarded.\n\nThe summarizer gets **no tools** (#1769): the spawn carries a `--settings`\n\ndeny of `*`\n\n,\na `--disallowedTools`\n\nlist, no MCP servers, and a working directory outside any\nrepository. A deny rule outranks the ambient allow rules — including the user-level\n`~/.claude/settings.json`\n\n, which a neutral working directory does not escape — and it\ncovers tools that do not exist yet, which a list of names cannot. The log being\nsummarized is not content we control, so this is the guarantee rather than the prompt's\nwording (the prompt says it too).\n\n**Request application/json**:\n\n```\n{ \"log\": \"npm ERR! cannot find module 'foo'\\n...\" }\n```\n\n**Response 200 application/json**:\n\n```\n{\n  \"summary\": \"Errors: cannot find module 'foo'\\nSuggested fix: run `yarn add foo`\",\n  \"truncated\": false // true when the log exceeded 32 KB and only the tail was analyzed\n}\n```\n\nEmpty output returns a `{ summary }`\n\nnote rather than calling the CLI. Errors:\n`400`\n\n(missing `log`\n\n), `403`\n\n(disallowed origin), `502`\n\n(the `claude`\n\nrun failed).\n\n**Internal endpoint.** Claude hooks (injected per session — see\n[Claude hook injection](#claude-hook-injection)) POST their event payload here.\nYou normally don't call this yourself.\n\n**Request application/json** — the Claude hook payload; only these fields are used:\n\n```\n{\n  \"session_id\": \"d16f43f3-...\",        // the session the event is for\n  \"hook_event_name\": \"UserPromptSubmit\" // \"UserPromptSubmit\" | \"Stop\" | \"Notification\"\n}\n```\n\nEffect (see [Session model](#session-model)):\n\n`hook_event_name` |\nEffect |\n|---|---|\n`UserPromptSubmit` |\n`working = true` for the session. |\n`Stop` |\n`working = false` ; if the session is backgrounded, also `waiting = true` . |\n`Notification` |\nIf the session is backgrounded, `waiting = true` . |\n\nAny resulting state change is published on the `sessions`\n\npub/sub channel.\n\n**Response 200 application/json**:\n\n`{ \"ok\": true }`\n\n(always, even for unknown events).The endpoints above are the core; the server exposes many more (all under\n`http://localhost:$PORT`\n\n; query params shown where relevant). Mutating endpoints are\nsame-origin-guarded.\n\n**Sessions & agents**\n\n| Endpoint | Purpose |\n|---|---|\n`GET /api/session/:id?cwd=` |\nOne session's summary — cumulative `usage` and `context` (model + last-turn context tokens). Backs the cell token & ctx% badges. |\n`GET /api/codex/sessions?cwd=` |\nCodex sessions for the project (from `~/.codex` rollouts), newest first. |\n`GET /api/antigravity/sessions?cwd=` |\nAntigravity conversations for the project, newest first. agy does record a workspace, but never as a complete conversation-to-workspace map (`cache/last_conversations.json` keeps one conversation per directory and is written at exit; `history.jsonl` carries no conversation id), so the project comes from MulmoTerminal's own `~/.mulmoterminal/antigravity-conversations.jsonl` ; agy's transcript supplies the title. |\n`GET /api/grok/sessions?cwd=` |\nGrok conversations for the project, newest first. `~/.grok/sessions` is partitioned by working directory (percent-encoded), so this is a directory listing; each conversation's `summary.json` supplies the title and the last-active time, falling back to the directory's `prompt_history.jsonl` . |\n`GET /api/cost?cwd=&session=` |\nEstimated $ cost — session / today / month. |\n`GET /api/transcript/timeline?session=&cwd=` |\nPer-session activity timeline (tools run). |\n`GET /api/transcript/last-turn?session=&cwd=&agent=` |\nA session's last completed exchange (`prompt` , `reply` ) plus the `text` to paste into another terminal. `agent=codex` reads the codex rollout instead of the Claude transcript. |\n`GET /api/decisions?cwd=&limit=` |\nThe decisions a human was asked to make in this project, newest first — each question with the options it offered, their descriptions, and the answer. `answerKind` says whether the answer was one of the options, text the user wrote instead (the question was wrong), or never given. Read out of Claude's own transcripts; writes nothing. `scanned` reports how many transcripts were read (the scan is capped) and `unreadable` how many could not be, so a partial answer is visible rather than implied. A `cwd` that is not an existing directory answers an empty response rather than falling back to the default workspace. |\n`GET /api/decisions/digest?cwd=` |\nThe same decisions as Markdown, for an agent to read (`{ enabled, markdown }` ). `enabled: false` means the `decisionDigest` setting is off — a different answer from an empty digest, so a reader can tell \"switched off\" from \"nothing decided here\". |\n\n**Git & worktrees**\n\n| Endpoint | Purpose |\n|---|---|\n`GET /api/git-status?cwd=` |\n`{ repo, branch, detached, dirty, ahead, behind, upstream }` . |\n`POST /api/git-remote` |\nThe dir's GitHub repo URL (for the header GitHub menu). |\n`GET /api/worktrees?cwd=` · `GET /api/worktrees/diff?cwd=` |\nList managed worktrees / diff one vs its base. |\n`POST /api/worktrees/create` · `/remove` · `/push` · `/pr` |\nCreate on `agent/<slug>` — or, with `issue: <N>` , on `issue/<N>-<slug>` forked from a freshly fetched `origin/<base>` ; remove (managed root only), push, open a PR (`gh` , else compare URL). |\n`GET /api/prs` · `GET /api/issues` |\nOpen PRs / issues across the configured `prRepos` — `gh` for github.com entries, `glab` for gitlab.com and any host declared in `gitlabHosts` . |\n`GET /api/repo-dirs` |\nWhich saved directories clone which GitHub repo, ordered, with the recorded choice per repo. |\n`POST /api/issues/start` |\nCut an issue's worktree in one of that repo's known clones and spawn a session there, seeded with the issue as a draft. |\n`GET /api/github/star` · `POST /api/github/star` |\nWhether you have starred MulmoTerminal, and star it (via `gh` ). `starred: null` means `gh` could not answer, and hides the button. |\n\n**Workspace views**\n\n| Endpoint | Purpose |\n|---|---|\n`GET /api/wiki` (`?slug=` ) · `/api/wiki/graph` · `/api/wiki/lint` |\nRead-only wiki index / page / graph / lint. |\n`GET /api/collections/…` · `/api/feeds` · `GET|PUT /api/shortcuts` |\nCollections browser, feeds, favorites (see `docs/collection-plugin-integration.md` ). |\n`GET /api/files/browse/{list,text,version,md}` · `PUT /api/files/browse/{write,backup}` |\nFile tree / read / Markdown-render / write (contained within the project root). `text` answers `{ text, version }` ; `write` takes `{ text, baseVersion }` (`null` = expecting to create it) and answers 409 with the version now on disk if the file changed since — so a save can't silently overwrite the agent that edits the same files. `version` answers that token alone, for the editor's periodic check. `backup` banks a buffer the editor is about to discard. |\n`GET /api/files/raw?path=` |\nRaw asset bytes (workspace-rooted). |\n\n**Conversation rooms**\n\nA room is one conversation, kept apart from the cells having it (`~/.mulmoterminal/rooms/<id>.jsonl`\n\n,\nappend-only). A round table writes every turn into one; the point of it being a file behind an API\nis that the things which are **not** agents can join the same conversation — a person in the Rooms\nview, a shell, a CI job posting a result. No agent calls any of this: the runner reads their turns\nand writes for them, which is why the feature needs no MCP tool.\n\n| Endpoint | Purpose |\n|---|---|\n`GET /api/rooms` |\nThe rooms that exist, newest activity first. |\n`GET /api/rooms/:room?since=` |\nWhat was said, oldest first. A room that does not exist is empty; a room that cannot be read answers 500, so a caller can tell \"nobody has spoken\" from \"I could not find out\" and decide for itself (the round-table runner carries on from the previous turn; the Rooms view says so). |\n`POST /api/rooms/:room` |\n`{ from, text }` — append. `from` is a display name, not an identity, and nothing authenticates it. Text over 4000 characters is clipped, since a room is read into an agent's context. |\n`DELETE /api/rooms/:room` |\nForget a conversation. |\n\nFrom a shell: `mulmoterminal room read <room>`\n\n· `room post <room> <text…> [--from <name>]`\n\n·\n`room list`\n\n. Everything after `--`\n\nis message text, so a post can contain `--force`\n\nwithout losing it.\n\n**GUI panel / plugins / MCP**\n\n| Endpoint | Purpose |\n|---|---|\n`POST /api/mcp/:sessionId` |\nPer-session GUI MCP server (Streamable HTTP; `GET` /`DELETE` → 405). |\n`POST /api/plugin/:toolName` |\nGUI-plugin dispatch (incl. `spawnBackgroundChat` , `manageAccounting` , `presentHtml` ). |\n`GET /api/agent/toolResults/:id` · `POST /api/agent/toolResult` |\nGUI-panel result history / persist. |\n`GET /api/tools` · `GET /api/tool-calls/:id` |\nAvailable tools / tool-call history. |\n`POST /api/accounting` |\nDouble-entry accounting (bundled plugin). |\n\n**Config, sound & misc**\n\n| Endpoint | Purpose |\n|---|---|\n`GET|POST /api/config` |\nUser UI config (`cwdPresets` , `soundFile` , `soundKinds` , `sounds` , `prRepos` , `launchers` , `quickCommands` , `userMcpServers` , `providers` ). |\n`GET /api/sound?kind=` · `/api/dir-sound?cwd=&kind=` · `/api/sound-preset/:id` · `/api/dir-config?cwd=` |\nCustom / per-directory / preset attention sound + per-dir config. `kind` selects a config entry, never a path. |\n`GET /api/dir-config-detail?cwd=` |\nThe same per-dir config, plus the settings a running terminal doesn't need (`provider` , `model` , `skills` , `addDirs` , header button/chip labels), plus which keys the file set and how each fared (applied / dropped in validation / not a setting at all). Read-only; backs the Settings modal's Directory settings preview. Unlike the other `?cwd=` routes this one does not fall back to the default workspace — it reports on the directory it was asked about, so a path that no longer exists comes back as `exists:false` . Sound paths and button commands stay server-side. |\n`GET /api/launch-options` |\nThe Anthropic-compatible backends this server can reach, each with its models and — when it can't — the reason. Reports the name of the env var a key is read from, never the key. |\n`GET /api/update-status` |\nWhat is running and whether anything newer exists: `install` (`npm` / `git` ), `version` , `commit` (a checkout's short HEAD sha), `latest` (npm, only when newer) and the one-line `notice` . Backs the header's Update badge and the Settings version line. Served from memory, recomputed at startup and every 3 hours — a long-running server started with `npx mulmoterminal@latest` is current when it starts, so only a later check can tell it a release shipped. `ready` is false until the first check lands. |\n`GET /api/notifications` (`/history` ) · `POST /api/notifications/:id/clear` |\nNotification feed. |\n`POST /api/transcribe` (`/model` …) |\nVoice-input transcription (Whisper, macOS). |\n`POST /api/translation` |\nRuntime UI-string translation. |\n`GET /api/remote-host/status` · `POST /api/remote-host/{connect,disconnect}` |\nCompanion phone-client link. Each response carries the command channel's `health` (`online` / `reconnecting` / `offline` , plus the last listener error), so the toolbar shows a dropped channel instead of the last state it happened to fetch. |\n`POST /api/open-dir` · `POST /api/pick-file` |\nReveal a dir in Finder/Explorer; OS file-picker → path (`{ directory: true }` opens the folder picker — used by the launcher's Working-directory 📁 button). Both run on the SERVER's machine, and both answer 500 with a reason when it has nothing to open a dialog with, rather than a silent nothing (#1447). The picker tries every dialog the host might have: macOS `osascript` , Windows PowerShell, WSL the Windows dialog over interop (`powershell.exe` + `wslpath` ), Linux `zenity` → `kdialog` → `qarma` → `yad` . A user cancel is a 200 with `paths: []` . |\n`POST /api/session/:id/drop` |\nA dropped file whose path the browser withheld. Raw bytes, not JSON, under the file's own content type (base64 in JSON would cap real files near 18 MB, and a dropped `.json` would be parsed as a document); the original name rides percent-encoded in `x-drop-filename` and is used for its suffix only. Answers `{ path }` — absolute, inside the private per-session directory the session was granted at launch. 110 MiB cap; 404 for a session this server isn't running. |\n\nThe phone itself uses **none** of these routes — it reaches the host over Firestore command\ndocs, not HTTP. Every command it can send, and the shapes it gets back, are in\n[ docs/remote-host-protocol.md](/receptron/mulmoterminal/blob/main/docs/remote-host-protocol.md).\n\nA raw WebSocket carrying the terminal stream for one session. One PTY per connection (or reattach to an existing background PTY).\n\n**Connect**\n\n`ws://host/ws`\n\n— start a**new** session (server generates a UUID and spawns`claude --session-id <uuid> --settings <hooks>`\n\n).`ws://host/ws?session=<id>`\n\n—**resume/reattach** a session. If a live background PTY exists for`<id>`\n\n, the socket reattaches to it (and its recent output buffer is replayed); otherwise the server spawns`claude --resume <id> --settings <hooks>`\n\n.`&cols=<n>&rows=<n>`\n\n— the terminal's geometry, on every endpoint that starts a PTY. The PTY is created at it instead of the 120x30 default, so nothing is ever drawn at a size the browser didn't ask for. Out-of-range values are ignored (same bounds as a`resize`\n\nframe), and a connection that sends none keeps the default until its first`resize`\n\n.\n\n**Server → client** (JSON text frames):\n\n| Message | Meaning |\n|---|---|\n`{ \"type\": \"session\", \"id\": string }` |\nSent immediately on connect — the session id this socket is bound to (lets the client learn a new session's generated id). |\n`{ \"type\": \"output\", \"data\": string }` |\nPTY output to write to the terminal. On reattach, the first `output` frame is the replayed tail buffer (≤ 64 KB). |\n`{ \"type\": \"exit\", \"exitCode\": number, \"signal\": number }` |\nThe `claude` process exited; the socket then closes. |\n\n**Client → server** (JSON text frames):\n\n| Message | Meaning |\n|---|---|\n`{ \"type\": \"input\", \"data\": string }` |\nKeystrokes / bytes to write to the PTY. |\n`{ \"type\": \"resize\", \"cols\": number, \"rows\": number }` |\nResize the PTY. |\n\nA non-JSON frame is written to the PTY verbatim (fallback).\n\n**Disconnect** — when the socket closes, if Claude is still `working`\n\nthe PTY is\n**kept alive** in the background; otherwise it's killed. See\n[Session lifecycle](#session-lifecycle).\n\nTwo more raw WebSockets share the `/ws`\n\nframe format (`output`\n\n/ `input`\n\n/ `resize`\n\n/\n`exit`\n\n):\n\n— a`/ws/codex?session=<id>&cwd=<dir>&gui=<0|1>`\n\n**Codex** agent PTY (see[Agents: Claude & Codex](#agents-claude--codex)). Like`/ws`\n\nit sends a`session`\n\nframe with the id and reattaches to a live or tmux-backed session on resume.`gui=0`\n\n(grid cells) omits the GUI MCP and marks the session a grid terminal.— a`/ws/launch?session=<id>&cwd=<dir>&launcher=<index>`\n\n**launch command** PTY (a plain shell,`codex`\n\n, or any command configured in Settings → Launch commands). Unlike a Run-menu script it's**persistent and reattachable**(survives page switches / reconnects), but it has no Claude hooks, so its dot only shows running vs. exited.\n\nA raw WebSocket carrying a one-off **Run-menu command** (see\n[Scripts (Run menu)](#scripts-run-menu)) — a plain shell PTY, **not** a Claude\nsession, so there's no `session`\n\nmessage, no hooks, and no reattach.\n\n**Connect**\n\n`ws://host/ws/run?index=<n>&cwd=<dir>`\n\n— run the script at position`<n>`\n\nin`<dir>/script.json`\n\n(cwd falls back to`CLAUDE_CWD`\n\n). The server reads that file and spawns`$SHELL -lc \"<command>\"`\n\nin the script's`cwd`\n\n. An out-of-range index (or a missing/invalid`script.json`\n\n) yields`{ \"type\": \"error\", \"message\": string }`\n\nand the socket closes.\n\nThe **output / input / resize / exit** frames are identical to `/ws`\n\n. There is no\n`session`\n\nframe.\n\n**Disconnect** — the terminal is **ephemeral**: when the socket closes (cell\nclosed, or page reloaded) the process is **killed**. There is no background\nsurvival and no resume.\n\nA minimal Socket.IO pub/sub for live session-activity updates. Channel names are Socket.IO rooms.\n\n**Path**:`/ws/pubsub`\n\n, transport:`websocket`\n\n.**Client → server events**:`subscribe`\n\nwith a channel name (string) → join the room.`unsubscribe`\n\nwith a channel name (string) → leave the room.\n\n**Server → client event**:`data`\n\nwith`{ channel: string, data: <payload> }`\n\n.\n\n**Channel \"sessions\"** — payloads describe a single session change:\n\n```\n// activity change (working/waiting flipped)\n{ \"id\": \"d16f43f3-...\", \"working\": false, \"waiting\": true, \"event\": \"Stop\" }\n\n// a brand-new session was created\n{ \"id\": \"…\", \"working\": false, \"event\": \"created\" }\n\n// a session's PTY was closed/reaped\n{ \"id\": \"…\", \"working\": false, \"event\": \"closed\" }\n```\n\n`event`\n\nis the originating hook (`UserPromptSubmit`\n\n| `Stop`\n\n| `Notification`\n\n) or\na lifecycle marker (`created`\n\n| `closed`\n\n| `null`\n\n). The client treats **any**\n`sessions`\n\nmessage as a signal to refetch `GET /api/sessions`\n\n(the server is the\nsingle source of truth for the list), so payload details are advisory.\n\nPer-session state lives on the server (`activity`\n\nmap) and is surfaced as two\nbooleans on every session record:\n\n| Flag | Set when | Cleared when | UI |\n|---|---|---|---|\n`working` |\n`UserPromptSubmit` hook fires (Claude started a turn) |\n`Stop` hook fires (turn finished) |\nBlue dot next to the title |\n`waiting` |\nA background session fires `Notification` (waiting for input — permission / question / idle) or `Stop` (finished, output unseen, ready for another message) |\nThe session is brought to the foreground (a WebSocket attaches to it) |\nBold title |\n\n\"Foreground\" = a session that currently has an attached terminal WebSocket (the\none you're viewing). `waiting`\n\nis only ever set for **background** sessions,\nbecause a foreground session is already on screen.\n\n```\n        new ws /ws                         ws /ws?session=<id>\n            │                                      │\n            ▼                                      ▼\n   generate UUID, spawn               live bg PTY?  ──yes──►  reattach + replay buffer\n   claude --session-id <uuid>              │ no\n   register \"New session\",                 ▼\n   publish \"created\"               spawn claude --resume <id>\n            │                                      │\n            └───────────────┬──────────────────────┘\n                            ▼\n                   attached (foreground)  ── setWaiting(false) ──► not bold\n                            │\n              ws close (switch away / disconnect)\n                            │\n            ┌───────── working? ──────────┐\n           yes                            no\n            │                             │\n   keep PTY alive (background)        kill PTY (reap), publish \"closed\"\n            │\n   Stop hook in background:\n   waiting=true (bold), working=false, reap PTY\n   (flag persists via on-disk record → stays listed & bold until viewed)\n```\n\nKey rules:\n\n**Switching away never interrupts Claude mid-turn**— a`working`\n\nsession's PTY survives in the background.- A background session that goes\n**idle**(`Stop`\n\n) is**reaped**(killed). If it finished with unseen output, its`waiting`\n\nflag persists via the on-disk session record, so it stays listed and**bold** until you open it. **Reattach over respawn**: selecting a session that still has a live background PTY reattaches to it (replaying a ≤ 64 KB output tail) instead of spawning a duplicate`claude`\n\n.**One live viewer per session**: a session is bound to a single socket. Opening it in a second place (another tab, or another grid cell pointed at the same dir) reattaches there and**supersedes** the first, which detaches. So a launcher's resume list**refuses** a session that is open anywhere (`● open`\n\n) rather than offering to take it over — and the server answers \"anywhere\" from its own PTY table plus tmux, so another browser tab and a second`mulmoterminal`\n\nprocess count too.- Brand-new sessions are listed\n**immediately**(before their`.jsonl`\n\nexists) via the in-memory`knownSessions`\n\nregistry + a`created`\n\npush; an unused one disappears when its PTY is reaped. **Background workers get their own filter.** A session nobody started by hand — a collection's scheduled refresh, a**user scheduled task**(the dev worklog and anything else the scheduler runs), or a plugin's`spawnBackgroundChat`\n\n`hidden: true`\n\n— is listed under the**Background** chip instead of among the chats, so a refresh schedule doesn't fill the history. It stays openable (a MulmoTerminal session is a live terminal, so a row you can't reach is a process you can't stop), and it is put on the same count+age retention as the scheduler's own sessions. The chip appears only when there is one to show. A**manual** collection Refresh is a normal visible session — unchanged. The marking is persisted (`~/.mulmoterminal/background-sessions.json`\n\n), so a worker stays out of the chat list after it finishes and after a restart.\n\nActivity is detected via Claude Code hooks injected **per spawn**, without\ntouching the user's `~/.claude/settings.json`\n\nor project settings. The server\npasses `claude --settings '<json>'`\n\nwhere the JSON registers a command hook for\n`UserPromptSubmit`\n\n, `Stop`\n\n, and `Notification`\n\n, each of which pipes the hook\npayload to the server:\n\n```\n{\n  \"hooks\": {\n    \"UserPromptSubmit\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"curl -s -X POST http://localhost:$PORT/api/hook -H 'content-type: application/json' -d @-\" }] }],\n    \"Stop\":             [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"curl … -d @-\" }] }],\n    \"Notification\":     [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"curl … -d @-\" }] }]\n  }\n}\n```\n\nBecause the server spawns each new session with `--session-id <uuid>`\n\n, it always\nknows the live session's id — even before the session's `.jsonl`\n\nfile exists.\n\nEvery Claude session is spawned with `claude --append-system-prompt '<text>'`\n\n, asking the\nagent to end a reply with a short summary **when it hands control back** — the work is\nfinished, or it is stopping to ask a question. Coming back to a grid cell after a while, the\nstanding request and what came of it are otherwise only recoverable by scrolling the whole\nsession.\n\nThe summary states three things: the request **for the conversation as a whole** (not the\nlast message — several turns of refinement do not replace what was asked first), what was\nachieved, and what was not and why. It is written in the language of the conversation, and\nplaced last with nothing after it.\n\nIt is deliberately **not** written on every turn: mid-work replies and short factual answers\ncarry no standing request, and a summary that always appears stops being read. The wording\nlives in `server/agents/session-summary-prompt.ts`\n\n.\n\n**On by default, and switchable off** with `appendSystemPrompt: false`\n\n— in\n`~/.mulmoterminal/config.json`\n\n, or in a directory's `.mulmoterminal.json`\n\n, which outranks the\nglobal value. Read per spawn, so no restart is needed; a session already running keeps what it\nwas launched with. Nothing in the app parses what the summary says, so turning it off costs no\nfeature — the roster and push notifications simply show the raw tail of the reply.\n\nWhich sections `--append-system-prompt`\n\nends up carrying is decided in\n`server/agents/appended-prompt.ts`\n\n: this one and the `prWorkdirFooter`\n\nclone line are separate\nsettings on the same flag, and with both off the flag is not passed at all.\n\nCodex sessions are unaffected — the CLI has no equivalent flag.\n\nClaude stores each project's sessions as JSONL files under\n`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`\n\n, where the absolute `cwd`\n\nhas its `/`\n\nand `.`\n\ncharacters replaced with `-`\n\n(e.g.\n`/Users/you/proj`\n\n→ `-Users-you-proj`\n\n).\n\nA session's display **title** is derived by scanning its JSONL for, in order of\npreference:\n\n- the\n**session note** the user wrote (see below), - else a live\n**AI title** the server generated for the session this run (see below), - else the latest\n`ai-title`\n\nrecord's`aiTitle`\n\n(e.g. written by MulmoClaude), - else the latest\n`last-prompt`\n\nrecord's`lastPrompt`\n\n, - else the first real user message (slash/local-command wrappers like\n`<local-command-…>`\n\nare skipped), - else\n`\"(untitled session)\"`\n\n.\n\nIn-memory sessions not yet persisted show as `\"New session\"`\n\nuntil their file\nappears, at which point the on-disk title takes over.\n\nThe raw last prompt is a poor cell-header label once a session becomes a\nback-and-forth: a follow-up is either a trivial ack (`ok`\n\n, `はい`\n\n— skipped, so the\nheader keeps showing the now-stale opening task) or context-dependent (`2番目にして`\n\n— meaningless on its own). So the header shows a short **AI title** instead, falling back\nto the last prompt when there is none yet.\n\nBy default that title is **read, not generated**: Claude Code writes an `ai-title`\n\nrecord\ninto its own transcript, and the server picks it up from the pass it already makes over\nthat file. Nothing is spawned, so nothing has to be given tools — the previous behaviour\nlaunched a full `claude -p`\n\nsession per title, and one of those ran `git push origin main`\n\nin a working repository (#1769). The cost is freshness: Claude's own title is written once\nper session and never revised, so it describes what the session STARTED as. Set\n`MT_TITLE_SOURCE=headless`\n\nto go back to summarizing the recent turns with a cheap model\n(`MT_TITLE_MODEL`\n\n, default `haiku`\n\n), which does follow a drifting topic.\n\nEither way the title is refreshed on the same schedule — at a turn's `Stop`\n\n(when the reply\nis on disk) and only when one is **due**: none yet, the newest prompt was a\ntrivial/context-dependent ack (so the raw last prompt would be stale), or every few turns.\nThe cadence matters much less now that the default source costs nothing to read. The title\nthis server is showing lives in memory (this repo never writes `ai-title`\n\nlines into Claude's\nown transcript); a resumed session falls back to the on-disk `ai-title`\n\ndirectly.\n\nEvery tier above says what the **agent** said, which stops answering \"which cell is this?\"\nonce several sessions are open. So a cell header also takes a **note you write yourself**: the\npencil button beside the header text opens a one-line box (Enter saves, Esc cancels, clicking\naway saves). While a note is set it *replaces* the header line — the title it displaced stays in\nthe tooltip — and it becomes the session's title in the launcher's session list and on the phone's roster\ntoo, so one session goes by one name everywhere.\n\nNotes are capped at 200 characters and folded to a single line. They are stored per **session\nid** in `~/.mulmoterminal/session-memos.jsonl`\n\nand survive both the session being reaped and a\nserver restart: resume the session and the note comes back. Saving one publishes it on the\n`sessions`\n\nchannel, so every other open tab and the phone update without asking.\n\n`POST /api/session/:id/memo`\n\nwith `{ \"text\": \"…\" }`\n\nwrites one; an empty `text`\n\nerases it. The\nroute answers with the **stored** text, which is what a reload will show.\n\n```\nserver/\n  index.ts        Express app, /api routes, upgrade routing, PTY lifecycle,\n                  session state, hook injection, session discovery, GUI-MCP mount\n  agents/         AgentAdapter seam + per-agent args/sessions: claude.ts,\n                  codex.ts, registry.ts, claude-args.ts, codex-args.ts,\n                  codex-session(s).ts, codex-skills.ts\n  config/         user + per-directory + header config: app-config.ts,\n                  config-routes.ts, config-schema.ts, dir-config.ts,\n                  cwd-presets.ts, header-*.ts\n  session/        per-session transcript/activity/cost: transcript.ts,\n                  session-resolve.ts, activity-*.ts, cost.ts,\n                  command-summary.ts, terminal-replay.ts, file-cache.ts\n  git/            git, GitHub (gh) and GitLab (glab) + worktrees: git-status.ts, gitRemote.ts,\n                  gh.ts, prs.ts, issues.ts, pr-for-branch.ts, worktrees.ts, worktree-*.ts\n  files/          files-browse.ts (contained tree read/write), pick-file.ts,\n                  open-dir.ts, wsl.ts (interop detection + wslpath),\n                  scripts.ts (Run-menu script.json loader)\n  infra/          process/transport/misc: tmux.ts, tmux-routes.ts,\n                  pubsub.ts (socket.io /ws/pubsub), spa-fallback.ts, host-tools.ts,\n                  plugins-registry.ts, web-push.ts, install-bundled-skills.ts, accounting-tool.ts\n  mcp/            per-session MCP broker\n  backends/       wiki, collections, feeds, accounting, notifier,\n                  translation, whisper, remote-host, html, files\n  skills/         bundled skills: mulmoterminal-config (entry point + audit), -dirs, -theme,\n                  -header, -keys, -model, -notify, -bug-report, -decisions\n  fix-pty-perms.js              postinstall: fixes node-pty binary permissions\nsrc/\n  App.vue                       Layout; owns the grid, the overlays and the tab favicon\n  router/                       Vue Router routes (/, /terminals, /collections,\n                                /accounting, /prs, /files, /wiki, …)\n  components/\n    Terminal.vue                             xterm.js terminal; /ws, /ws/codex, /ws/run\n    AppToolbar.vue                           shared header + toolbar buttons\n    GridView.vue, TerminalGrid.vue, TerminalCell.vue, CommandCell.vue, LauncherCell.vue\n    CellLaunchForm.vue                       what an EMPTY cell shows: Agent Picker + dir +\n                                             resume / scripts / worktrees / tool groups\n    GuiPanel.vue, PluginFrame.vue            GUI panel (Canvas) + Shadow-DOM plugin host\n    FilesOverlay.vue                         file browser + CodeMirror editor\n    GitBranchChip.vue, ModelContextBadge.vue header chips / badges\n    PrsOverlay.vue                           cross-repo PRs & Issues\n    Wiki*View.vue, Collections*.vue, AccountingOverlay.vue   workspace views\n    TimelineOverlay.vue, ToolsPane.vue, NotificationBell.vue, RemoteHostControl.vue\n    SettingsModal.vue                        settings — the dialog shell + section order\n    settings/                                one file per settings section (theme, sounds,\n                                             web push, google, PR repos, launchers, quick\n                                             commands, MCP, cost, shortcuts, …), plus the\n                                             shared SettingsStepper / SettingsListRow\n  composables/                  useSessions, usePubSub, useGitStatus, useCost,\n                                useChatLauncher, useFilesView, useWikiBrowse,\n                                useCollectionBrowse, useNotifications, useVoiceInput, …\ncommon/           Shared by server/ and src/ — both tsconfigs include it, so a value or\n                  wire type either side decides from belongs HERE, never mirrored in both:\n                  dirChrome.ts, ghItems.ts, gitStatus.ts, launchOptions.ts, shortcuts.ts,\n                  sourceExtensions.ts, modelPresets.ts, modelIds.ts, theme*.ts, …\nvite.config.ts    Dev proxy for /ws (+ /ws/codex, /ws/launch, /ws/run), /ws/pubsub, /api,\n                  /artifacts, /htmlfile\nvitest.config.ts  jsdom test environment\nyarn test\n```\n\n`test/src/components/`\n\ncovers the roster and the launcher's session list:\n`CockpitHeader.spec.ts`\n\n, `rosterPhase.spec.ts`\n\nand `rosterAlertClasses.spec.ts`\n\nfor\nwhat a row shows, `CellLaunchForm.spec.ts`\n\nfor resuming one. The pub/sub composable\nand `fetch`\n\nare mocked so the tests run without a server.\n\nMulmoTerminal is built by ** receptron** —\n\n**and**\n\n[Satoshi Nakajima](https://x.com/snakajima)**.**\n\n[Isamu Arimoto](https://github.com/isamu)Satoshi was the software architect for **Windows 95**, **Windows 98** and **Internet Explorer\n3.0 / 4.0** at Microsoft, later founded UIEvolution / Xevo, and still builds from Seattle.\n\nThe two have shipped open source together since 2015, and the core of each venture has been public every time:\n\n(2015)\n|\nGPU video processing for iOS, built at Veemob |\n(2015)\n|\nan animation runtime that made manga move on phones |\n(2020)\n|\ntakeout ordering for restaurants during COVID, run at the Singularity Society and launched with ITOCHU |\n(2023) ·\n(2024) ·\n(2025)\n|\nLLM agents, declarative dataflow, AI video |\n\nMulmoTerminal is the seventh.\n\nIt exists because we run several coding agents every day and kept losing track of which one was waiting on us. Everything here was built for that, then kept because it worked. MIT licensed.\n\n**Updates** are announced in Japanese on X:[@SingularitySoci](https://x.com/SingularitySoci)**Sister project:**[MulmoClaude](https://github.com/receptron/mulmoclaude)\n\n**Please open an issue rather than a pull request.** Bug reports and feature requests are very\nwelcome and are the way a change gets in; outside pull requests are closed automatically,\nwhatever their size.\n\nWriting code stopped being the bottleneck — reading it did not, and a large generated diff is hard to audit for a reviewer who did not help shape the design. This app runs coding agents against your real machine and repositories, so we do not merge what we cannot fully review. What is scarce instead is the bug we cannot reach from here and the idea we have not had.\n\nThe full policy, the issue-writing rules and the automated triage: ** CONTRIBUTING.md**\n(bilingual).", "url": "https://wpnews.pro/news/show-hn-mulmoterminal-run-many-claude-code-sessions-see-which-needs-you", "canonical_source": "https://github.com/receptron/mulmoterminal", "published_at": "2026-08-25 19:20:48+00:00", "updated_at": "2026-08-25 19:45:26.008348+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["MulmoTerminal", "Claude Code", "Codex", "receptron", "GraphAI", "Satoshi Nakajima", "Isamu Arimoto"], "alternates": {"html": "https://wpnews.pro/news/show-hn-mulmoterminal-run-many-claude-code-sessions-see-which-needs-you", "markdown": "https://wpnews.pro/news/show-hn-mulmoterminal-run-many-claude-code-sessions-see-which-needs-you.md", "text": "https://wpnews.pro/news/show-hn-mulmoterminal-run-many-claude-code-sessions-see-which-needs-you.txt", "jsonld": "https://wpnews.pro/news/show-hn-mulmoterminal-run-many-claude-code-sessions-see-which-needs-you.jsonld"}}