mdinterface 0.1.1

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/server.js ADDED
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env node
2
+ /* mdinterface — rendered markdown canvas + live Claude Code session.
3
+ *
4
+ * Usage: node server.js <file.md> [--port 7777] [--cmd claude]
5
+ *
6
+ * Three one-way arrows:
7
+ * canvas selection ──keystrokes──▶ Claude Code PTY
8
+ * Claude Code ──edits──────▶ the file on disk
9
+ * file watcher ──content────▶ canvas re-render
10
+ */
11
+
12
+ const express = require("express");
13
+ const http = require("node:http");
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+ const os = require("node:os");
17
+ const crypto = require("node:crypto");
18
+ const { WebSocketServer } = require("ws");
19
+ const pty = require("node-pty");
20
+
21
+ // ---------- args ----------
22
+ const args = process.argv.slice(2);
23
+ const fileArg = args.find((a) => !a.startsWith("--"));
24
+ if (!fileArg) {
25
+ console.error("Usage: mdinterface <file.md> [--port 7777] [--cmd claude]");
26
+ process.exit(1);
27
+ }
28
+ let DOC = path.resolve(fileArg); // reassignable: the toolbar file picker can switch docs
29
+ if (!fs.existsSync(DOC)) {
30
+ console.error(`File not found: ${DOC}`);
31
+ process.exit(1);
32
+ }
33
+ if (fs.statSync(DOC).isDirectory()) {
34
+ console.error(
35
+ `${DOC} is a directory — point me at a markdown file, e.g.:\n node server.js ${path.join(fileArg, "doc.md")}`
36
+ );
37
+ process.exit(1);
38
+ }
39
+ const flag = (name, dflt) => {
40
+ const i = args.indexOf(`--${name}`);
41
+ return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
42
+ };
43
+ const PORT = parseInt(flag("port", "7777"), 10);
44
+ const CLAUDE_CMD = flag("cmd", process.env.MDINTERFACE_CMD || "claude");
45
+
46
+ // ---------- access control ----------
47
+ // mdinterface drives a live shell/Claude PTY, so the server must NOT be reachable by other
48
+ // machines or by random web pages. Three layers:
49
+ // 1) bind to loopback only (see server.listen) — no LAN exposure;
50
+ // 2) a per-launch secret token in the URL, required by /doc and the WebSocket — blocks
51
+ // other local processes/pages that don't have the token;
52
+ // 3) Origin + Host validation on the WebSocket — blocks a malicious site you visit from
53
+ // driving the PTY (WebSockets bypass same-origin) and blocks DNS-rebinding.
54
+ const TOKEN = crypto.randomBytes(16).toString("hex");
55
+ const { wsAllowed } = require("./access")(PORT, TOKEN);
56
+
57
+ // ---------- web server ----------
58
+ const app = express();
59
+ // Never send the per-launch token (it rides in the URL's ?t=) to anywhere via Referer —
60
+ // a link in a rendered doc must not leak it. Applies to every response below.
61
+ app.use((_req, res, next) => {
62
+ res.setHeader("Referrer-Policy", "no-referrer");
63
+ next();
64
+ });
65
+ app.use(express.static(path.join(__dirname, "public")));
66
+ app.get("/doc", (req, res) => {
67
+ if (req.query.t !== TOKEN) return res.status(403).end();
68
+ // path is returned (for the file picker) but only to a token-holding client.
69
+ res.json({ name: path.basename(DOC), path: DOC, content: read() });
70
+ });
71
+ // Directory listing for the file browser: folders (to navigate) + markdown files. Token
72
+ // gated. Lists any dir the user can read — same reach the PTY already has.
73
+ app.get("/ls", (req, res) => {
74
+ if (req.query.t !== TOKEN) return res.status(403).end();
75
+ const dir =
76
+ typeof req.query.dir === "string" && req.query.dir
77
+ ? path.resolve(req.query.dir)
78
+ : path.dirname(DOC);
79
+ let ents;
80
+ try {
81
+ ents = fs.readdirSync(dir, { withFileTypes: true });
82
+ } catch (e) {
83
+ return res.status(400).json({ error: e.code === "EACCES" ? "Permission denied" : e.message });
84
+ }
85
+ const entries = [];
86
+ for (const e of ents) {
87
+ if (e.name.startsWith(".")) continue; // skip dotfiles
88
+ const full = path.join(dir, e.name);
89
+ let isDir = e.isDirectory();
90
+ if (e.isSymbolicLink()) {
91
+ try {
92
+ isDir = fs.statSync(full).isDirectory();
93
+ } catch {
94
+ continue;
95
+ }
96
+ }
97
+ if (isDir) entries.push({ name: e.name, path: full, isDir: true });
98
+ else if (/\.(md|markdown)$/i.test(e.name))
99
+ entries.push({ name: e.name, path: full, isDir: false });
100
+ }
101
+ entries.sort((a, b) => (a.isDir !== b.isDir ? (a.isDir ? -1 : 1) : a.name.localeCompare(b.name)));
102
+ const parent = path.dirname(dir);
103
+ res.json({ dir, parent: parent === dir ? null : parent, current: DOC, entries });
104
+ });
105
+ // Switch the canvas to a different document — lets Claude (via the canvas_open MCP tool)
106
+ // open a file it just created, e.g. one pulled from Notion. Same path → same openDoc().
107
+ app.post("/open", express.json({ limit: "64kb" }), (req, res) => {
108
+ if (req.query.t !== TOKEN) return res.status(403).end();
109
+ res.json(openDoc(req.body?.path));
110
+ });
111
+ const server = http.createServer(app);
112
+
113
+ function read() {
114
+ try {
115
+ return fs.readFileSync(DOC, "utf8");
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
120
+
121
+ // ---------- selection mirror: the browser's selection, written to disk ----------
122
+ // The canvas never types into Claude. Instead the current selection is mirrored to a
123
+ // file, and a UserPromptSubmit hook injects that file as context on the user's next
124
+ // message — so Claude is ambiently aware of what's selected, with zero prompt noise.
125
+ const CLAUDE_DIR = path.join(path.dirname(DOC), ".claude");
126
+ const SEL_FILE = path.join(CLAUDE_DIR, "mdinterface-selection.txt");
127
+
128
+ // ---------- runtime file: how the separate MCP process reaches this server ----------
129
+ // canvas_edit writes the open document directly; canvas_open POSTs back to this server. The
130
+ // MCP process reads port/token/doc from this file before each call, so edits follow the
131
+ // toolbar file picker. Edits always apply immediately — undo is the Undo button (or git).
132
+ const RUNTIME_FILE = path.join(CLAUDE_DIR, "mdinterface-runtime.json");
133
+ function writeRuntime() {
134
+ try {
135
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
136
+ // Owner-only: this file holds the session token, so keep it unreadable by other users.
137
+ // `doc` is the currently-open document — canvas_edit reads it so it follows file switches.
138
+ fs.writeFileSync(RUNTIME_FILE, JSON.stringify({ port: PORT, token: TOKEN, doc: DOC }), {
139
+ mode: 0o600,
140
+ });
141
+ try {
142
+ fs.chmodSync(RUNTIME_FILE, 0o600);
143
+ } catch {} // tighten even if the file pre-existed
144
+ } catch {}
145
+ }
146
+
147
+ function lineRange(blocks, content) {
148
+ let start = Infinity,
149
+ end = -Infinity;
150
+ for (const raw of blocks) {
151
+ const idx = content.indexOf(raw);
152
+ if (idx === -1) continue;
153
+ const s = content.slice(0, idx).split("\n").length; // 1-based start line
154
+ const e = s + raw.replace(/\n+$/, "").split("\n").length - 1;
155
+ start = Math.min(start, s);
156
+ end = Math.max(end, e);
157
+ }
158
+ return start <= end ? [start, end] : null;
159
+ }
160
+
161
+ function writeSelection(text, blocks) {
162
+ try {
163
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
164
+ if (!text) {
165
+ // Explicitly announce "nothing selected" rather than staying silent, so a cleared
166
+ // selection actively cancels any stale CANVAS SELECTION blocks left in history.
167
+ fs.writeFileSync(
168
+ SEL_FILE,
169
+ `===== CANVAS SELECTION: NONE (as of this message) =====\n` +
170
+ `No text is currently selected in the canvas. Any CANVAS SELECTION block shown ` +
171
+ `earlier in this conversation is STALE — do not act on it.\n`
172
+ );
173
+ return;
174
+ }
175
+ const rng = lineRange(blocks || [], read());
176
+ const where = rng
177
+ ? rng[0] === rng[1]
178
+ ? `line ${rng[0]}`
179
+ : `lines ${rng[0]}-${rng[1]}`
180
+ : "an unknown location";
181
+ // Lead with the passage inside hard delimiters so it can't be skimmed past; the live
182
+ // block supersedes every earlier one. Injected verbatim before each message.
183
+ const body =
184
+ `===== CURRENT CANVAS SELECTION (${path.basename(DOC)}, ${where}) =====\n` +
185
+ `${text}\n` +
186
+ `===== END CANVAS SELECTION =====\n` +
187
+ `^ This is the user's selection AS OF THIS MESSAGE. It supersedes any CANVAS ` +
188
+ `SELECTION block shown earlier in the conversation — a changed selection means the ` +
189
+ `user has moved on, so ignore the older ones unless the message is very clearly ` +
190
+ `about earlier discussion. When they say "this", "here", "the selection", or ` +
191
+ `similar, THIS passage is the referent — use it directly.\n`;
192
+ fs.writeFileSync(SEL_FILE, body);
193
+ } catch {}
194
+ }
195
+
196
+ // Apply a direct edit from the canvas: replace the nth occurrence of a block's raw
197
+ // markdown with the user's edited version, then write the file. The file watcher
198
+ // broadcasts the new content, so every canvas (including the editor's) re-renders.
199
+ function applyDirectEdit(oldRaw, newRaw, nth) {
200
+ if (typeof oldRaw !== "string" || typeof newRaw !== "string" || !oldRaw) return;
201
+ const content = read();
202
+ let idx = -1,
203
+ from = 0;
204
+ for (let i = 0; i <= (nth | 0); i++) {
205
+ idx = content.indexOf(oldRaw, from);
206
+ if (idx === -1) return; // block no longer present (file changed underneath) — ignore
207
+ from = idx + oldRaw.length;
208
+ }
209
+ const updated = content.slice(0, idx) + newRaw + content.slice(idx + oldRaw.length);
210
+ if (updated !== content) {
211
+ try {
212
+ fs.writeFileSync(DOC, updated);
213
+ } catch {}
214
+ }
215
+ }
216
+
217
+ // Install the hooks that feed Claude (idempotent, non-destructive merge into
218
+ // settings.local.json, which reloads live):
219
+ // SessionStart → cat the whole doc, so the full document is in context from the
220
+ // start of every session (and on resume / clear / compact).
221
+ // UserPromptSubmit → cat the selection mirror, so the current selection rides along
222
+ // with each message — read instantly from disk, no round-trip.
223
+ const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
224
+
225
+ function ensureHook(settings, event, command) {
226
+ settings.hooks = settings.hooks || {};
227
+ settings.hooks[event] = settings.hooks[event] || [];
228
+ const arr = settings.hooks[event];
229
+ if (arr.some((g) => (g.hooks || []).some((h) => h.command === command))) return false;
230
+ arr.push({ matcher: "", hooks: [{ type: "command", command }] });
231
+ return true;
232
+ }
233
+
234
+ function installHooks() {
235
+ const settingsPath = path.join(CLAUDE_DIR, "settings.local.json");
236
+ const selCmd = `cat ${shQuote(SEL_FILE)} 2>/dev/null`;
237
+ // The preamble also carries the behavioral directive so it travels with ANY document
238
+ // (not just one that happens to have a CLAUDE.md): edit via canvas_edit, and stay terse.
239
+ const docCmd =
240
+ `printf '[mdinterface] You are in a mdinterface session: %s is shown in a live canvas the ` +
241
+ `user reads, selects in, and edits. To CHANGE the document, use the ` +
242
+ `mcp__mdinterface__canvas_edit tool, NOT the built-in Edit/Write — it needs no prior Read and ` +
243
+ `the canvas re-renders instantly. After an edit, reply in one short line or not at all. ` +
244
+ `Full on-disk contents of %s as of session start:\\n\\n' ${shQuote(path.basename(DOC))} ${shQuote(path.basename(DOC))} && cat ${shQuote(DOC)}`;
245
+ try {
246
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
247
+ let settings = {};
248
+ if (fs.existsSync(settingsPath)) {
249
+ try {
250
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")) || {};
251
+ } catch {}
252
+ }
253
+ // Drop any prior mdinterface-managed hooks first, so switching documents REPLACES them
254
+ // rather than accumulating a SessionStart `cat` per doc ever opened.
255
+ const notMine = (event, marker) => {
256
+ if (!settings.hooks || !Array.isArray(settings.hooks[event])) return;
257
+ settings.hooks[event] = settings.hooks[event].filter(
258
+ (g) =>
259
+ !(g.hooks || []).some((h) => typeof h.command === "string" && h.command.includes(marker))
260
+ );
261
+ };
262
+ notMine("SessionStart", "[mdinterface]");
263
+ notMine("UserPromptSubmit", "mdinterface-selection.txt");
264
+ // Migration: strip hooks from the old "mdcanvas" and "line0" names so upgrading doesn't
265
+ // leave duplicates.
266
+ notMine("SessionStart", "[mdcanvas]");
267
+ notMine("UserPromptSubmit", "mdcanvas-selection.txt");
268
+ notMine("SessionStart", "[line0]");
269
+ notMine("UserPromptSubmit", "line0-selection.txt");
270
+ let changed = true; // we always rewrite (hooks were just normalized)
271
+ ensureHook(settings, "SessionStart", docCmd);
272
+ ensureHook(settings, "UserPromptSubmit", selCmd);
273
+ // Pre-approve the mdinterface MCP server + its tools so they load without prompts.
274
+ if (!Array.isArray(settings.enabledMcpjsonServers)) settings.enabledMcpjsonServers = [];
275
+ // Migration: drop the old "mdcanvas"/"line0" server names + their blanket grants if present.
276
+ settings.enabledMcpjsonServers = settings.enabledMcpjsonServers.filter(
277
+ (s) => s !== "mdcanvas" && s !== "line0"
278
+ );
279
+ if (!settings.enabledMcpjsonServers.includes("mdinterface")) {
280
+ settings.enabledMcpjsonServers.push("mdinterface");
281
+ changed = true;
282
+ }
283
+ settings.permissions = settings.permissions || {};
284
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
285
+ settings.permissions.allow = settings.permissions.allow.filter(
286
+ (p) => p !== "mcp__mdcanvas__*" && p !== "mcp__line0__*"
287
+ );
288
+ if (!settings.permissions.allow.includes("mcp__mdinterface__*")) {
289
+ settings.permissions.allow.push("mcp__mdinterface__*");
290
+ changed = true;
291
+ }
292
+ if (changed) fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
293
+ } catch (e) {
294
+ console.warn(
295
+ `Could not install hooks (${e.message}). The selection is still written to\n ${SEL_FILE}\n` +
296
+ `— add a UserPromptSubmit hook running \`${selCmd}\` and a SessionStart hook running \`cat <doc>\`.`
297
+ );
298
+ }
299
+ }
300
+
301
+ // Register the canvas_edit MCP server in .mcp.json (project root = the doc's folder),
302
+ // non-destructively. Claude spawns it at session start; it writes the doc directly so
303
+ // Claude can edit without the built-in Edit tool's mandatory Read. Needs a session
304
+ // restart to take effect (.mcp.json is read at startup, not live).
305
+ function installMcpServer() {
306
+ const mcpPath = path.join(path.dirname(DOC), ".mcp.json");
307
+ const entry = {
308
+ type: "stdio",
309
+ command: process.execPath, // same node that runs mdinterface — avoids PATH surprises
310
+ args: [path.join(__dirname, "mcp-server.js"), DOC],
311
+ };
312
+ try {
313
+ let cfg = {};
314
+ if (fs.existsSync(mcpPath)) {
315
+ try {
316
+ cfg = JSON.parse(fs.readFileSync(mcpPath, "utf8")) || {};
317
+ } catch {}
318
+ }
319
+ cfg.mcpServers = cfg.mcpServers || {};
320
+ // Migration: remove the server under the old "mdcanvas"/"line0" names if still registered.
321
+ let migrated = false;
322
+ for (const old of ["mdcanvas", "line0"]) {
323
+ if (cfg.mcpServers[old]) {
324
+ delete cfg.mcpServers[old];
325
+ migrated = true;
326
+ }
327
+ }
328
+ const cur = cfg.mcpServers.mdinterface;
329
+ const same =
330
+ cur &&
331
+ cur.command === entry.command &&
332
+ JSON.stringify(cur.args) === JSON.stringify(entry.args);
333
+ if (!same || migrated) {
334
+ cfg.mcpServers.mdinterface = entry;
335
+ fs.writeFileSync(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`);
336
+ }
337
+ } catch (e) {
338
+ console.warn(
339
+ `Could not register the canvas_edit MCP server (${e.message}). Built-in Edit still works.`
340
+ );
341
+ }
342
+ }
343
+
344
+ installHooks();
345
+ installMcpServer();
346
+ writeRuntime(); // mode + callback info for the MCP server
347
+ writeSelection("", []); // start clean
348
+
349
+ for (const sig of ["exit", "SIGINT", "SIGTERM"]) {
350
+ process.on(sig, () => {
351
+ try {
352
+ fs.writeFileSync(SEL_FILE, "");
353
+ } catch {}
354
+ try {
355
+ fs.unlinkSync(RUNTIME_FILE);
356
+ } catch {} // gone → MCP server defaults back to auto
357
+ if (sig !== "exit") process.exit(0);
358
+ });
359
+ }
360
+
361
+ // ---------- one shared PTY running Claude Code ----------
362
+ let shell;
363
+ // Recent terminal output, replayed to each newly connected client so a page reload
364
+ // reconstructs the current screen (Claude's TUI is otherwise idle and won't repaint).
365
+ const TERM_BUFFER_MAX = 256 * 1024;
366
+ let termBuffer = "";
367
+ let ptyCols = 100,
368
+ ptyRows = 32; // last known size, for the repaint nudge
369
+ // Force the TUI to redraw the current frame cleanly: a momentary size change (rows-1 →
370
+ // rows) triggers a SIGWINCH-driven repaint even when the real size is unchanged. Used to
371
+ // un-stick a desynced terminal (after reconnect, refocus, or a settled resize).
372
+ function repaintPty() {
373
+ if (!shell) return;
374
+ try {
375
+ shell.resize(ptyCols, Math.max(1, ptyRows - 1));
376
+ shell.resize(ptyCols, ptyRows);
377
+ } catch {}
378
+ }
379
+ // Coalesce PTY output: Claude's TUI emits many tiny writes per frame. Batching them
380
+ // into one message every few ms collapses hundreds of WebSocket messages/sec into a
381
+ // handful, which is the difference between a laggy and a snappy terminal.
382
+ //
383
+ // Leading-edge: the first chunk after an idle gap is sent IMMEDIATELY (zero added latency,
384
+ // so keystroke echo and the start of a response feel instant), then anything arriving
385
+ // within the next few ms is coalesced into one trailing flush. Best of both.
386
+ const TERM_FLUSH_MS = 8;
387
+ let pending = "";
388
+ let flushTimer = null;
389
+ function flushTerm() {
390
+ if (!pending) return;
391
+ const data = pending;
392
+ pending = "";
393
+ broadcast({ type: "term", data });
394
+ }
395
+ function onPtyData(d) {
396
+ termBuffer += d;
397
+ // Trim only once we've grown a full buffer past the cap, not on every write — under heavy
398
+ // output that turns a 256KB re-allocation per flush into one per ~256KB of throughput.
399
+ if (termBuffer.length > TERM_BUFFER_MAX * 2) termBuffer = termBuffer.slice(-TERM_BUFFER_MAX);
400
+ pending += d;
401
+ if (flushTimer) return; // inside a batch window — accumulate; the timer flushes it
402
+ flushTerm(); // leading edge: emit the first chunk with no delay
403
+ flushTimer = setTimeout(() => {
404
+ flushTimer = null;
405
+ flushTerm();
406
+ }, TERM_FLUSH_MS);
407
+ }
408
+ function spawnPty(cols = 100, rows = 32) {
409
+ const env = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
410
+ const cwd = path.dirname(DOC);
411
+ const sh = process.env.SHELL || (os.platform() === "win32" ? "powershell.exe" : "/bin/bash");
412
+ const opts = { name: "xterm-256color", cols, rows, cwd, env };
413
+ // Launch through the user's interactive login shell so PATH, rc files,
414
+ // and aliases apply — this is how `claude` is normally found.
415
+ try {
416
+ return pty.spawn(sh, ["-ilc", CLAUDE_CMD], opts);
417
+ } catch (e) {
418
+ console.warn(
419
+ `Could not start "${CLAUDE_CMD}" via ${sh} (${e.message}); opening a plain shell.`
420
+ );
421
+ }
422
+ try {
423
+ return pty.spawn(sh, [], opts);
424
+ } catch (e) {
425
+ console.error(
426
+ `PTY could not start at all (${e.message}).\n` +
427
+ `node-pty's prebuilt spawn-helper may have lost its executable bit. Restore it:\n` +
428
+ ` chmod +x node_modules/node-pty/prebuilds/*/spawn-helper\n` +
429
+ `If there's no prebuilt binary for your platform, build it instead:\n` +
430
+ ` npm rebuild node-pty`
431
+ );
432
+ return null;
433
+ }
434
+ }
435
+
436
+ // Start the PTY and wire BOTH its data and exit handlers (the earlier version reattached
437
+ // only onData on restart, so auto-restart silently worked just once). On exit we restart —
438
+ // but a command that dies immediately (claude missing, an instant crash, or the user typing
439
+ // `exit`) would otherwise respawn in a tight loop, so we count rapid exits, back off, and
440
+ // stop after a few with a clear message instead of spinning the CPU.
441
+ const RAPID_EXIT_MS = 1000;
442
+ const MAX_RAPID_EXITS = 5;
443
+ let shellSpawnAt = 0;
444
+ let rapidExits = 0;
445
+ function startShell() {
446
+ shell = spawnPty(ptyCols, ptyRows); // respawn at the current size, not the 100x32 default
447
+ if (!shell) {
448
+ broadcast({ type: "term", data: "Terminal unavailable — see server console for the fix.\r\n" });
449
+ return;
450
+ }
451
+ shellSpawnAt = Date.now();
452
+ shell.onData(onPtyData);
453
+ shell.onExit(({ exitCode }) => {
454
+ termBuffer = "";
455
+ pending = ""; // new session — drop the old screen
456
+ if (flushTimer) {
457
+ clearTimeout(flushTimer);
458
+ flushTimer = null;
459
+ }
460
+ rapidExits = Date.now() - shellSpawnAt < RAPID_EXIT_MS ? rapidExits + 1 : 0;
461
+ if (rapidExits >= MAX_RAPID_EXITS) {
462
+ shell = null;
463
+ broadcast({
464
+ type: "term",
465
+ data: `\r\n[session kept exiting (last code ${exitCode}) — stopped. Check that your command ('${CLAUDE_CMD}') runs, then reload to retry.]\r\n`,
466
+ });
467
+ return;
468
+ }
469
+ broadcast({ type: "term", data: `\r\n[session exited ${exitCode} — restarting]\r\n` });
470
+ setTimeout(startShell, rapidExits ? Math.min(rapidExits * 400, 2000) : 0);
471
+ });
472
+ }
473
+
474
+ // ---------- websocket: terminal stream + doc pushes + selection bridge ----------
475
+ // verifyClient rejects the upgrade (HTTP 401) unless it passes Host + Origin + token.
476
+ const wss = new WebSocketServer({ server, verifyClient: ({ req }) => wsAllowed(req) });
477
+ const clients = new Set();
478
+
479
+ wss.on("connection", (ws) => {
480
+ clients.add(ws);
481
+ let reconnect = false;
482
+ if (!shell) {
483
+ termBuffer = "";
484
+ rapidExits = 0; // a fresh manual connect (e.g. reload) earns a clean slate of retries
485
+ startShell();
486
+ } else if (termBuffer) {
487
+ // Reconnect (e.g. page reload): replay the current screen for instant content, and
488
+ // flag for a forced repaint once this client's real size lands (see resize handler).
489
+ ws.send(JSON.stringify({ type: "term", data: termBuffer }));
490
+ reconnect = true;
491
+ }
492
+ ws.send(JSON.stringify({ type: "doc", content: read(), missing: !fs.existsSync(DOC) }));
493
+ ws.send(JSON.stringify({ type: "history", canUndo: history.length > 0 }));
494
+
495
+ ws.on("message", (raw) => {
496
+ let msg;
497
+ try {
498
+ msg = JSON.parse(String(raw)); // raw is RawData (Buffer/ArrayBuffer); coerce before parsing
499
+ } catch {
500
+ return;
501
+ }
502
+ // Selection mirroring is independent of the PTY — handle it before the shell guard.
503
+ if (msg.type === "selection" && typeof msg.text === "string") {
504
+ const passage = msg.text.replace(/\s+/g, " ").trim().slice(0, 2000);
505
+ const blocks = Array.isArray(msg.blocks) ? msg.blocks.slice(0, 50) : [];
506
+ // Mirror to disk — never typed into the prompt. The hook surfaces it to Claude.
507
+ writeSelection(passage, blocks);
508
+ return;
509
+ }
510
+ // Direct in-canvas edit — independent of the PTY; writes the file straight to disk.
511
+ if (msg.type === "edit") {
512
+ applyDirectEdit(msg.oldRaw, msg.newRaw, msg.nth);
513
+ return;
514
+ }
515
+ // Restart the Claude session: kill the PTY; the onExit handler spawns a fresh one,
516
+ // which reloads hooks, CLAUDE.md, the MCP server, and SessionStart context. (Does not
517
+ // reload server.js itself — that still needs a manual relaunch.)
518
+ if (msg.type === "restart") {
519
+ if (shell) {
520
+ try {
521
+ shell.kill();
522
+ } catch {}
523
+ }
524
+ return;
525
+ }
526
+ // Client asks for a clean repaint (on refocus / after a settled resize) to un-stick
527
+ // a terminal whose drawing desynced from its size.
528
+ if (msg.type === "repaint") {
529
+ repaintPty();
530
+ return;
531
+ }
532
+ // Switch to a different document (toolbar file picker).
533
+ if (msg.type === "open") {
534
+ const r = openDoc(msg.path);
535
+ if (r.error) ws.send(JSON.stringify({ type: "open-error", message: r.error }));
536
+ return;
537
+ }
538
+ // Roll back the last change. Write the previous version and broadcast it directly;
539
+ // pre-set `last` so the watcher doesn't re-push it onto history (keeps undo linear).
540
+ if (msg.type === "undo") {
541
+ if (history.length) {
542
+ const prev = history.pop();
543
+ historyBytes -= prev.length;
544
+ last = prev;
545
+ lastMissing = false;
546
+ try {
547
+ fs.writeFileSync(DOC, prev);
548
+ } catch {}
549
+ broadcast({ type: "doc", content: prev, missing: false });
550
+ }
551
+ broadcastHistory();
552
+ return;
553
+ }
554
+ if (!shell) return;
555
+ if (msg.type === "term-in") shell.write(msg.data);
556
+ if (msg.type === "resize" && msg.cols && msg.rows) {
557
+ ptyCols = msg.cols;
558
+ ptyRows = msg.rows;
559
+ try {
560
+ shell.resize(msg.cols, msg.rows);
561
+ } catch {}
562
+ if (reconnect) {
563
+ reconnect = false;
564
+ // The terminal is now correctly sized; force a clean repaint of the replayed screen.
565
+ setTimeout(repaintPty, 50);
566
+ }
567
+ }
568
+ });
569
+
570
+ ws.on("close", () => clients.delete(ws));
571
+ });
572
+
573
+ function broadcast(obj) {
574
+ const s = JSON.stringify(obj);
575
+ for (const c of clients) if (c.readyState === 1) c.send(s);
576
+ }
577
+
578
+ // ---------- watch the file; the disk is the interface ----------
579
+ // Event-driven (near-instant) so the canvas re-renders the moment Claude's edit hits
580
+ // disk — mid-turn, before Claude finishes narrating. fs.watch on the *directory* (not
581
+ // the file) survives atomic save-by-rename, which an editor or Claude's writer may use
582
+ // and which would otherwise silence a file-level watch. A slow polling watch stays on
583
+ // as a safety net; the `last` check keeps either path from double-broadcasting.
584
+ let last = read();
585
+ let lastMissing = !fs.existsSync(DOC);
586
+ let updateTimer = null;
587
+ // Undo history: prior document contents, newest last. Every observed change (from Claude,
588
+ // the canvas, or an external editor) pushes the previous version, so the doc-side Undo
589
+ // button can roll back regardless of who made the change.
590
+ const HISTORY_BYTES_MAX = 8 * 1024 * 1024; // bound undo memory by total bytes, not count
591
+ const history = [];
592
+ let historyBytes = 0;
593
+ function broadcastHistory() {
594
+ broadcast({ type: "history", canUndo: history.length > 0 });
595
+ }
596
+ function maybeUpdate() {
597
+ const missing = !fs.existsSync(DOC);
598
+ const next = read();
599
+ if (next !== last || missing !== lastMissing) {
600
+ if (!missing && !lastMissing && next !== last) {
601
+ history.push(last);
602
+ historyBytes += last.length;
603
+ while (history.length > 1 && historyBytes > HISTORY_BYTES_MAX)
604
+ historyBytes -= history.shift().length;
605
+ }
606
+ last = next;
607
+ lastMissing = missing;
608
+ // A deleted doc broadcasts an explicit `missing` flag so the canvas shows "removed"
609
+ // rather than a blank page indistinguishable from an emptied file.
610
+ broadcast({ type: "doc", content: next, missing });
611
+ broadcastHistory();
612
+ }
613
+ }
614
+ function scheduleUpdate() {
615
+ // tiny debounce so a multi-write save coalesces into one re-render once it settles
616
+ if (updateTimer) clearTimeout(updateTimer);
617
+ updateTimer = setTimeout(maybeUpdate, 20);
618
+ }
619
+ // Re-pointable watcher so the file picker can switch documents at runtime.
620
+ let docWatcher = null,
621
+ watchedDoc = null;
622
+ function startWatcher() {
623
+ try {
624
+ if (docWatcher) docWatcher.close();
625
+ } catch {}
626
+ if (watchedDoc) fs.unwatchFile(watchedDoc, maybeUpdate);
627
+ watchedDoc = DOC;
628
+ try {
629
+ docWatcher = fs.watch(path.dirname(DOC), (_event, filename) => {
630
+ if (!filename || filename === path.basename(DOC)) scheduleUpdate();
631
+ });
632
+ } catch (e) {
633
+ console.warn(`fs.watch unavailable (${e.message}); relying on polling.`);
634
+ }
635
+ fs.watchFile(DOC, { interval: 1000 }, maybeUpdate); // fallback safety net
636
+ }
637
+ startWatcher();
638
+
639
+ // Switch the active document at runtime (toolbar file picker) WITHOUT restarting the
640
+ // Claude session. Only the content document changes; the support files (selection,
641
+ // runtime, hooks, MCP registration) stay anchored to the folder mdinterface launched in,
642
+ // so the hooks keep working and the chat is preserved. canvas_edit follows the new doc
643
+ // because it reads the current path from the runtime file (writeRuntime below).
644
+ // canvas_open is in the blanket-approved mcp__mdinterface__* set, and whatever it opens becomes
645
+ // the target canvas_edit writes to. So a prompt-injected document could otherwise chain
646
+ // canvas_open → canvas_edit to write a hook into .claude/settings.local.json (or .mcp.json)
647
+ // with zero further approval — turning "edit my markdown" into command execution. This guard
648
+ // is what keeps canvas_open to its intended job: swapping between documents.
649
+ //
650
+ // realpath FIRST so traversal ("../.claude/…") and symlinks (a .md pointing into .claude/)
651
+ // are checked on the resolved target, not the name — the naive version ships its own bypass.
652
+ const ALLOWED_DOC_EXT = new Set([".md", ".markdown", ".txt"]);
653
+ function resolveSafeDoc(requested) {
654
+ let real;
655
+ try {
656
+ real = fs.realpathSync(requested);
657
+ } catch {
658
+ return null;
659
+ } // must already exist + be readable
660
+ if (!fs.statSync(real).isFile()) return null; // no directories
661
+ if (!ALLOWED_DOC_EXT.has(path.extname(real).toLowerCase())) return null; // primary control
662
+ if (real.split(path.sep).includes(".claude")) return null; // never anything under .claude/
663
+ return real;
664
+ }
665
+
666
+ function openDoc(rawPath) {
667
+ if (typeof rawPath !== "string" || !rawPath.trim()) return { error: "No path given." };
668
+ let p = rawPath.trim();
669
+ // Tolerate pasted paths wrapped in quotes (a common habit, esp. for paths with spaces).
670
+ if (p.length >= 2 && ((p[0] === "'" && p.endsWith("'")) || (p[0] === '"' && p.endsWith('"'))))
671
+ p = p.slice(1, -1);
672
+ p = p.replace(/\\ /g, " "); // unescape "\ " from shell-style drag-and-drop
673
+ const expanded = path.resolve(p.replace(/^~(?=\/|$)/, os.homedir()));
674
+ if (!fs.existsSync(expanded)) return { error: `Not found: ${expanded}` };
675
+ if (fs.statSync(expanded).isDirectory()) return { error: `That's a directory: ${expanded}` };
676
+ const resolved = resolveSafeDoc(expanded);
677
+ if (!resolved)
678
+ return {
679
+ error: `Refused to open ${path.basename(expanded)} — only .md/.markdown/.txt files outside .claude/ can be opened.`,
680
+ };
681
+ if (resolved === DOC) return { error: "That document is already open." };
682
+
683
+ DOC = resolved;
684
+ history.length = 0;
685
+ historyBytes = 0;
686
+ last = read();
687
+ lastMissing = !fs.existsSync(DOC);
688
+ writeRuntime(); // runtime.doc → new path, so canvas_edit edits the right file
689
+ writeSelection("", []); // clear the (now-irrelevant) selection
690
+ startWatcher(); // watch the new file/folder
691
+
692
+ broadcast({ type: "opened", path: DOC, name: path.basename(DOC) });
693
+ broadcast({ type: "doc", content: read(), missing: lastMissing });
694
+ broadcastHistory();
695
+ return { ok: true }; // no shell.kill() — the Claude session is preserved
696
+ }
697
+
698
+ // Bind to loopback only — never expose the PTY to the LAN.
699
+ // Fail clearly instead of throwing a raw stack trace when the port is taken (the common
700
+ // case: another mdinterface is already running). The listen error surfaces on the http server
701
+ // and/or the attached WebSocket server, so handle both.
702
+ function onServerError(e) {
703
+ if (e && e.code === "EADDRINUSE") {
704
+ console.error(
705
+ `\n Port ${PORT} is already in use — another mdinterface may be running.\n` +
706
+ ` Stop it (lsof -ti :${PORT} | xargs kill) or pick another port:\n` +
707
+ ` mdinterface ${JSON.stringify(path.basename(DOC))} --port 8001\n`
708
+ );
709
+ } else {
710
+ console.error(`\n mdinterface could not start: ${(e && e.message) || e}\n`);
711
+ }
712
+ process.exit(1);
713
+ }
714
+ server.on("error", onServerError);
715
+ wss.on("error", onServerError);
716
+
717
+ server.listen(PORT, "127.0.0.1", () => {
718
+ const url = `http://localhost:${PORT}/?t=${TOKEN}`;
719
+ console.log(`mdinterface ▸ ${path.basename(DOC)} ▸ ${url}`);
720
+ const opener =
721
+ os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
722
+ require("node:child_process").exec(`${opener} "${url}"`);
723
+ });