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 +21 -0
- package/README.md +420 -0
- package/bin/mulmoterminal.js +188 -0
- package/dist/assets/index-Bhcnvx68.css +1 -0
- package/dist/assets/index-r5DjoOQU.js +186 -0
- package/dist/assets/marp-DqK0_Srt.js +3451 -0
- package/dist/index.html +14 -0
- package/package.json +100 -0
- package/plugins/plugins.json +4 -0
- package/server/backends/image-gen.ts +74 -0
- package/server/backends/markdown.ts +164 -0
- package/server/fix-pty-perms.js +15 -0
- package/server/host-tools.ts +45 -0
- package/server/index.ts +911 -0
- package/server/mcp/broker.ts +105 -0
- package/server/plugins-registry.ts +110 -0
- package/server/pubsub.ts +36 -0
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}
|