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/server/index.ts
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import http from "http";
|
|
3
|
+
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
|
4
|
+
import pty from "node-pty";
|
|
5
|
+
import type { IPty } from "node-pty";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import fs from "fs/promises";
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
12
|
+
import { createPubSub } from "./pubsub.js";
|
|
13
|
+
import { mountAllRoutes, allowedToolNames, toolSummaries } from "./plugins-registry.js";
|
|
14
|
+
import { buildGuiMcpServer } from "./mcp/broker.js";
|
|
15
|
+
import { initMarkdownBackend } from "./backends/markdown.js";
|
|
16
|
+
|
|
17
|
+
// Per-session activity flags, driven by Claude hooks (see /api/hook).
|
|
18
|
+
interface Activity {
|
|
19
|
+
working?: boolean;
|
|
20
|
+
waiting?: boolean;
|
|
21
|
+
event?: string | null;
|
|
22
|
+
at?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A live PTY and its (possibly detached) browser socket.
|
|
26
|
+
interface PtyEntry {
|
|
27
|
+
term: IPty;
|
|
28
|
+
ws: WebSocket | null;
|
|
29
|
+
buffer: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface KnownSession {
|
|
33
|
+
createdAt: number;
|
|
34
|
+
title: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A GUI plugin result, deduped by uuid; the rest of the payload is opaque here.
|
|
38
|
+
interface ToolResult {
|
|
39
|
+
uuid: string;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One entry in a session's tool-call history (Pre/PostToolUse hooks).
|
|
44
|
+
interface ToolCall {
|
|
45
|
+
toolUseId?: string;
|
|
46
|
+
toolName?: string;
|
|
47
|
+
toolInput?: unknown;
|
|
48
|
+
toolOutput?: unknown;
|
|
49
|
+
durationMs?: number;
|
|
50
|
+
status: string;
|
|
51
|
+
at: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A sidebar session row (resolved from disk or a pending in-memory session).
|
|
55
|
+
interface SessionMeta {
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
mtime: number;
|
|
59
|
+
working: boolean;
|
|
60
|
+
waiting: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Recency rank for an on-disk .jsonl, before its contents are read.
|
|
64
|
+
interface DiskStat {
|
|
65
|
+
kind: "disk";
|
|
66
|
+
id: string;
|
|
67
|
+
file: string;
|
|
68
|
+
mtime: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// An in-memory session not yet persisted to disk.
|
|
72
|
+
interface PendingSession extends SessionMeta {
|
|
73
|
+
kind: "pending";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Node fs errors carry a `code` (e.g. "ENOENT"); narrow before reading it.
|
|
77
|
+
const hasErrnoCode = (e: unknown): e is { code?: string } => typeof e === "object" && e !== null;
|
|
78
|
+
|
|
79
|
+
// Error message extracted defensively from an unknown thrown value.
|
|
80
|
+
const messageOf = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
81
|
+
|
|
82
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
|
|
83
|
+
|
|
84
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
85
|
+
|
|
86
|
+
const PORT = process.env.PORT || 3456;
|
|
87
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
|
|
88
|
+
// Permission mode for backend-spawned Claude sessions. Defaults to "auto" so
|
|
89
|
+
// the backend runs hands-off; override with CLAUDE_PERMISSION_MODE (e.g.
|
|
90
|
+
// "default" / "acceptEdits" / "bypassPermissions" / "plan") when needed.
|
|
91
|
+
const CLAUDE_PERMISSION_MODE = process.env.CLAUDE_PERMISSION_MODE || "auto";
|
|
92
|
+
const CLAUDE_CWD = process.env.CLAUDE_CWD || path.join(os.homedir(), "mulmoclaude");
|
|
93
|
+
|
|
94
|
+
// CLAUDE_CWD is the workspace used as the PTY cwd and as the root for persisted
|
|
95
|
+
// session state, so it must exist before we spawn anything into it.
|
|
96
|
+
await fs.mkdir(CLAUDE_CWD, { recursive: true });
|
|
97
|
+
|
|
98
|
+
// A session id is always a UUID (server-generated, or a .jsonl basename). Reject
|
|
99
|
+
// anything else so a client can't smuggle CLI flags (e.g. "--resume" followed by
|
|
100
|
+
// a value that claude re-parses as a flag) into the spawned process.
|
|
101
|
+
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
102
|
+
|
|
103
|
+
// Only same-machine browser origins may open the terminal / pub-sub sockets, so
|
|
104
|
+
// a malicious website the user visits can't drive the local Claude PTY (a
|
|
105
|
+
// cross-site WebSocket hijack). A missing Origin (non-browser local client) is
|
|
106
|
+
// allowed; any localhost host on any port is allowed (covers the Vite dev proxy).
|
|
107
|
+
function isAllowedOrigin(origin?: string) {
|
|
108
|
+
if (!origin) return true;
|
|
109
|
+
try {
|
|
110
|
+
const host = new URL(origin).hostname;
|
|
111
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pub/sub channel the sidebar subscribes to for live session-activity changes.
|
|
118
|
+
const SESSIONS_CHANNEL = "sessions";
|
|
119
|
+
|
|
120
|
+
// Per-session pub/sub channel the GUI panel subscribes to. The MCP broker POSTs a
|
|
121
|
+
// toolResult to /api/agent/toolResult, which stores it keyed by session id and
|
|
122
|
+
// publishes it here (mirrors MulmoClaude's sessionChannel; see the spike doc).
|
|
123
|
+
const sessionChannel = (id: string) => `session:${id}`;
|
|
124
|
+
|
|
125
|
+
// The GUI MCP server is served in-process over Streamable HTTP at /mcp/:sessionId
|
|
126
|
+
// (see the route below) and wired into each spawned claude via --mcp-config. It
|
|
127
|
+
// exposes one GUI-protocol tool per enabled plugin (driven by plugins/plugins.json)
|
|
128
|
+
// and drives the GUI panel via the toolResult route.
|
|
129
|
+
|
|
130
|
+
// MCP tool names claude uses, in the mcp__<server>__<tool> form, one per enabled
|
|
131
|
+
// plugin. Auto-allowed via --allowedTools so the spike doesn't trip the permission
|
|
132
|
+
// prompt (permissions stay terminal-native). Comma-joined into one --allowedTools.
|
|
133
|
+
const GUI_MCP_TOOLS = allowedToolNames().join(",");
|
|
134
|
+
|
|
135
|
+
// A per-session list store mirrored to disk so it survives a server reboot — one
|
|
136
|
+
// JSON file per session under <workspace>/<dirName>/<sessionId>.json
|
|
137
|
+
// (<workspace> = CLAUDE_CWD). The in-memory Map is the working copy; the file is
|
|
138
|
+
// rewritten on each change and lazy-loaded on first access. Session ids are
|
|
139
|
+
// validated UUIDs (SESSION_ID_RE), so they're safe to use as filenames.
|
|
140
|
+
function createSessionStore<T>(dirName: string) {
|
|
141
|
+
const dir = path.join(CLAUDE_CWD, dirName);
|
|
142
|
+
const fileFor = (id: string) => path.join(dir, `${id}.json`);
|
|
143
|
+
const map = new Map<string, T[]>(); // id -> list (the working copy; mutate in place)
|
|
144
|
+
const loading = new Map<string, Promise<T[]>>(); // id -> Promise<list>, dedupes concurrent loads
|
|
145
|
+
|
|
146
|
+
// Lazily load a session's list from disk, then keep using the in-memory copy.
|
|
147
|
+
function get(sessionId: string): Promise<T[]> {
|
|
148
|
+
const cached = map.get(sessionId);
|
|
149
|
+
if (cached) return Promise.resolve(cached);
|
|
150
|
+
const inflight = loading.get(sessionId);
|
|
151
|
+
if (inflight) return inflight;
|
|
152
|
+
const p = (async () => {
|
|
153
|
+
let list: T[] = [];
|
|
154
|
+
if (SESSION_ID_RE.test(sessionId)) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(await fs.readFile(fileFor(sessionId), "utf8"));
|
|
157
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
158
|
+
} catch {
|
|
159
|
+
// No file yet (or unreadable) => start empty.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
map.set(sessionId, list);
|
|
163
|
+
loading.delete(sessionId);
|
|
164
|
+
return list;
|
|
165
|
+
})();
|
|
166
|
+
loading.set(sessionId, p);
|
|
167
|
+
return p;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Persist a session's list (best-effort, fire-and-forget).
|
|
171
|
+
async function save(sessionId: string) {
|
|
172
|
+
if (!SESSION_ID_RE.test(sessionId)) return;
|
|
173
|
+
try {
|
|
174
|
+
await fs.mkdir(dir, { recursive: true });
|
|
175
|
+
await fs.writeFile(fileFor(sessionId), JSON.stringify(map.get(sessionId) || []));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.error(`[${dirName}] failed to persist ${sessionId}: ${messageOf(e)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { get, save };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// GUI toolResults per session, persisted under <workspace>/.toolresults so the
|
|
185
|
+
// panel replays the rendered views even after a server reboot. (Chat + message
|
|
186
|
+
// history live in the terminal and Claude's .jsonl; this is the GUI-side store.)
|
|
187
|
+
// Each entry is an array of toolResults, capped to the most recent N.
|
|
188
|
+
const toolResultsStore = createSessionStore<ToolResult>(".toolresults");
|
|
189
|
+
const GUI_HISTORY_LIMIT = 50;
|
|
190
|
+
|
|
191
|
+
// Upsert a toolResult into a session's list, deduped by uuid — a re-emitted result
|
|
192
|
+
// (e.g. a form whose viewState changed after the user submitted) updates in place.
|
|
193
|
+
// Mirrors MulmoClaude's applyToolResultToSession.
|
|
194
|
+
async function storeToolResult(sessionId: string, result: ToolResult) {
|
|
195
|
+
const list = await toolResultsStore.get(sessionId);
|
|
196
|
+
const idx = list.findIndex((r) => r.uuid === result.uuid);
|
|
197
|
+
if (idx >= 0) {
|
|
198
|
+
list[idx] = result;
|
|
199
|
+
} else {
|
|
200
|
+
list.push(result);
|
|
201
|
+
if (list.length > GUI_HISTORY_LIMIT) list.splice(0, list.length - GUI_HISTORY_LIMIT);
|
|
202
|
+
}
|
|
203
|
+
toolResultsStore.save(sessionId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Per-session tool-call history, fed by Claude's PreToolUse/PostToolUse hooks so
|
|
207
|
+
// it captures EVERY tool call — built-ins (Bash, Read, …), the user's MCP tools,
|
|
208
|
+
// AND our GUI plugin tools — not just the GUI ones the broker sees. Published on a
|
|
209
|
+
// per-session channel the tools pane subscribes to. (The broker's toolResults
|
|
210
|
+
// store above is separate; it only drives rendering of GUI views.)
|
|
211
|
+
//
|
|
212
|
+
// Persisted under <workspace>/.toolcalls via the same disk-backed store as the
|
|
213
|
+
// toolResults, so the history survives a server reboot.
|
|
214
|
+
const toolCallsStore = createSessionStore<ToolCall>(".toolcalls");
|
|
215
|
+
const TOOLCALLS_LIMIT = 200;
|
|
216
|
+
const toolCallsChannel = (id: string) => `toolcalls:${id}`;
|
|
217
|
+
// Stored tool outputs are capped so one verbose tool can't bloat the on-disk
|
|
218
|
+
// history (and the pane). The raw output still reaches the LLM via the terminal;
|
|
219
|
+
// this is only the history copy.
|
|
220
|
+
const TOOL_OUTPUT_CAP = 20_000;
|
|
221
|
+
|
|
222
|
+
function capToolOutput(output: unknown): unknown {
|
|
223
|
+
if (typeof output === "string" && output.length > TOOL_OUTPUT_CAP) {
|
|
224
|
+
return output.slice(0, TOOL_OUTPUT_CAP) + `\n… (truncated ${output.length - TOOL_OUTPUT_CAP} chars)`;
|
|
225
|
+
}
|
|
226
|
+
return output;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// PreToolUse: a tool started. Append a "running" entry (deduped by tool_use_id).
|
|
230
|
+
async function recordToolCallStart(sessionId: string, { toolUseId, toolName, toolInput }: { toolUseId?: string; toolName?: string; toolInput?: unknown }) {
|
|
231
|
+
const list = await toolCallsStore.get(sessionId);
|
|
232
|
+
if (toolUseId && list.some((c) => c.toolUseId === toolUseId)) return;
|
|
233
|
+
const call = { toolUseId, toolName, toolInput, status: "running", at: Date.now() };
|
|
234
|
+
list.push(call);
|
|
235
|
+
if (list.length > TOOLCALLS_LIMIT) list.splice(0, list.length - TOOLCALLS_LIMIT);
|
|
236
|
+
pubsub?.publish(toolCallsChannel(sessionId), call);
|
|
237
|
+
toolCallsStore.save(sessionId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// PostToolUse (status "completed") or PostToolUseFailure (status "failed"):
|
|
241
|
+
// complete the matching entry by tool_use_id (or add one if we never saw the
|
|
242
|
+
// start). A failed tool fires PostToolUseFailure, NOT PostToolUse, so both route
|
|
243
|
+
// here — otherwise the entry would be stuck on "running".
|
|
244
|
+
async function recordToolCallEnd(
|
|
245
|
+
sessionId: string,
|
|
246
|
+
{
|
|
247
|
+
toolUseId,
|
|
248
|
+
toolName,
|
|
249
|
+
toolInput,
|
|
250
|
+
toolOutput,
|
|
251
|
+
durationMs,
|
|
252
|
+
status,
|
|
253
|
+
}: {
|
|
254
|
+
toolUseId?: string;
|
|
255
|
+
toolName?: string;
|
|
256
|
+
toolInput?: unknown;
|
|
257
|
+
toolOutput?: unknown;
|
|
258
|
+
durationMs?: number;
|
|
259
|
+
status: string;
|
|
260
|
+
},
|
|
261
|
+
) {
|
|
262
|
+
const list = await toolCallsStore.get(sessionId);
|
|
263
|
+
const output = capToolOutput(toolOutput);
|
|
264
|
+
let call = toolUseId ? list.find((c) => c.toolUseId === toolUseId) : undefined;
|
|
265
|
+
if (call) {
|
|
266
|
+
call.status = status;
|
|
267
|
+
call.toolOutput = output;
|
|
268
|
+
call.durationMs = durationMs;
|
|
269
|
+
} else {
|
|
270
|
+
call = { toolUseId, toolName, toolInput, toolOutput: output, status, at: Date.now(), durationMs };
|
|
271
|
+
list.push(call);
|
|
272
|
+
if (list.length > TOOLCALLS_LIMIT) list.splice(0, list.length - TOOLCALLS_LIMIT);
|
|
273
|
+
}
|
|
274
|
+
pubsub?.publish(toolCallsChannel(sessionId), call);
|
|
275
|
+
toolCallsStore.save(sessionId);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Only the most-recent N sessions are listed in the sidebar; older ones aren't
|
|
279
|
+
// read or parsed, keeping /api/sessions cheap for projects with many sessions.
|
|
280
|
+
const SESSION_LIST_LIMIT = 50;
|
|
281
|
+
|
|
282
|
+
// Per-session "working" state, driven by Claude hooks (see /api/hook):
|
|
283
|
+
// UserPromptSubmit => Claude started thinking; Stop => it finished.
|
|
284
|
+
const activity = new Map<string, Activity>(); // id -> { working, event, at }
|
|
285
|
+
|
|
286
|
+
// Live ptys keyed by session id. A pty outlives its WebSocket while the session
|
|
287
|
+
// is still "working", so switching away doesn't interrupt Claude mid-turn; it
|
|
288
|
+
// is reaped once the session goes idle (Stop hook) or the process exits. `ws`
|
|
289
|
+
// is null while the session runs in the background.
|
|
290
|
+
const ptys = new Map<string, PtyEntry>(); // id -> { term, ws, buffer }
|
|
291
|
+
|
|
292
|
+
// New sessions started in this process that have no .jsonl on disk yet (Claude
|
|
293
|
+
// only writes the file on the first prompt). Merged into /api/sessions so a
|
|
294
|
+
// freshly created session shows in the sidebar immediately. An entry is dropped
|
|
295
|
+
// once the file exists (the on-disk record takes over) or the pty is reaped.
|
|
296
|
+
const knownSessions = new Map<string, KnownSession>(); // id -> { createdAt, title }
|
|
297
|
+
|
|
298
|
+
// Bytes of recent output kept per pty and replayed when a client reattaches to
|
|
299
|
+
// a background session, so the user sees context instead of a blank screen.
|
|
300
|
+
const OUTPUT_BUFFER_LIMIT = 64 * 1024;
|
|
301
|
+
|
|
302
|
+
// Assigned once the HTTP server exists (createPubSub needs it).
|
|
303
|
+
let pubsub: ReturnType<typeof createPubSub> | null = null;
|
|
304
|
+
|
|
305
|
+
// Tear down a session's PTY and bookkeeping, then notify subscribers. The
|
|
306
|
+
// `activity` entry is dropped too — UNLESS it still carries `waiting`, which is
|
|
307
|
+
// what keeps a finished/needs-attention background session bold (via its
|
|
308
|
+
// on-disk record) until the user opens it. This keeps `activity` from growing
|
|
309
|
+
// unbounded while preserving the bold-until-viewed behavior.
|
|
310
|
+
function reap(id: string) {
|
|
311
|
+
const entry = ptys.get(id);
|
|
312
|
+
if (!entry) return; // already reaped
|
|
313
|
+
ptys.delete(id);
|
|
314
|
+
// An unpersisted new session vanishes with its pty; a persisted one stays
|
|
315
|
+
// visible via its on-disk record.
|
|
316
|
+
knownSessions.delete(id);
|
|
317
|
+
const a = activity.get(id);
|
|
318
|
+
if (!a || (!a.working && !a.waiting)) activity.delete(id);
|
|
319
|
+
try {
|
|
320
|
+
entry.term.kill();
|
|
321
|
+
} catch {
|
|
322
|
+
// already gone
|
|
323
|
+
}
|
|
324
|
+
pubsub?.publish(SESSIONS_CHANNEL, { id, working: false, event: "closed" });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Publish a session's current activity (working + waiting) to subscribers.
|
|
328
|
+
function publishActivity(id: string) {
|
|
329
|
+
const a = activity.get(id) || {};
|
|
330
|
+
pubsub?.publish(SESSIONS_CHANNEL, {
|
|
331
|
+
id,
|
|
332
|
+
working: a.working ?? false,
|
|
333
|
+
waiting: a.waiting ?? false,
|
|
334
|
+
event: a.event ?? null,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Claude is thinking (UserPromptSubmit) until it finishes (Stop). No-op (and no
|
|
339
|
+
// publish) when the state is unchanged.
|
|
340
|
+
function setWorking(id: string, working: boolean, event?: string) {
|
|
341
|
+
const prev = activity.get(id) || {};
|
|
342
|
+
if ((prev.working ?? false) === working) return;
|
|
343
|
+
activity.set(id, { ...prev, working, event: event ?? prev.event ?? null, at: Date.now() });
|
|
344
|
+
publishActivity(id);
|
|
345
|
+
|
|
346
|
+
// A background session (no attached client) that just went idle is reaped.
|
|
347
|
+
if (!working) {
|
|
348
|
+
const entry = ptys.get(id);
|
|
349
|
+
if (entry && !entry.ws) {
|
|
350
|
+
console.log(`[pty] reaping idle background session ${id}`);
|
|
351
|
+
reap(id);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// A background session needs the user's attention: it is waiting for input
|
|
357
|
+
// (Notification: permission / question / idle) or has finished a turn with
|
|
358
|
+
// output the user hasn't seen (Stop). Cleared when brought to the foreground
|
|
359
|
+
// (see the WebSocket connection handler).
|
|
360
|
+
function setWaiting(id: string, waiting: boolean, event?: string) {
|
|
361
|
+
const prev = activity.get(id) || {};
|
|
362
|
+
if ((prev.waiting ?? false) === waiting) return;
|
|
363
|
+
activity.set(id, { ...prev, waiting, event: event ?? prev.event ?? null, at: Date.now() });
|
|
364
|
+
publishActivity(id);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Hook config injected via `claude --settings <json>`. Each event POSTs the full
|
|
368
|
+
// hook payload to /api/hook. UserPromptSubmit => working, Stop => idle,
|
|
369
|
+
// Notification => waiting for input. PreToolUse/PostToolUse/PostToolUseFailure
|
|
370
|
+
// (matcher "" => every tool, including built-ins and MCP tools) feed the
|
|
371
|
+
// per-session tool-call history that the GUI's tools pane shows. A failed tool
|
|
372
|
+
// fires PostToolUseFailure (NOT PostToolUse), so we register both to complete the
|
|
373
|
+
// entry either way — otherwise a failed call would stay stuck on "running".
|
|
374
|
+
function hookSettingsJson() {
|
|
375
|
+
const cmd = `curl -s -X POST http://localhost:${PORT}/api/hook ` + `-H 'content-type: application/json' -d @- >/dev/null 2>&1`;
|
|
376
|
+
const entry = [{ hooks: [{ type: "command", command: cmd }] }];
|
|
377
|
+
// Tool hooks take a matcher; "" matches all tools.
|
|
378
|
+
const toolEntry = [{ matcher: "", hooks: [{ type: "command", command: cmd }] }];
|
|
379
|
+
return JSON.stringify({
|
|
380
|
+
hooks: {
|
|
381
|
+
UserPromptSubmit: entry,
|
|
382
|
+
Stop: entry,
|
|
383
|
+
Notification: entry,
|
|
384
|
+
PreToolUse: toolEntry,
|
|
385
|
+
PostToolUse: toolEntry,
|
|
386
|
+
PostToolUseFailure: toolEntry,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// MCP config injected via `claude --mcp-config <json>`. Points claude at the
|
|
392
|
+
// in-process GUI MCP server served over Streamable HTTP. The session id rides in
|
|
393
|
+
// the URL path (the MCP server is otherwise stateless), so no env or subprocess is
|
|
394
|
+
// needed — the agent just makes an HTTP call back to this server. Using
|
|
395
|
+
// 127.0.0.1 (not localhost) avoids an IPv6/IPv4 resolution mismatch against the
|
|
396
|
+
// server's listen address.
|
|
397
|
+
function mcpConfigJson(sessionId: string) {
|
|
398
|
+
return JSON.stringify({
|
|
399
|
+
mcpServers: {
|
|
400
|
+
"mulmoterminal-gui": {
|
|
401
|
+
type: "http",
|
|
402
|
+
url: `http://127.0.0.1:${PORT}/mcp/${sessionId}`,
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Claude stores each project's sessions under ~/.claude/projects/<encoded-cwd>/,
|
|
409
|
+
// where the absolute cwd has its "/" and "." characters replaced by "-".
|
|
410
|
+
function projectSessionsDir(cwd: string) {
|
|
411
|
+
const encoded = path.resolve(cwd).replace(/[/.]/g, "-");
|
|
412
|
+
return path.join(os.homedir(), ".claude", "projects", encoded);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A real user prompt from a JSONL "user" line's content, or null if it's a
|
|
416
|
+
// slash-/local-command wrapper rather than a typed prompt. Content may be a
|
|
417
|
+
// plain string or an array of blocks (guard against null elements).
|
|
418
|
+
function userPromptText(content: unknown): string | null {
|
|
419
|
+
const text = Array.isArray(content) ? content.map((x) => (isRecord(x) ? String(x.text ?? "") : String(x ?? ""))).join(" ") : content;
|
|
420
|
+
if (typeof text === "string" && text.trim() && !/^\s*<(local-command|command-|bash-)/.test(text)) {
|
|
421
|
+
return text.trim();
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Parse a JSONL file into the objects on each non-blank, valid line.
|
|
427
|
+
function parseJsonl(raw: string): Record<string, unknown>[] {
|
|
428
|
+
const out: Record<string, unknown>[] = [];
|
|
429
|
+
for (const line of raw.split("\n")) {
|
|
430
|
+
if (!line.trim()) continue;
|
|
431
|
+
try {
|
|
432
|
+
const o: unknown = JSON.parse(line);
|
|
433
|
+
if (isRecord(o)) out.push(o);
|
|
434
|
+
} catch {
|
|
435
|
+
// Skip malformed lines.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Scan a session JSONL for a human-friendly title and last activity.
|
|
442
|
+
async function readSessionMeta(dir: string, file: string): Promise<SessionMeta> {
|
|
443
|
+
const full = path.join(dir, file);
|
|
444
|
+
const [raw, stat] = await Promise.all([fs.readFile(full, "utf8"), fs.stat(full)]);
|
|
445
|
+
|
|
446
|
+
let aiTitle: string | null = null;
|
|
447
|
+
let lastPrompt: string | null = null;
|
|
448
|
+
let firstUserMsg: string | null = null;
|
|
449
|
+
|
|
450
|
+
for (const o of parseJsonl(raw)) {
|
|
451
|
+
if (o.type === "ai-title" && o.aiTitle) aiTitle = String(o.aiTitle);
|
|
452
|
+
else if (o.type === "last-prompt" && o.lastPrompt) lastPrompt = String(o.lastPrompt);
|
|
453
|
+
else if (o.type === "user" && firstUserMsg === null) {
|
|
454
|
+
firstUserMsg = userPromptText(isRecord(o.message) ? o.message.content : undefined);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const title = aiTitle || lastPrompt || firstUserMsg || "(untitled session)";
|
|
459
|
+
const id = path.basename(file, ".jsonl");
|
|
460
|
+
const a = activity.get(id);
|
|
461
|
+
return {
|
|
462
|
+
id,
|
|
463
|
+
title,
|
|
464
|
+
mtime: stat.mtimeMs,
|
|
465
|
+
working: a?.working ?? false,
|
|
466
|
+
waiting: a?.waiting ?? false,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const app = express();
|
|
471
|
+
// Generous body limit: PostToolUse hook payloads carry the tool's full output
|
|
472
|
+
// (a big Read/Bash result can blow past Express's 100kb default, which would 413
|
|
473
|
+
// the hook and leave its tool-call entry stuck on "running").
|
|
474
|
+
app.use(express.json({ limit: "25mb" }));
|
|
475
|
+
|
|
476
|
+
// Host tool: spawnBackgroundChat. Unlike a plugin (handled by mountAllRoutes'
|
|
477
|
+
// catch-all), it needs server internals — it spawns a brand-new interactive Claude
|
|
478
|
+
// terminal session, seeded with `message`, that the user can open from the sidebar.
|
|
479
|
+
// `role`/`hidden` are accepted for signature parity with MulmoClaude but ignored:
|
|
480
|
+
// the session is always visible. Registered BEFORE mountAllRoutes so this specific
|
|
481
|
+
// route wins over /api/plugin/:toolName.
|
|
482
|
+
app.post("/api/plugin/spawnBackgroundChat", (req, res) => {
|
|
483
|
+
const body = isRecord(req.body) ? req.body : {};
|
|
484
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
485
|
+
if (!message) {
|
|
486
|
+
return res.json({ message: "spawnBackgroundChat: `message` is required (non-empty string)." });
|
|
487
|
+
}
|
|
488
|
+
const sessionId = randomUUID();
|
|
489
|
+
// ws is null: the session runs headless until the user opens it (reattach
|
|
490
|
+
// replays the buffered output). The "created" pubsub event in spawnClaudePty
|
|
491
|
+
// surfaces it in the sidebar right away.
|
|
492
|
+
spawnClaudePty(sessionId, null, null, message);
|
|
493
|
+
return res.json({
|
|
494
|
+
message: `Spawned a new terminal session (chatId ${sessionId}). It runs in parallel; the user can open it from the sidebar.`,
|
|
495
|
+
jsonData: { chatId: sessionId },
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Mount each enabled GUI plugin's REST routes (e.g. POST /api/markdown,
|
|
500
|
+
// POST /api/form). The GUI MCP server dispatches tool calls to these.
|
|
501
|
+
mountAllRoutes(app);
|
|
502
|
+
|
|
503
|
+
// In-process GUI MCP server, served over Streamable HTTP. claude (wired up via
|
|
504
|
+
// mcpConfigJson) POSTs JSON-RPC here; the session id is in the URL path. We run in
|
|
505
|
+
// STATELESS mode (sessionIdGenerator: undefined): one fresh Server+transport per
|
|
506
|
+
// request, no session header / no initialize handshake required across requests.
|
|
507
|
+
// The SDK forbids reusing a stateless transport, so we never cache it.
|
|
508
|
+
const mcpReject = (_req: express.Request, res: express.Response) => res.status(405).set("Allow", "POST").json({ error: "method not allowed" });
|
|
509
|
+
app.post("/mcp/:sessionId", async (req, res) => {
|
|
510
|
+
const { sessionId } = req.params;
|
|
511
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
512
|
+
return res.status(400).json({ error: "invalid sessionId" });
|
|
513
|
+
}
|
|
514
|
+
const server = buildGuiMcpServer(sessionId, `http://127.0.0.1:${PORT}`);
|
|
515
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
516
|
+
res.on("close", () => {
|
|
517
|
+
transport.close();
|
|
518
|
+
server.close();
|
|
519
|
+
});
|
|
520
|
+
try {
|
|
521
|
+
await server.connect(transport);
|
|
522
|
+
await transport.handleRequest(req, res, req.body);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error(`[mcp] request failed for ${sessionId}:`, err);
|
|
525
|
+
if (!res.headersSent) res.status(500).json({ error: "mcp error" });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
// No SSE stream / session teardown in stateless mode — reject the rest.
|
|
529
|
+
app.get("/mcp/:sessionId", mcpReject);
|
|
530
|
+
app.delete("/mcp/:sessionId", mcpReject);
|
|
531
|
+
|
|
532
|
+
// Serve Vite build output
|
|
533
|
+
app.use(express.static(path.join(__dirname, "../dist")));
|
|
534
|
+
|
|
535
|
+
// Activity hooks update a session's working / needs-attention flags.
|
|
536
|
+
// `foreground` (a ws is attached => being viewed) suppresses the attention flag.
|
|
537
|
+
function handleActivityHook(sessionId: string, event: string, foreground: boolean) {
|
|
538
|
+
if (event === "UserPromptSubmit") {
|
|
539
|
+
setWorking(sessionId, true, event);
|
|
540
|
+
} else if (event === "Stop") {
|
|
541
|
+
// A background session that finished a turn has output the user hasn't seen
|
|
542
|
+
// yet (and is ready for another message) — flag it for attention.
|
|
543
|
+
if (!foreground) setWaiting(sessionId, true, event);
|
|
544
|
+
setWorking(sessionId, false, event);
|
|
545
|
+
} else if (event === "Notification") {
|
|
546
|
+
// Background session waiting for input (permission / question / idle).
|
|
547
|
+
if (!foreground) setWaiting(sessionId, true, event);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
interface HookToolPayload {
|
|
552
|
+
tool_use_id?: string;
|
|
553
|
+
tool_name?: string;
|
|
554
|
+
tool_input?: unknown;
|
|
555
|
+
tool_output?: unknown;
|
|
556
|
+
tool_response?: unknown;
|
|
557
|
+
duration_ms?: number;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Pre/PostToolUse hooks feed the per-session tool-call history. A failed tool
|
|
561
|
+
// fires PostToolUseFailure (NOT PostToolUse), so both complete the entry.
|
|
562
|
+
async function handleToolHook(sessionId: string, event: string, p: HookToolPayload) {
|
|
563
|
+
if (event === "PreToolUse") {
|
|
564
|
+
await recordToolCallStart(sessionId, { toolUseId: p.tool_use_id, toolName: p.tool_name, toolInput: p.tool_input });
|
|
565
|
+
} else if (event === "PostToolUse" || event === "PostToolUseFailure") {
|
|
566
|
+
await recordToolCallEnd(sessionId, {
|
|
567
|
+
toolUseId: p.tool_use_id,
|
|
568
|
+
toolName: p.tool_name,
|
|
569
|
+
toolInput: p.tool_input,
|
|
570
|
+
toolOutput: p.tool_output ?? p.tool_response,
|
|
571
|
+
durationMs: p.duration_ms,
|
|
572
|
+
status: event === "PostToolUseFailure" ? "failed" : "completed",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Claude hooks (Stop / Notification / Pre|PostToolUse) POST their payload here so
|
|
578
|
+
// we can flag which background sessions have new activity / build tool history.
|
|
579
|
+
app.post("/api/hook", async (req, res) => {
|
|
580
|
+
const body = req.body || {};
|
|
581
|
+
const sessionId = body.session_id;
|
|
582
|
+
const event = body.hook_event_name;
|
|
583
|
+
if (sessionId) {
|
|
584
|
+
const entry = ptys.get(sessionId);
|
|
585
|
+
const foreground = !!(entry && entry.ws);
|
|
586
|
+
handleActivityHook(sessionId, event, foreground);
|
|
587
|
+
await handleToolHook(sessionId, event, body);
|
|
588
|
+
console.log(`[hook] ${event} for ${sessionId}`);
|
|
589
|
+
}
|
|
590
|
+
res.json({ ok: true });
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// The GUI toolResult sink. Two callers POST here:
|
|
594
|
+
// - the MCP broker, after a plugin produces a result (data gates rendering);
|
|
595
|
+
// - the GUI panel, to persist a plugin view's state change (e.g. a submitted
|
|
596
|
+
// form's viewState) under the same uuid.
|
|
597
|
+
// We store the result keyed by session id and publish it on that session's channel
|
|
598
|
+
// so the active panel renders/updates it live. Mirrors MulmoClaude's internal
|
|
599
|
+
// toolResult route + applyToolResultToSession.
|
|
600
|
+
app.post("/api/agent/toolResult", async (req, res) => {
|
|
601
|
+
const { sessionId, toolName, uuid } = req.body || {};
|
|
602
|
+
// The session id flows from env (broker) / the client and ends up in a pub/sub
|
|
603
|
+
// channel name — keep it to the known UUID shape.
|
|
604
|
+
if (!sessionId || !SESSION_ID_RE.test(sessionId)) {
|
|
605
|
+
return res.status(400).json({ error: "invalid sessionId" });
|
|
606
|
+
}
|
|
607
|
+
if (typeof toolName !== "string" || !toolName) {
|
|
608
|
+
return res.status(400).json({ error: "invalid toolName" });
|
|
609
|
+
}
|
|
610
|
+
if (typeof uuid !== "string" || !uuid) {
|
|
611
|
+
return res.status(400).json({ error: "invalid uuid" });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Store everything except the routing fields; the result itself is the payload
|
|
615
|
+
// the panel renders.
|
|
616
|
+
// `persistOnly` (set by the GUI panel when a view persists its own state change)
|
|
617
|
+
// means: store, but do NOT re-publish on the session channel. Re-publishing would
|
|
618
|
+
// echo the update back to the originating panel as a fresh result, which re-seeds
|
|
619
|
+
// the view and re-emits — an infinite flicker loop. The broker (new tool calls)
|
|
620
|
+
// omits the flag, so its results still publish and render live.
|
|
621
|
+
const result = { ...req.body };
|
|
622
|
+
delete result.sessionId;
|
|
623
|
+
const persistOnly = result.persistOnly === true;
|
|
624
|
+
delete result.persistOnly;
|
|
625
|
+
await storeToolResult(sessionId, result);
|
|
626
|
+
|
|
627
|
+
if (!persistOnly) {
|
|
628
|
+
pubsub?.publish(sessionChannel(sessionId), result);
|
|
629
|
+
console.log(`[gui] toolResult ${toolName} for ${sessionId}`);
|
|
630
|
+
}
|
|
631
|
+
res.json({ ok: true });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// Replay a session's stored toolResults so the panel can render them when the
|
|
635
|
+
// user (re)selects that session. Loads from disk (<workspace>/.toolresults) on
|
|
636
|
+
// first access so the views survive a reboot.
|
|
637
|
+
app.get("/api/agent/toolResults/:sessionId", async (req, res) => {
|
|
638
|
+
const { sessionId } = req.params;
|
|
639
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
640
|
+
return res.status(400).json({ error: "invalid sessionId" });
|
|
641
|
+
}
|
|
642
|
+
res.json({ sessionId, toolResults: await toolResultsStore.get(sessionId) });
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// The GUI plugin tools available this session (for the tools pane's "Available
|
|
646
|
+
// Tools" list). The full set claude can call — built-ins, other MCP — is not
|
|
647
|
+
// enumerable server-side; those still show up in the tool-call history below.
|
|
648
|
+
app.get("/api/tools", (_req, res) => {
|
|
649
|
+
res.json({ tools: toolSummaries });
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Replay a session's tool-call history (every tool, via the Pre/PostToolUse hooks)
|
|
653
|
+
// so the tools pane can render it when the user (re)selects that session. Loads
|
|
654
|
+
// from disk (<workspace>/.toolcalls) on first access so it survives a reboot.
|
|
655
|
+
app.get("/api/tool-calls/:sessionId", async (req, res) => {
|
|
656
|
+
const { sessionId } = req.params;
|
|
657
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
658
|
+
return res.status(400).json({ error: "invalid sessionId" });
|
|
659
|
+
}
|
|
660
|
+
res.json({ sessionId, toolCalls: await toolCallsStore.get(sessionId) });
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// List the chat sessions for the current project (CLAUDE_CWD), including
|
|
664
|
+
// newly-created sessions that aren't persisted to disk yet.
|
|
665
|
+
app.get("/api/sessions", async (_req, res) => {
|
|
666
|
+
try {
|
|
667
|
+
const dir = projectSessionsDir(CLAUDE_CWD);
|
|
668
|
+
let files: string[] = [];
|
|
669
|
+
try {
|
|
670
|
+
files = (await fs.readdir(dir)).filter((f) => f.endsWith(".jsonl"));
|
|
671
|
+
} catch (err) {
|
|
672
|
+
if (!hasErrnoCode(err) || err.code !== "ENOENT") throw err;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Cheap pass: stat (don't read) every file just for its mtime, so we can
|
|
676
|
+
// rank by recency. Skip any that vanished between readdir and stat.
|
|
677
|
+
const onDiskStats = (
|
|
678
|
+
await Promise.all(
|
|
679
|
+
files.map(async (file): Promise<DiskStat | null> => {
|
|
680
|
+
try {
|
|
681
|
+
const st = await fs.stat(path.join(dir, file));
|
|
682
|
+
return { kind: "disk", id: path.basename(file, ".jsonl"), file, mtime: st.mtimeMs };
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}),
|
|
687
|
+
)
|
|
688
|
+
).filter((s): s is DiskStat => s !== null);
|
|
689
|
+
const onDisk = new Set(onDiskStats.map((s) => s.id));
|
|
690
|
+
|
|
691
|
+
// In-memory sessions not yet written to disk. Prune any that have since
|
|
692
|
+
// been persisted — the on-disk record (with its real title) wins.
|
|
693
|
+
const pending: PendingSession[] = [];
|
|
694
|
+
for (const [id, meta] of knownSessions) {
|
|
695
|
+
if (onDisk.has(id)) {
|
|
696
|
+
knownSessions.delete(id);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
pending.push({
|
|
700
|
+
kind: "pending",
|
|
701
|
+
id,
|
|
702
|
+
title: meta.title,
|
|
703
|
+
mtime: meta.createdAt,
|
|
704
|
+
working: activity.get(id)?.working ?? false,
|
|
705
|
+
waiting: activity.get(id)?.waiting ?? false,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Keep only the most-recent N, then read & parse contents for just those
|
|
710
|
+
// on-disk files (a deleted/corrupt file is dropped, not fatal).
|
|
711
|
+
const top = [...onDiskStats, ...pending].sort((a, b) => b.mtime - a.mtime).slice(0, SESSION_LIST_LIMIT);
|
|
712
|
+
const sessions = (
|
|
713
|
+
await Promise.all(
|
|
714
|
+
top.map((s) =>
|
|
715
|
+
s.kind === "pending"
|
|
716
|
+
? { id: s.id, title: s.title, mtime: s.mtime, working: s.working, waiting: s.waiting }
|
|
717
|
+
: readSessionMeta(dir, s.file).catch(() => null),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
.filter((s): s is SessionMeta => s !== null)
|
|
722
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
723
|
+
|
|
724
|
+
res.json({ cwd: CLAUDE_CWD, sessions });
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.error("[api] /api/sessions failed:", err);
|
|
727
|
+
res.status(500).json({ error: String(err) });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const server = http.createServer(app);
|
|
732
|
+
pubsub = createPubSub(server, isAllowedOrigin);
|
|
733
|
+
|
|
734
|
+
// Give the markdown host app its workspace (for artifacts/documents storage) +
|
|
735
|
+
// pubsub (to forward file-change events to the plugin-scoped channel so the
|
|
736
|
+
// presentDocument view live-refreshes). Done after pubsub exists (task #6).
|
|
737
|
+
initMarkdownBackend({ workspace: CLAUDE_CWD, pubsub });
|
|
738
|
+
|
|
739
|
+
// Terminal WebSocket. Uses noServer + manual upgrade routing so it shares the
|
|
740
|
+
// HTTP server with socket.io (the pub/sub at /ws/pubsub) without the two
|
|
741
|
+
// libraries fighting over the "upgrade" event.
|
|
742
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
743
|
+
server.on("upgrade", (req, socket, head) => {
|
|
744
|
+
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
|
745
|
+
if (pathname === "/ws") {
|
|
746
|
+
if (!isAllowedOrigin(req.headers.origin)) {
|
|
747
|
+
console.warn(`[ws] rejected cross-origin upgrade from ${req.headers.origin}`);
|
|
748
|
+
socket.destroy();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
752
|
+
}
|
|
753
|
+
// Other paths (e.g. /ws/pubsub) are left to socket.io's own upgrade handler.
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Reattach a live background PTY to a new socket: drop any stale socket, swap in
|
|
757
|
+
// the new one, and replay the buffered tail for context.
|
|
758
|
+
function reattachPty(entry: PtyEntry, ws: WebSocket, sessionId: string): PtyEntry {
|
|
759
|
+
console.log(`[ws] reattach ${sessionId} (pid=${entry.term.pid})`);
|
|
760
|
+
// Drop any socket still attached (e.g. the same session open in another tab)
|
|
761
|
+
// so it can't keep writing to this PTY.
|
|
762
|
+
if (entry.ws && entry.ws !== ws && entry.ws.readyState === entry.ws.OPEN) {
|
|
763
|
+
entry.ws.close();
|
|
764
|
+
}
|
|
765
|
+
entry.ws = ws;
|
|
766
|
+
if (entry.buffer && ws.readyState === ws.OPEN) {
|
|
767
|
+
ws.send(JSON.stringify({ type: "output", data: entry.buffer }));
|
|
768
|
+
}
|
|
769
|
+
return entry;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Spawn a fresh claude PTY for this session, register it, and wire its output /
|
|
773
|
+
// exit back to the browser socket. `ws` may be null for a session spawned without
|
|
774
|
+
// a viewer yet (e.g. spawnBackgroundChat) — output just buffers until a client
|
|
775
|
+
// reattaches. `initialPrompt`, when given, is passed to claude as the first turn
|
|
776
|
+
// so the session starts working immediately, before anyone opens it.
|
|
777
|
+
function spawnClaudePty(sessionId: string, resume: string | null, ws: WebSocket | null, initialPrompt?: string): PtyEntry {
|
|
778
|
+
const settings = hookSettingsJson();
|
|
779
|
+
// Register the GUI MCP broker and auto-allow its tools so the plugin tools run
|
|
780
|
+
// without a permission prompt (--strict-mcp-config keeps the user's other MCP
|
|
781
|
+
// servers out of the spike).
|
|
782
|
+
const mcp = mcpConfigJson(sessionId);
|
|
783
|
+
const guiArgs = ["--permission-mode", CLAUDE_PERMISSION_MODE, "--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", GUI_MCP_TOOLS];
|
|
784
|
+
const baseArgs = resume ? ["--resume", resume, "--settings", settings, ...guiArgs] : ["--session-id", sessionId, "--settings", settings, ...guiArgs];
|
|
785
|
+
// The initial prompt goes last as a positional arg (claude's initial-prompt
|
|
786
|
+
// form). "--" ends option parsing so a prompt that happens to start with "-"
|
|
787
|
+
// can't be reinterpreted as a flag.
|
|
788
|
+
const args = initialPrompt ? [...baseArgs, "--", initialPrompt] : baseArgs;
|
|
789
|
+
|
|
790
|
+
console.log(`[ws] client connected (${resume ? "resume" : "new"} ${sessionId})`);
|
|
791
|
+
|
|
792
|
+
const term = pty.spawn(CLAUDE_BIN, args, {
|
|
793
|
+
name: "xterm-256color",
|
|
794
|
+
cols: 120,
|
|
795
|
+
rows: 30,
|
|
796
|
+
cwd: CLAUDE_CWD,
|
|
797
|
+
env: process.env,
|
|
798
|
+
});
|
|
799
|
+
console.log(`[pty] spawned claude (pid=${term.pid})`);
|
|
800
|
+
|
|
801
|
+
const entry: PtyEntry = { term, ws, buffer: "" };
|
|
802
|
+
ptys.set(sessionId, entry);
|
|
803
|
+
|
|
804
|
+
if (!resume) {
|
|
805
|
+
// Brand-new session: surface it in the sidebar before it's persisted. A
|
|
806
|
+
// spawned session (initialPrompt) gets a title from its first message so it's
|
|
807
|
+
// recognizable in the sidebar before anyone opens it.
|
|
808
|
+
const title = initialPrompt ? initialPrompt.replace(/\s+/g, " ").trim().slice(0, 60) || "New session" : "New session";
|
|
809
|
+
knownSessions.set(sessionId, { createdAt: Date.now(), title });
|
|
810
|
+
pubsub?.publish(SESSIONS_CHANNEL, { id: sessionId, working: false, event: "created" });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// PTY -> browser (buffering a bounded tail for reattach).
|
|
814
|
+
term.onData((data) => {
|
|
815
|
+
entry.buffer = (entry.buffer + data).slice(-OUTPUT_BUFFER_LIMIT);
|
|
816
|
+
if (entry.ws && entry.ws.readyState === entry.ws.OPEN) {
|
|
817
|
+
entry.ws.send(JSON.stringify({ type: "output", data }));
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
term.onExit(({ exitCode, signal }) => {
|
|
822
|
+
console.log(`[pty] exited code=${exitCode} signal=${signal}`);
|
|
823
|
+
if (entry.ws && entry.ws.readyState === entry.ws.OPEN) {
|
|
824
|
+
entry.ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
|
|
825
|
+
entry.ws.close();
|
|
826
|
+
}
|
|
827
|
+
// Clear the dot if it died mid-turn, then tear down everything (deletes
|
|
828
|
+
// ptys/knownSessions/activity and publishes "closed") so a process that
|
|
829
|
+
// exits on its own — e.g. a brand-new session that never persisted —
|
|
830
|
+
// doesn't linger in the sidebar.
|
|
831
|
+
setWorking(sessionId, false);
|
|
832
|
+
reap(sessionId);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
return entry;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// browser -> PTY. The protocol is client-controlled, so validate every frame
|
|
839
|
+
// before touching node-pty (bad cols/rows or non-string input can throw).
|
|
840
|
+
function handleClientFrame(entry: PtyEntry, ws: WebSocket, raw: RawData, sessionId: string) {
|
|
841
|
+
// Ignore frames from a socket that a newer client has already superseded.
|
|
842
|
+
if (entry.ws !== ws) return;
|
|
843
|
+
let msg;
|
|
844
|
+
try {
|
|
845
|
+
msg = JSON.parse(raw.toString());
|
|
846
|
+
} catch {
|
|
847
|
+
return; // not JSON — never write arbitrary payloads to the PTY
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
if (msg.type === "input" && typeof msg.data === "string") {
|
|
851
|
+
entry.term.write(msg.data);
|
|
852
|
+
} else if (
|
|
853
|
+
msg.type === "resize" &&
|
|
854
|
+
Number.isInteger(msg.cols) &&
|
|
855
|
+
Number.isInteger(msg.rows) &&
|
|
856
|
+
msg.cols >= 2 &&
|
|
857
|
+
msg.cols <= 500 &&
|
|
858
|
+
msg.rows >= 1 &&
|
|
859
|
+
msg.rows <= 200
|
|
860
|
+
) {
|
|
861
|
+
entry.term.resize(msg.cols, msg.rows);
|
|
862
|
+
}
|
|
863
|
+
} catch (err) {
|
|
864
|
+
// e.g. a write/resize that races the PTY exiting — drop it, never crash.
|
|
865
|
+
console.warn(`[ws] dropped message for ${sessionId}: ${messageOf(err)}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Socket closed: detach it; keep the PTY alive if Claude is mid-turn (reaped on
|
|
870
|
+
// the Stop hook), otherwise reap now.
|
|
871
|
+
function handleClientClose(entry: PtyEntry, ws: WebSocket, sessionId: string) {
|
|
872
|
+
// Ignore if a newer client already reattached to this session.
|
|
873
|
+
if (entry.ws !== ws) return;
|
|
874
|
+
entry.ws = null;
|
|
875
|
+
if (activity.get(sessionId)?.working) {
|
|
876
|
+
console.log(`[ws] disconnected; keeping working session ${sessionId} alive`);
|
|
877
|
+
} else {
|
|
878
|
+
console.log(`[ws] disconnected; killing idle session ${sessionId}`);
|
|
879
|
+
reap(sessionId);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
wss.on("connection", (ws, req) => {
|
|
884
|
+
// ?session=<id> resumes an existing conversation; absent => fresh session. For
|
|
885
|
+
// new sessions we generate the id ourselves (--session-id) so the server always
|
|
886
|
+
// knows the current session's id, even before any file exists.
|
|
887
|
+
const resume = new URL(req.url ?? "/", "http://localhost").searchParams.get("session");
|
|
888
|
+
if (resume && !SESSION_ID_RE.test(resume)) {
|
|
889
|
+
console.warn(`[ws] rejecting non-UUID session id: ${JSON.stringify(resume)}`);
|
|
890
|
+
ws.close();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const sessionId = resume || randomUUID();
|
|
894
|
+
|
|
895
|
+
// Tell the browser which session this is (it learns the id of new sessions).
|
|
896
|
+
ws.send(JSON.stringify({ type: "session", id: sessionId }));
|
|
897
|
+
|
|
898
|
+
const existing = ptys.get(sessionId);
|
|
899
|
+
const entry = existing ? reattachPty(existing, ws, sessionId) : spawnClaudePty(sessionId, resume, ws);
|
|
900
|
+
|
|
901
|
+
// The session is now in the foreground (being viewed): clear any
|
|
902
|
+
// "waiting for input" flag so it stops showing as bold.
|
|
903
|
+
setWaiting(sessionId, false);
|
|
904
|
+
|
|
905
|
+
ws.on("message", (raw) => handleClientFrame(entry, ws, raw, sessionId));
|
|
906
|
+
ws.on("close", () => handleClientClose(entry, ws, sessionId));
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
server.listen(PORT, () => {
|
|
910
|
+
console.log(`mulmoterminal running at http://localhost:${PORT}`);
|
|
911
|
+
});
|