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/LICENSE +21 -0
- package/README.md +131 -0
- package/access.js +60 -0
- package/mcp-server.js +269 -0
- package/package.json +64 -0
- package/public/index.html +805 -0
- package/public/render-core.js +103 -0
- package/public/vendor/addon-fit.min.js +8 -0
- package/public/vendor/addon-webgl.min.js +8 -0
- package/public/vendor/marked.min.js +6 -0
- package/public/vendor/purify.min.js +3 -0
- package/public/vendor/xterm.min.css +8 -0
- package/public/vendor/xterm.min.js +8 -0
- package/server.js +723 -0
- package/start.sh +31 -0
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
|
+
});
|