mulmoterminal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Receptron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,420 @@
1
+ # mulmoterminal
2
+
3
+ A browser-based terminal for running [Claude Code](https://claude.com/claude-code)
4
+ sessions, with a sidebar that lists the current project's chat sessions and shows
5
+ live, hook-driven activity for each one.
6
+
7
+ Each session runs as a real PTY on the server (`claude` in a pseudo-terminal) and
8
+ is streamed to an [xterm.js](https://xtermjs.org/) terminal in the browser over a
9
+ WebSocket. A sidebar lists every Claude session for the project and reflects, in
10
+ real time, which sessions are **working** (Claude is thinking) and which **need
11
+ attention** (waiting for input, or finished with output you haven't seen).
12
+
13
+ ---
14
+
15
+ ## Install & run
16
+
17
+ Requires the [`claude`](https://claude.com/claude-code) CLI on your `PATH` and
18
+ **Node ≥ 22.9**.
19
+
20
+ ```bash
21
+ npx mulmoterminal # start on http://localhost:3456 and open the browser
22
+ # or install globally:
23
+ npm install -g mulmoterminal
24
+ mulmoterminal
25
+ ```
26
+
27
+ Options: `--port <n>` (default 3456), `--no-open`, `--version`, `--help`.
28
+
29
+ The published package ships the server (run via `tsx`) plus the pre-built web UI;
30
+ `npx mulmoterminal` checks for the `claude` CLI, picks a free port, starts the
31
+ server, and opens the browser. For local development from a clone, see
32
+ [Running](#running).
33
+
34
+ ---
35
+
36
+ ## Contents
37
+
38
+ - [Architecture](#architecture)
39
+ - [Why a PTY?](#why-a-pty)
40
+ - [Tech stack](#tech-stack)
41
+ - [Configuration](#configuration)
42
+ - [Running](#running)
43
+ - [Server API specification](#server-api-specification)
44
+ - [HTTP: `GET /api/sessions`](#http-get-apisessions)
45
+ - [HTTP: `POST /api/hook`](#http-post-apihook)
46
+ - [WebSocket: `/ws` (terminal)](#websocket-ws-terminal)
47
+ - [Socket.IO: `/ws/pubsub` (activity pub/sub)](#socketio-wspubsub-activity-pubsub)
48
+ - [Session model](#session-model)
49
+ - [Session lifecycle](#session-lifecycle)
50
+ - [Claude hook injection](#claude-hook-injection)
51
+ - [Session discovery & titles](#session-discovery--titles)
52
+ - [Project structure](#project-structure)
53
+ - [Testing](#testing)
54
+
55
+ ---
56
+
57
+ ## Architecture
58
+
59
+ ```
60
+ ┌──────────────────────────────────────┐ ┌─────────────────────────────────────────────┐
61
+ │ Browser (Vue 3 + xterm.js) │ │ Server (Express + Node) │
62
+ │ │ │ │
63
+ │ Sidebar.vue ──subscribe("sessions")──┼──SIO───►│ socket.io /ws/pubsub ── publish ──┐ │
64
+ │ ▲ refetch on any push │ │ │ │
65
+ │ └──── GET /api/sessions ─────────┼──HTTP──►│ Express /api/sessions │ │
66
+ │ │ │ /api/hook ◄──curl── hooks │ │
67
+ │ Terminal.vue ── ws JSON msgs ────────┼──WS────►│ ws /ws ──► node-pty ─► `claude`──hooks┘
68
+ │ (input / resize / output) │ │ (one PTY per session) │
69
+ └──────────────────────────────────────┘ └─────────────────────────────────────────────┘
70
+ ```
71
+
72
+ - **Terminal I/O** flows over a raw WebSocket (`/ws`), one PTY per session.
73
+ - **Session list** is fetched over HTTP (`/api/sessions`).
74
+ - **Live activity** is pushed over a Socket.IO pub/sub channel (`/ws/pubsub`);
75
+ the server learns of activity from **Claude hooks** that POST to `/api/hook`.
76
+ - The Vite dev server proxies `/ws`, `/ws/pubsub`, and `/api` to the backend;
77
+ in production the backend serves the built client from `dist/`.
78
+
79
+ ---
80
+
81
+ ## Why a PTY?
82
+
83
+ Claude Code's interactive mode renders its UI with [Ink](https://github.com/vadimdemedes/ink)
84
+ (a React-based TUI framework), which requires a real **TTY** to be attached. A
85
+ plain `child_process.spawn()` provides no TTY, so interactive Claude won't start
86
+ (it stays silent). [node-pty](https://github.com/microsoft/node-pty) allocates a
87
+ real **pseudo-terminal** at the OS level, so from Claude's point of view it's
88
+ running in an ordinary terminal — full TUI rendering, cursor movement, colors,
89
+ and tool-approval prompts all work. We don't use `-p`/headless mode or the Agent
90
+ SDK; we drive the real interactive CLI and relay its TTY over the WebSocket.
91
+
92
+ > **macOS note:** node-pty's bundled `spawn-helper` binary ships without the
93
+ > execute bit (mode 644), which causes a `posix_spawnp failed` error. The
94
+ > `postinstall` script (`server/fix-pty-perms.js`) fixes it to 755 automatically.
95
+
96
+ ---
97
+
98
+ ## Tech stack
99
+
100
+ | Layer | Technology |
101
+ | -------- | ---------- |
102
+ | Frontend | Vue 3 (`<script setup>` + TypeScript), Vite, xterm.js (`@xterm/*`), socket.io-client |
103
+ | Backend | Node (ESM, TypeScript run via `tsx`), Express 5, `ws` (terminal WebSocket), `node-pty`, socket.io |
104
+ | Tests | Vitest + @vue/test-utils + jsdom |
105
+
106
+ Requires **Node ≥ 22.9** (uses `node --env-file-if-exists`) and the `claude` CLI on `PATH`.
107
+
108
+ ---
109
+
110
+ ## Configuration
111
+
112
+ The server is configured entirely through environment variables, optionally
113
+ loaded from a `.env` file via `node --env-file-if-exists=.env` (wired into the
114
+ npm scripts). The `.env` is optional — every variable below has a default, so
115
+ the server runs without one.
116
+
117
+ | Variable | Default | Description |
118
+ | ------------ | -------------- | ----------- |
119
+ | `PORT` | `3456` | HTTP/WebSocket port. |
120
+ | `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. |
121
+ | `CLAUDE_CWD` | `$HOME` | Working directory each `claude` PTY runs in. Determines which project's sessions the sidebar lists. **Must be an absolute path** — `~` is not expanded for values read from `.env`. |
122
+
123
+ Example `.env` (gitignored):
124
+
125
+ ```
126
+ CLAUDE_CWD=/Users/you/my-project
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Running
132
+
133
+ ```bash
134
+ yarn install # postinstall fixes node-pty prebuilt binary perms
135
+
136
+ yarn dev # server (:3456) + Vite dev server, concurrently
137
+ # or individually:
138
+ yarn dev:server # backend only (node --import tsx --env-file-if-exists=.env server/index.ts)
139
+ yarn dev:client # Vite dev server only
140
+
141
+ yarn build # type-check (vue-tsc) + vite build -> dist/
142
+ yarn typecheck:server # type-check the server (tsconfig.server.json)
143
+ yarn server # run backend; serves dist/ + the APIs on :3456
144
+ yarn test # vitest run
145
+ ```
146
+
147
+ The backend is TypeScript run directly via `tsx` (no build step); `server/` is
148
+ type-checked separately through `tsconfig.server.json` (`strict`), kept out of
149
+ the main `build` so the two type-check independently.
150
+
151
+ In dev, open the Vite URL; its proxy forwards `/ws`, `/ws/pubsub`, and `/api` to
152
+ `:3456`. In production, run `yarn build` then `yarn server` and open
153
+ `http://localhost:3456`.
154
+
155
+ ---
156
+
157
+ ## Server API specification
158
+
159
+ Base URL: `http://localhost:$PORT` (default `http://localhost:3456`).
160
+
161
+ ### HTTP: `GET /api/sessions`
162
+
163
+ Lists the most-recent chat sessions for the current project (`CLAUDE_CWD`),
164
+ newest first, including freshly-created sessions that aren't yet written to disk.
165
+
166
+ **Response `200 application/json`**
167
+
168
+ ```jsonc
169
+ {
170
+ "cwd": "/Users/you/my-project",
171
+ "sessions": [
172
+ {
173
+ "id": "d16f43f3-ef63-4a5e-b273-debaccb3522a", // session UUID (= .jsonl basename)
174
+ "title": "Review available skills list", // see "Session discovery & titles"
175
+ "mtime": 1781471064511.22, // last-modified, ms epoch (sort key)
176
+ "working": false, // Claude is mid-turn (blue dot)
177
+ "waiting": false // needs attention (bold)
178
+ }
179
+ // ...
180
+ ]
181
+ }
182
+ ```
183
+
184
+ - Sessions are read from `~/.claude/projects/<encoded CLAUDE_CWD>/*.jsonl` and
185
+ merged with in-memory sessions started this run but not yet persisted (those
186
+ have `title: "New session"` and `mtime` = creation time).
187
+ - Sorted by `mtime` descending and capped at the **50** most recent. Files are
188
+ ranked by a cheap `stat`-only pass; only the top 50 are read and parsed for
189
+ titles, so the endpoint stays cheap regardless of how many sessions exist.
190
+ - `500 { "error": string }` on an unexpected filesystem error. A missing project
191
+ directory is **not** an error — it yields an empty `sessions` array.
192
+
193
+ ### HTTP: `POST /api/hook`
194
+
195
+ **Internal endpoint.** Claude hooks (injected per session — see
196
+ [Claude hook injection](#claude-hook-injection)) POST their event payload here.
197
+ You normally don't call this yourself.
198
+
199
+ **Request `application/json`** — the Claude hook payload; only these fields are used:
200
+
201
+ ```jsonc
202
+ {
203
+ "session_id": "d16f43f3-...", // the session the event is for
204
+ "hook_event_name": "UserPromptSubmit" // "UserPromptSubmit" | "Stop" | "Notification"
205
+ }
206
+ ```
207
+
208
+ Effect (see [Session model](#session-model)):
209
+
210
+ | `hook_event_name` | Effect |
211
+ | ------------------ | ------ |
212
+ | `UserPromptSubmit` | `working = true` for the session. |
213
+ | `Stop` | `working = false`; if the session is **backgrounded**, also `waiting = true`. |
214
+ | `Notification` | If the session is **backgrounded**, `waiting = true`. |
215
+
216
+ Any resulting state change is published on the `sessions` pub/sub channel.
217
+
218
+ **Response `200 application/json`**: `{ "ok": true }` (always, even for unknown events).
219
+
220
+ ### WebSocket: `/ws` (terminal)
221
+
222
+ A raw WebSocket carrying the terminal stream for one session. One PTY per
223
+ connection (or reattach to an existing background PTY).
224
+
225
+ **Connect**
226
+
227
+ - `ws://host/ws` — start a **new** session (server generates a UUID and spawns
228
+ `claude --session-id <uuid> --settings <hooks>`).
229
+ - `ws://host/ws?session=<id>` — **resume/reattach** a session. If a live
230
+ background PTY exists for `<id>`, the socket reattaches to it (and its recent
231
+ output buffer is replayed); otherwise the server spawns
232
+ `claude --resume <id> --settings <hooks>`.
233
+
234
+ **Server → client** (JSON text frames):
235
+
236
+ | Message | Meaning |
237
+ | ------- | ------- |
238
+ | `{ "type": "session", "id": string }` | Sent immediately on connect — the session id this socket is bound to (lets the client learn a new session's generated id). |
239
+ | `{ "type": "output", "data": string }` | PTY output to write to the terminal. On reattach, the first `output` frame is the replayed tail buffer (≤ 64 KB). |
240
+ | `{ "type": "exit", "exitCode": number, "signal": number }` | The `claude` process exited; the socket then closes. |
241
+
242
+ **Client → server** (JSON text frames):
243
+
244
+ | Message | Meaning |
245
+ | ------- | ------- |
246
+ | `{ "type": "input", "data": string }` | Keystrokes / bytes to write to the PTY. |
247
+ | `{ "type": "resize", "cols": number, "rows": number }` | Resize the PTY. |
248
+
249
+ A non-JSON frame is written to the PTY verbatim (fallback).
250
+
251
+ **Disconnect** — when the socket closes, if Claude is still `working` the PTY is
252
+ **kept alive** in the background; otherwise it's killed. See
253
+ [Session lifecycle](#session-lifecycle).
254
+
255
+ ### Socket.IO: `/ws/pubsub` (activity pub/sub)
256
+
257
+ A minimal Socket.IO pub/sub for live session-activity updates. Channel names are
258
+ Socket.IO rooms.
259
+
260
+ - **Path**: `/ws/pubsub`, transport: `websocket`.
261
+ - **Client → server events**:
262
+ - `subscribe` with a channel name (string) → join the room.
263
+ - `unsubscribe` with a channel name (string) → leave the room.
264
+ - **Server → client event**: `data` with `{ channel: string, data: <payload> }`.
265
+
266
+ **Channel `"sessions"`** — payloads describe a single session change:
267
+
268
+ ```jsonc
269
+ // activity change (working/waiting flipped)
270
+ { "id": "d16f43f3-...", "working": false, "waiting": true, "event": "Stop" }
271
+
272
+ // a brand-new session was created
273
+ { "id": "…", "working": false, "event": "created" }
274
+
275
+ // a session's PTY was closed/reaped
276
+ { "id": "…", "working": false, "event": "closed" }
277
+ ```
278
+
279
+ `event` is the originating hook (`UserPromptSubmit` | `Stop` | `Notification`) or
280
+ a lifecycle marker (`created` | `closed` | `null`). The client treats **any**
281
+ `sessions` message as a signal to refetch `GET /api/sessions` (the server is the
282
+ single source of truth for the list), so payload details are advisory.
283
+
284
+ ---
285
+
286
+ ## Session model
287
+
288
+ Per-session state lives on the server (`activity` map) and is surfaced as two
289
+ booleans on every session record:
290
+
291
+ | Flag | Set when | Cleared when | UI |
292
+ | --------- | -------- | ------------ | -- |
293
+ | `working` | `UserPromptSubmit` hook fires (Claude started a turn) | `Stop` hook fires (turn finished) | **Blue dot** next to the title |
294
+ | `waiting` | A **background** session fires `Notification` (waiting for input — permission / question / idle) **or** `Stop` (finished, output unseen, ready for another message) | The session is brought to the **foreground** (a WebSocket attaches to it) | **Bold** title |
295
+
296
+ "Foreground" = a session that currently has an attached terminal WebSocket (the
297
+ one you're viewing). `waiting` is only ever set for **background** sessions,
298
+ because a foreground session is already on screen.
299
+
300
+ ---
301
+
302
+ ## Session lifecycle
303
+
304
+ ```
305
+ new ws /ws ws /ws?session=<id>
306
+ │ │
307
+ ▼ ▼
308
+ generate UUID, spawn live bg PTY? ──yes──► reattach + replay buffer
309
+ claude --session-id <uuid> │ no
310
+ register "New session", ▼
311
+ publish "created" spawn claude --resume <id>
312
+ │ │
313
+ └───────────────┬──────────────────────┘
314
+
315
+ attached (foreground) ── setWaiting(false) ──► not bold
316
+
317
+ ws close (switch away / disconnect)
318
+
319
+ ┌───────── working? ──────────┐
320
+ yes no
321
+ │ │
322
+ keep PTY alive (background) kill PTY (reap), publish "closed"
323
+
324
+ Stop hook in background:
325
+ waiting=true (bold), working=false, reap PTY
326
+ (flag persists via on-disk record → stays listed & bold until viewed)
327
+ ```
328
+
329
+ Key rules:
330
+
331
+ - **Switching away never interrupts Claude mid-turn** — a `working` session's PTY
332
+ survives in the background.
333
+ - A background session that goes **idle** (`Stop`) is **reaped** (killed). If it
334
+ finished with unseen output, its `waiting` flag persists via the on-disk
335
+ session record, so it stays listed and **bold** until you open it.
336
+ - **Reattach over respawn**: selecting a session that still has a live background
337
+ PTY reattaches to it (replaying a ≤ 64 KB output tail) instead of spawning a
338
+ duplicate `claude`.
339
+ - Brand-new sessions appear in the sidebar **immediately** (before their `.jsonl`
340
+ exists) via the in-memory `knownSessions` registry + a `created` push; an
341
+ unused one disappears when its PTY is reaped.
342
+
343
+ ---
344
+
345
+ ## Claude hook injection
346
+
347
+ Activity is detected via Claude Code hooks injected **per spawn**, without
348
+ touching the user's `~/.claude/settings.json` or project settings. The server
349
+ passes `claude --settings '<json>'` where the JSON registers a command hook for
350
+ `UserPromptSubmit`, `Stop`, and `Notification`, each of which pipes the hook
351
+ payload to the server:
352
+
353
+ ```jsonc
354
+ {
355
+ "hooks": {
356
+ "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "curl -s -X POST http://localhost:$PORT/api/hook -H 'content-type: application/json' -d @-" }] }],
357
+ "Stop": [{ "hooks": [{ "type": "command", "command": "curl … -d @-" }] }],
358
+ "Notification": [{ "hooks": [{ "type": "command", "command": "curl … -d @-" }] }]
359
+ }
360
+ }
361
+ ```
362
+
363
+ Because the server spawns each new session with `--session-id <uuid>`, it always
364
+ knows the live session's id — even before the session's `.jsonl` file exists.
365
+
366
+ ---
367
+
368
+ ## Session discovery & titles
369
+
370
+ Claude stores each project's sessions as JSONL files under
371
+ `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, where the absolute `cwd`
372
+ has its `/` and `.` characters replaced with `-` (e.g.
373
+ `/Users/you/proj` → `-Users-you-proj`).
374
+
375
+ A session's display **title** is derived by scanning its JSONL for, in order of
376
+ preference:
377
+
378
+ 1. the latest `ai-title` record's `aiTitle`,
379
+ 2. else the latest `last-prompt` record's `lastPrompt`,
380
+ 3. else the first real user message (slash/local-command wrappers like
381
+ `<local-command-…>` are skipped),
382
+ 4. else `"(untitled session)"`.
383
+
384
+ In-memory sessions not yet persisted show as `"New session"` until their file
385
+ appears, at which point the on-disk title takes over.
386
+
387
+ ---
388
+
389
+ ## Project structure
390
+
391
+ ```
392
+ server/
393
+ index.js Express app, /api routes, terminal WebSocket, PTY lifecycle,
394
+ session state, hook injection, session discovery
395
+ pubsub.js createPubSub(server) — socket.io pub/sub at /ws/pubsub
396
+ fix-pty-perms.js postinstall: fixes node-pty prebuilt binary permissions
397
+ src/
398
+ App.vue Layout (sidebar + terminal); owns the active session id
399
+ components/
400
+ Sidebar.vue Session list; working dot + waiting bold; pub/sub driven
401
+ Sidebar.spec.ts Vitest component tests
402
+ Terminal.vue xterm.js terminal; /ws connection, reconnect on switch
403
+ composables/
404
+ usePubSub.ts socket.io-client pub/sub composable (subscribe/unsubscribe)
405
+ vite.config.ts Dev proxy for /ws, /ws/pubsub, /api
406
+ vitest.config.ts jsdom test environment
407
+ ```
408
+
409
+ ---
410
+
411
+ ## Testing
412
+
413
+ ```bash
414
+ yarn test
415
+ ```
416
+
417
+ `src/components/Sidebar.spec.ts` covers the sidebar: rendering the server's
418
+ session list, the working dot, the `waiting` bold state, refetching on a pub/sub
419
+ push, and emitting `select` on click. The pub/sub composable and `fetch` are
420
+ mocked so the tests run without a server.
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+
3
+ // MulmoTerminal launcher — `npx mulmoterminal` entry point.
4
+ //
5
+ // Ships the server source (TypeScript) + a pre-built client (Vite dist/), and
6
+ // runs the server via tsx. Mirrors the mulmoclaude launcher.
7
+
8
+ import { execSync, spawn } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { get as httpGet } from "node:http";
11
+ import { createRequire } from "node:module";
12
+ import { createServer } from "node:net";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const PKG_DIR = join(__dirname, "..");
18
+ const SERVER_ENTRY = join(PKG_DIR, "server", "index.ts");
19
+ const DEFAULT_PORT = 3456;
20
+ const READY_TIMEOUT_MS = 15_000;
21
+ const MAX_PORT_PROBES = 20;
22
+
23
+ // Single source of truth: read the version from the shipped package.json so
24
+ // `--version` never drifts from the published version.
25
+ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
26
+
27
+ const log = (msg) => console.log(`\x1b[36m[mulmoterminal]\x1b[0m ${msg}`);
28
+ const error = (msg) => console.error(`\x1b[31m[mulmoterminal]\x1b[0m ${msg}`);
29
+
30
+ function claudeInstalled() {
31
+ try {
32
+ // Intentionally resolves `claude` from the user's PATH — detecting their
33
+ // Claude Code CLI install is the whole point of this pre-flight check.
34
+ // eslint-disable-next-line sonarjs/no-os-command-from-path
35
+ execSync("claude --version", { stdio: "pipe" });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function pickOpenCommand() {
43
+ if (process.platform === "darwin") return "open";
44
+ if (process.platform === "win32") return "start";
45
+ return "xdg-open";
46
+ }
47
+
48
+ // Resolve with true if nothing is listening on `port`, false otherwise.
49
+ function isPortFree(port) {
50
+ return new Promise((resolve) => {
51
+ const probe = createServer();
52
+ probe.once("error", () => resolve(false));
53
+ probe.once("listening", () => probe.close(() => resolve(true)));
54
+ probe.listen(port, "127.0.0.1");
55
+ });
56
+ }
57
+
58
+ async function findAvailablePort(start) {
59
+ for (let port = start; port < start + MAX_PORT_PROBES; port++) {
60
+ if (await isPortFree(port)) return port;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // Poll the server until it answers, then call onReady; give up after the timeout
66
+ // so the launcher never hangs on a crash loop.
67
+ function waitUntilReady(port, onReady) {
68
+ const startedAt = Date.now();
69
+ const attempt = () => {
70
+ const req = httpGet({ host: "127.0.0.1", port, path: "/", timeout: 1000 }, (res) => {
71
+ res.resume();
72
+ onReady();
73
+ });
74
+ req.on("error", retry);
75
+ req.on("timeout", () => {
76
+ req.destroy();
77
+ retry();
78
+ });
79
+ };
80
+ const retry = () => {
81
+ if (Date.now() - startedAt > READY_TIMEOUT_MS) return;
82
+ setTimeout(attempt, 300);
83
+ };
84
+ attempt();
85
+ }
86
+
87
+ function printReadyBanner(url) {
88
+ const bar = "\x1b[32m" + "─".repeat(48) + "\x1b[0m";
89
+ console.log(`\n${bar}`);
90
+ console.log(`\x1b[32m ✓ MulmoTerminal is ready\x1b[0m`);
91
+ console.log(`\x1b[32m → ${url}\x1b[0m`);
92
+ console.log(`\x1b[32m Press Ctrl+C to stop.\x1b[0m`);
93
+ console.log(`${bar}\n`);
94
+ }
95
+
96
+ function parsePortArg(args) {
97
+ const idx = args.indexOf("--port");
98
+ if (idx === -1) return { requestedPort: DEFAULT_PORT, portExplicit: false };
99
+ const raw = args[idx + 1];
100
+ const parsed = Number.parseInt(raw ?? "", 10);
101
+ if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed < 1 || parsed > 65535) {
102
+ error(`Invalid --port value: "${raw ?? ""}" (expected integer 1..65535)`);
103
+ process.exit(1);
104
+ }
105
+ return { requestedPort: parsed, portExplicit: true };
106
+ }
107
+
108
+ async function choosePort(requested, explicit) {
109
+ if (await isPortFree(requested)) return requested;
110
+ if (explicit) {
111
+ error(`Port ${requested} is already in use. Stop the other process or pick a different --port.`);
112
+ process.exit(1);
113
+ }
114
+ const fallback = await findAvailablePort(requested + 1);
115
+ if (fallback === null) {
116
+ error(`Port ${requested} is in use and no free port found nearby.`);
117
+ process.exit(1);
118
+ }
119
+ log(`Port ${requested} busy → using ${fallback} instead. (Pass --port <N> to pin.)`);
120
+ return fallback;
121
+ }
122
+
123
+ async function main() {
124
+ const args = process.argv.slice(2);
125
+
126
+ if (args.includes("--help") || args.includes("-h")) {
127
+ console.log(`
128
+ Usage: npx mulmoterminal [options]
129
+
130
+ Options:
131
+ --port <number> Server port (default: ${DEFAULT_PORT})
132
+ --no-open Don't open the browser automatically
133
+ --version Show version
134
+ --help Show this help
135
+ `);
136
+ return;
137
+ }
138
+ if (args.includes("--version")) {
139
+ console.log(`mulmoterminal ${VERSION}`);
140
+ return;
141
+ }
142
+
143
+ if (!claudeInstalled()) {
144
+ error("Claude Code CLI not found.");
145
+ error("Install it first: npm install -g @anthropic-ai/claude-code && claude auth login");
146
+ process.exit(1);
147
+ }
148
+ log("Claude Code CLI ✓");
149
+
150
+ if (!existsSync(SERVER_ENTRY)) {
151
+ error(`Server entry not found at ${SERVER_ENTRY}`);
152
+ process.exit(1);
153
+ }
154
+
155
+ const { requestedPort, portExplicit } = parsePortArg(args);
156
+ const port = await choosePort(requestedPort, portExplicit);
157
+
158
+ log(`Starting MulmoTerminal on port ${port}...`);
159
+ const server = spawn(process.execPath, ["--import", "tsx", SERVER_ENTRY], {
160
+ cwd: PKG_DIR,
161
+ env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
162
+ stdio: "inherit",
163
+ });
164
+
165
+ const url = `http://localhost:${port}`;
166
+ const noOpen = args.includes("--no-open");
167
+ waitUntilReady(port, () => {
168
+ printReadyBanner(url);
169
+ if (noOpen) return;
170
+ try {
171
+ // The command is a hardcoded literal; url is http://localhost:<numeric port>.
172
+ // eslint-disable-next-line sonarjs/os-command
173
+ execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
174
+ } catch {
175
+ log(`Open your browser: ${url}`);
176
+ }
177
+ });
178
+
179
+ const shutdown = () => {
180
+ server.kill("SIGTERM");
181
+ process.exit(0);
182
+ };
183
+ process.on("SIGINT", shutdown);
184
+ process.on("SIGTERM", shutdown);
185
+ server.on("exit", (code) => process.exit(code ?? 1));
186
+ }
187
+
188
+ main();
@@ -0,0 +1 @@
1
+ *{box-sizing:border-box;margin:0;padding:0}body{background:#1a1a2e;overflow:hidden}.chip[data-v-0d423d77]{color:#9aa5c4;cursor:pointer;background:#1b2647;border:1px solid #2a2a4e;border-radius:9999px;align-items:center;padding:2px 10px;font-size:11px;line-height:16px;transition:background .12s,color .12s,border-color .12s;display:inline-flex}.chip[data-v-0d423d77]:hover{color:#cfe0ff;background:#224a86}.chip.active[data-v-0d423d77]{color:#fff;background:#2563eb;border-color:#2563eb}.sidebar[data-v-65add4c2]{color:#e0e0e0;background:#16213e;border-right:1px solid #2a2a4e;flex-direction:column;flex-shrink:0;width:260px;font-family:system-ui,sans-serif;display:flex;overflow:hidden}.sidebar-header[data-v-65add4c2]{justify-content:space-between;align-items:center;padding:10px 14px;display:flex}.heading[data-v-65add4c2]{text-transform:uppercase;letter-spacing:.05em;color:#9aa5c4;font-size:13px;font-weight:600}.icon-btn[data-v-65add4c2]{color:#9aa5c4;cursor:pointer;background:0 0;border:none;font-size:16px;line-height:1}.icon-btn[data-v-65add4c2]:hover{color:#e0e0e0}.new-btn[data-v-65add4c2]{color:#cfe0ff;cursor:pointer;background:#1b3a6b;border:none;border-radius:6px;margin:0 12px 8px;padding:8px;font-size:13px}.new-btn[data-v-65add4c2]:hover{background:#224a86}.filters[data-v-65add4c2]{align-items:center;gap:6px;padding:0 12px 8px;display:flex}.sort-btn[data-v-65add4c2]{margin-left:auto;font-size:14px}.state[data-v-65add4c2]{color:#9aa5c4;padding:12px 14px;font-size:13px}.state.error[data-v-65add4c2]{color:#ef9a9a}.list[data-v-65add4c2]{flex:1;margin:0;padding:0;list-style:none;overflow-y:auto}.item[data-v-65add4c2]{cursor:pointer;border-left:3px solid #0000;flex-direction:column;gap:2px;padding:10px 14px;display:flex}.item[data-v-65add4c2]:hover{background:#1d2b4e}.item.active[data-v-65add4c2]{background:#1d2b4e;border-left-color:#4a8cff}.item-title[data-v-65add4c2]{white-space:nowrap;text-overflow:ellipsis;font-size:13px;overflow:hidden}.item.waiting .item-title[data-v-65add4c2]{color:#fff;font-weight:700}.spinner[data-v-65add4c2]{vertical-align:middle;border:2px solid #4a8cff4d;border-top-color:#4a8cff;border-radius:50%;width:10px;height:10px;margin-right:5px;animation:.9s linear infinite sidebar-spin-65add4c2;display:inline-block}@keyframes sidebar-spin-65add4c2{to{transform:rotate(360deg)}}.item-time[data-v-65add4c2]{color:#7c87a8;font-size:11px}.tabbar[data-v-09487b28]{color:#e0e0e0;background:#16213e;border-bottom:1px solid #2a2a4e;flex-shrink:0;align-items:center;gap:8px;height:40px;padding:0 10px;font-family:system-ui,sans-serif;display:flex;overflow:hidden}.new-btn[data-v-09487b28]{color:#cfe0ff;cursor:pointer;background:#1b3a6b;border:none;border-radius:6px;flex-shrink:0;width:26px;height:26px;font-size:16px;line-height:1}.new-btn[data-v-09487b28]:hover{background:#224a86}.filters[data-v-09487b28]{flex-shrink:0;align-items:center;gap:6px;display:flex}.sort-btn[data-v-09487b28]{font-size:14px}.tabs[data-v-09487b28]{flex:1;gap:6px;min-width:0;display:flex;overflow:hidden}.tab[data-v-09487b28]{color:#cdd5ee;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;flex:1 1 0;align-items:center;gap:5px;min-width:0;max-width:200px;height:28px;padding:0 10px;font-size:12px;transition:background .12s;display:flex;position:relative}.tab[data-v-09487b28]:hover{background:#1d2b4e}.tab.active[data-v-09487b28]{background:#1d2b4e;border-color:#4a8cff}.tab-title[data-v-09487b28]{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.tab.waiting .tab-title[data-v-09487b28]{color:#fff;font-weight:700}.unread-dot[data-v-09487b28]{background:#ef4444;border-radius:50%;flex-shrink:0;width:7px;height:7px;box-shadow:0 0 0 2px #16213e}.spinner[data-v-09487b28]{border:2px solid #4a8cff4d;border-top-color:#4a8cff;border-radius:50%;flex-shrink:0;width:10px;height:10px;animation:.9s linear infinite tabbar-spin-09487b28}@keyframes tabbar-spin-09487b28{to{transform:rotate(360deg)}}.actions[data-v-09487b28]{flex-shrink:0;align-items:center;gap:8px;display:flex}.icon-btn[data-v-09487b28]{color:#9aa5c4;cursor:pointer;background:0 0;border:none;font-size:16px;line-height:1}.icon-btn[data-v-09487b28]:hover{color:#e0e0e0}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}.terminal-wrapper[data-v-0a7d9e9b]{background:#1a1a2e;flex-direction:column;flex:1;min-width:0;height:100%;display:flex}.header[data-v-0a7d9e9b]{color:#e0e0e0;background:#16213e;align-items:center;gap:12px;padding:8px 16px;font-family:system-ui,sans-serif;font-size:14px;display:flex}.title[data-v-0a7d9e9b]{font-weight:600}.status[data-v-0a7d9e9b]{border-radius:4px;padding:2px 8px;font-size:12px}.status.connected[data-v-0a7d9e9b]{color:#a5d6a7;background:#1b5e20}.status.connecting[data-v-0a7d9e9b]{color:#ffcc80;background:#e65100}.status.disconnected[data-v-0a7d9e9b]{color:#ef9a9a;background:#b71c1c}.terminal-container[data-v-0a7d9e9b]{flex:1;padding:4px}.plugin-frame-host[data-v-5e7324b3]{display:block}.gui-panel[data-v-51ab822d]{background:#11162a;border-left:1px solid #2a2a4e;flex-direction:column;flex:1;min-width:0;height:100%;display:flex}.header[data-v-51ab822d]{color:#e0e0e0;background:#16213e;justify-content:space-between;align-items:center;padding:8px 16px;font-family:system-ui,sans-serif;font-size:14px;display:flex}.title[data-v-51ab822d]{font-weight:600}.gear[data-v-51ab822d]{color:#7c87a8;cursor:pointer;background:0 0;border:none;border-radius:4px;padding:2px 4px;font-size:15px;line-height:1}.gear[data-v-51ab822d]:hover{color:#e0e0e0}.gear.active[data-v-51ab822d]{color:#4a8cff}.content[data-v-51ab822d]{color:#e0e0e0;flex:1;padding:12px 16px;font-family:system-ui,sans-serif;font-size:14px;line-height:1.5;overflow-y:auto}.empty[data-v-51ab822d]{color:#7c87a8;font-size:13px}.empty code[data-v-51ab822d]{background:#1d2b4e;border-radius:4px;padding:1px 5px}.frame+.frame[data-v-51ab822d]{border-top:1px solid #2a2a4e;margin-top:16px;padding-top:16px}.tools-pane[data-v-167cc06d]{background:#0d1124;border-left:1px solid #2a2a4e;flex-direction:column;flex-shrink:0;width:340px;height:100%;display:flex}.header[data-v-167cc06d]{color:#e0e0e0;background:#16213e;padding:8px 16px;font-family:system-ui,sans-serif;font-size:14px}.title[data-v-167cc06d]{font-weight:600}.content[data-v-167cc06d]{color:#e0e0e0;flex:1;font-family:system-ui,sans-serif;font-size:13px;overflow-y:auto}.section[data-v-167cc06d]{border-bottom:1px solid #2a2a4e;padding:10px 12px}.section-title[data-v-167cc06d]{text-transform:uppercase;letter-spacing:.04em;color:#7c87a8;margin-bottom:8px;font-size:11px;font-weight:700}.section-title--with-action[data-v-167cc06d]{justify-content:space-between;align-items:center;gap:8px;display:flex}.copy-history[data-v-167cc06d]{text-transform:none;letter-spacing:.02em;color:#9aa5c4;cursor:pointer;background:#1d2b4e;border:1px solid #2a2a4e;border-radius:4px;padding:2px 8px;font-size:10px;font-weight:600;transition:color .15s,background .15s}.copy-history[data-v-167cc06d]:hover:not(:disabled){color:#cfe0ff;background:#243763}.copy-history[data-v-167cc06d]:disabled{opacity:.4;cursor:not-allowed}.muted[data-v-167cc06d]{color:#7c87a8;font-size:12px}.italic[data-v-167cc06d]{font-style:italic}.tool+.tool[data-v-167cc06d]{margin-top:4px}.tool-head[data-v-167cc06d],.call-head[data-v-167cc06d]{cursor:pointer;text-align:left;width:100%;color:inherit;background:0 0;border:none;justify-content:space-between;align-items:center;gap:8px;padding:4px 0;display:flex}.tool-name[data-v-167cc06d],.call-name[data-v-167cc06d]{color:#cfe0ff;word-break:break-all;background:#1d2b4e;border-radius:4px;padding:2px 6px;font-family:JetBrains Mono,monospace;font-size:12px}.caret[data-v-167cc06d]{color:#7c87a8;font-size:10px}.tool-desc[data-v-167cc06d]{color:#9aa5c4;white-space:pre-wrap;margin:2px 0 6px;font-size:12px}.call[data-v-167cc06d]{background:#11162a;border:1px solid #2a2a4e;border-radius:6px;margin-top:6px;padding:6px 8px}.call-meta[data-v-167cc06d]{flex-shrink:0;align-items:center;gap:8px;display:flex}.badge[data-v-167cc06d]{border-radius:999px;padding:1px 6px;font-size:10px}.badge.running[data-v-167cc06d]{color:#ffcc80;background:#4a3a10}.badge.done[data-v-167cc06d]{color:#a5d6a7;background:#14361c}.badge.failed[data-v-167cc06d]{color:#ef9a9a;background:#4a1414}.time[data-v-167cc06d]{color:#6b769a;font-variant-numeric:tabular-nums;font-size:11px}.call-body[data-v-167cc06d]{margin-top:6px}.label[data-v-167cc06d]{text-transform:uppercase;letter-spacing:.04em;color:#7c87a8;margin:6px 0 2px;font-size:10px}.block[data-v-167cc06d]{white-space:pre-wrap;word-break:break-word;background:#0a0e1f;border:1px solid #20284a;border-radius:4px;max-height:220px;margin:0;padding:6px 8px;font-family:JetBrains Mono,monospace;font-size:11.5px;overflow:auto}.block.result[data-v-167cc06d]{border-color:#1c3a24}.block.error[data-v-167cc06d]{color:#ef9a9a;border-color:#4a1414}.app[data-v-be189b61]{width:100vw;height:100vh;display:flex;overflow:hidden}.app-vertical[data-v-be189b61]{flex-direction:row}.app-horizontal[data-v-be189b61]{flex-direction:column}.main[data-v-be189b61]{flex:1;min-width:0;min-height:0;display:flex}.terminal-pane[data-v-be189b61]{min-width:0}.splitter[data-v-be189b61]{cursor:col-resize;background:#16213e;border-left:1px solid #2a2a4e;border-right:1px solid #2a2a4e;flex:0 0 5px}.splitter[data-v-be189b61]:hover{background:#2a3b66}.splitter[data-v-be189b61]:focus-visible{background:#4a8cff;outline:none}