mdinterface 0.1.1 → 0.2.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/README.md +8 -0
- package/mcp-server.js +1 -1
- package/package.json +5 -2
- package/public/index.html +18 -1
- package/server.js +176 -49
package/README.md
CHANGED
|
@@ -48,10 +48,18 @@ CDN, no first-load internet requirement.
|
|
|
48
48
|
node server.js path/to/doc.md
|
|
49
49
|
# prints a URL like http://localhost:7777/?t=… — open THAT (it carries a session token)
|
|
50
50
|
|
|
51
|
+
Or start with **no file** — you get an empty canvas and a file browser; pick a `.md` and the
|
|
52
|
+
session begins. The folder of that first document becomes Claude's working directory for the
|
|
53
|
+
rest of the session, so launch from (or pick within) the project you want Claude to work in:
|
|
54
|
+
|
|
55
|
+
node server.js
|
|
56
|
+
# empty canvas → Browse → pick a doc; Claude starts in that doc's folder
|
|
57
|
+
|
|
51
58
|
Options:
|
|
52
59
|
|
|
53
60
|
--port 8000 # different port
|
|
54
61
|
--cmd "claude --continue" # custom launch command (or set MDINTERFACE_CMD)
|
|
62
|
+
--help # usage and exit
|
|
55
63
|
|
|
56
64
|
## Use
|
|
57
65
|
|
package/mcp-server.js
CHANGED
|
@@ -213,7 +213,7 @@ function handle(msg) {
|
|
|
213
213
|
result(id, {
|
|
214
214
|
protocolVersion: params?.protocolVersion || "2025-06-18",
|
|
215
215
|
capabilities: { tools: {} },
|
|
216
|
-
serverInfo: { name: "mdinterface", version: "
|
|
216
|
+
serverInfo: { name: "mdinterface", version: require("./package.json").version },
|
|
217
217
|
});
|
|
218
218
|
} else if (method === "tools/list") {
|
|
219
219
|
result(id, { tools: TOOLS });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdinterface",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A live markdown canvas wired to Claude Code. Highlight a passage to set both the context and the scope, ask, and Claude edits exactly that and leaves the rest alone.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kevin Sundstrom",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "bash start.sh",
|
|
21
|
+
"mdinterface": "node server.js",
|
|
21
22
|
"lint": "biome check .",
|
|
22
23
|
"format": "biome format --write .",
|
|
23
24
|
"typecheck": "tsc --noEmit",
|
|
@@ -51,9 +52,11 @@
|
|
|
51
52
|
],
|
|
52
53
|
"dependencies": {
|
|
53
54
|
"express": "^4.19.0",
|
|
54
|
-
"node-pty": "^1.0.0",
|
|
55
55
|
"ws": "^8.17.0"
|
|
56
56
|
},
|
|
57
|
+
"optionalDependencies": {
|
|
58
|
+
"node-pty": "^1.0.0"
|
|
59
|
+
},
|
|
57
60
|
"devDependencies": {
|
|
58
61
|
"@biomejs/biome": "^2.0.0",
|
|
59
62
|
"@types/express": "^4.17.21",
|
package/public/index.html
CHANGED
|
@@ -387,7 +387,11 @@ function connect() {
|
|
|
387
387
|
if (!editingEl) render(msg.content);
|
|
388
388
|
}
|
|
389
389
|
if (msg.type === "history") undoBtn.disabled = !msg.canUndo;
|
|
390
|
+
if (msg.type === "nodoc") showNoDoc();
|
|
390
391
|
if (msg.type === "opened") {
|
|
392
|
+
// Leaving the no-document state: the server is starting Claude now, so reset the
|
|
393
|
+
// terminal for its fresh output and dismiss the picker.
|
|
394
|
+
if (nodoc) { nodoc = false; closeBrowser(); term.reset(); }
|
|
391
395
|
setDocMeta(msg.path, msg.name);
|
|
392
396
|
filepicker.classList.remove("error");
|
|
393
397
|
// New document — reset the diff baseline so the whole thing isn't flagged as
|
|
@@ -401,6 +405,19 @@ function showMissing() {
|
|
|
401
405
|
lastSrc = ""; prevBlocks = []; prevEls = []; flashedEls = []; notionLink.hidden = true;
|
|
402
406
|
docEl.innerHTML = '<p style="color:var(--ink-soft);font-style:italic">The document is no longer on disk — waiting for it to return…</p>';
|
|
403
407
|
}
|
|
408
|
+
// No file was given at launch. Show a hint + the file browser; Claude doesn't start until a
|
|
409
|
+
// document is picked (its folder becomes the working directory). Set by the server's "nodoc".
|
|
410
|
+
let nodoc = false;
|
|
411
|
+
function showNoDoc() {
|
|
412
|
+
nodoc = true;
|
|
413
|
+
lastSrc = ""; prevBlocks = []; prevEls = []; flashedEls = []; notionLink.hidden = true;
|
|
414
|
+
docEl.innerHTML = '<p style="color:var(--ink-soft);font-style:italic">No document open — pick a <code>.md</code> file to start. The folder you choose becomes Claude’s working directory.</p>';
|
|
415
|
+
document.title = "mdinterface — pick a document";
|
|
416
|
+
term.reset();
|
|
417
|
+
term.writeln("\x1b[2m No Claude session yet — pick a document to begin.\x1b[0m");
|
|
418
|
+
showHeader();
|
|
419
|
+
openBrowser(); // pop the file browser at the launch directory
|
|
420
|
+
}
|
|
404
421
|
connect();
|
|
405
422
|
|
|
406
423
|
// ---------- block rendering + change flash ----------
|
|
@@ -794,7 +811,7 @@ restartBtn.addEventListener("click", () => {
|
|
|
794
811
|
const header = document.querySelector("header");
|
|
795
812
|
let headerHide;
|
|
796
813
|
function showHeader() { clearTimeout(headerHide); header.classList.add("show"); }
|
|
797
|
-
function hideHeaderSoon(delay) { clearTimeout(headerHide); headerHide = setTimeout(() => header.classList.remove("show"), delay); }
|
|
814
|
+
function hideHeaderSoon(delay) { if (nodoc) return; clearTimeout(headerHide); headerHide = setTimeout(() => header.classList.remove("show"), delay); }
|
|
798
815
|
document.addEventListener("mousemove", (e) => { if (e.clientY <= 6) showHeader(); });
|
|
799
816
|
header.addEventListener("mouseenter", showHeader);
|
|
800
817
|
header.addEventListener("mouseleave", () => hideHeaderSoon(300));
|
package/server.js
CHANGED
|
@@ -16,21 +16,48 @@ const path = require("node:path");
|
|
|
16
16
|
const os = require("node:os");
|
|
17
17
|
const crypto = require("node:crypto");
|
|
18
18
|
const { WebSocketServer } = require("ws");
|
|
19
|
-
|
|
19
|
+
// node-pty powers the embedded terminal pane. It's an OPTIONAL native dependency (see
|
|
20
|
+
// optionalDependencies in package.json): if it didn't install or build, the rendered canvas,
|
|
21
|
+
// the file watcher, and the selection bridge all still come up — only the in-window terminal
|
|
22
|
+
// is disabled, and you run `claude` in your own terminal instead (the hooks still feed it the
|
|
23
|
+
// selection off disk). The specific reason is surfaced to the user at spawn time.
|
|
24
|
+
let pty = null;
|
|
25
|
+
try {
|
|
26
|
+
pty = require("node-pty");
|
|
27
|
+
} catch {}
|
|
20
28
|
|
|
21
29
|
// ---------- args ----------
|
|
22
30
|
const args = process.argv.slice(2);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
32
|
+
console.log(
|
|
33
|
+
"mdinterface — a live markdown canvas wired to Claude Code.\n\n" +
|
|
34
|
+
"Usage:\n" +
|
|
35
|
+
" mdinterface [file.md] [--port 7777] [--cmd claude]\n\n" +
|
|
36
|
+
"Run with a file to open it directly, or with no file to start on an empty\n" +
|
|
37
|
+
"canvas and pick one in the file browser. The folder of the first document\n" +
|
|
38
|
+
"opened becomes Claude's working directory for the rest of the session.\n\n" +
|
|
39
|
+
"Options:\n" +
|
|
40
|
+
" --port <n> Port to listen on (default 7777)\n" +
|
|
41
|
+
' --cmd <cmd> Command run in the terminal pane (default "claude";\n' +
|
|
42
|
+
" or set MDINTERFACE_CMD)\n" +
|
|
43
|
+
" -h, --help Show this help and exit\n\n" +
|
|
44
|
+
"The printed URL carries a per-launch token — open that exact URL."
|
|
45
|
+
);
|
|
46
|
+
process.exit(0);
|
|
27
47
|
}
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
// The file is the first bare argument — but skip values that belong to a value-taking flag
|
|
49
|
+
// (`--port 7777`, `--cmd claude`), or with no file they'd be misread as the filename.
|
|
50
|
+
const VALUE_FLAGS = new Set(["--port", "--cmd"]);
|
|
51
|
+
const fileArg = args.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(args[i - 1]));
|
|
52
|
+
// The file is optional: with none, mdinterface opens an empty canvas and the file browser,
|
|
53
|
+
// and initializes the session (hooks, MCP, watcher, Claude) when you pick the first document.
|
|
54
|
+
// reassignable: the toolbar file picker can switch documents.
|
|
55
|
+
let DOC = fileArg ? path.resolve(fileArg) : null;
|
|
56
|
+
if (DOC && !fs.existsSync(DOC)) {
|
|
30
57
|
console.error(`File not found: ${DOC}`);
|
|
31
58
|
process.exit(1);
|
|
32
59
|
}
|
|
33
|
-
if (fs.statSync(DOC).isDirectory()) {
|
|
60
|
+
if (DOC && fs.statSync(DOC).isDirectory()) {
|
|
34
61
|
console.error(
|
|
35
62
|
`${DOC} is a directory — point me at a markdown file, e.g.:\n node server.js ${path.join(fileArg, "doc.md")}`
|
|
36
63
|
);
|
|
@@ -66,7 +93,11 @@ app.use(express.static(path.join(__dirname, "public")));
|
|
|
66
93
|
app.get("/doc", (req, res) => {
|
|
67
94
|
if (req.query.t !== TOKEN) return res.status(403).end();
|
|
68
95
|
// path is returned (for the file picker) but only to a token-holding client.
|
|
69
|
-
res.json(
|
|
96
|
+
res.json(
|
|
97
|
+
DOC
|
|
98
|
+
? { name: path.basename(DOC), path: DOC, content: read() }
|
|
99
|
+
: { name: null, path: null, content: "" }
|
|
100
|
+
);
|
|
70
101
|
});
|
|
71
102
|
// Directory listing for the file browser: folders (to navigate) + markdown files. Token
|
|
72
103
|
// gated. Lists any dir the user can read — same reach the PTY already has.
|
|
@@ -75,7 +106,9 @@ app.get("/ls", (req, res) => {
|
|
|
75
106
|
const dir =
|
|
76
107
|
typeof req.query.dir === "string" && req.query.dir
|
|
77
108
|
? path.resolve(req.query.dir)
|
|
78
|
-
:
|
|
109
|
+
: DOC
|
|
110
|
+
? path.dirname(DOC)
|
|
111
|
+
: process.cwd(); // no doc yet → browse from where mdinterface was launched
|
|
79
112
|
let ents;
|
|
80
113
|
try {
|
|
81
114
|
ents = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -122,14 +155,22 @@ function read() {
|
|
|
122
155
|
// The canvas never types into Claude. Instead the current selection is mirrored to a
|
|
123
156
|
// file, and a UserPromptSubmit hook injects that file as context on the user's next
|
|
124
157
|
// message — so Claude is ambiently aware of what's selected, with zero prompt noise.
|
|
125
|
-
|
|
126
|
-
|
|
158
|
+
// Folder-derived paths. When launched with no file, DOC is null and these stay null until
|
|
159
|
+
// the first document is opened (openDoc → initDocSession), which anchors them to its folder.
|
|
160
|
+
let CLAUDE_DIR = null;
|
|
161
|
+
let SEL_FILE = null;
|
|
127
162
|
|
|
128
163
|
// ---------- runtime file: how the separate MCP process reaches this server ----------
|
|
129
164
|
// canvas_edit writes the open document directly; canvas_open POSTs back to this server. The
|
|
130
165
|
// MCP process reads port/token/doc from this file before each call, so edits follow the
|
|
131
166
|
// toolbar file picker. Edits always apply immediately — undo is the Undo button (or git).
|
|
132
|
-
|
|
167
|
+
let RUNTIME_FILE = null;
|
|
168
|
+
// Anchor the support-file paths to the open document's folder. Called once the doc is known.
|
|
169
|
+
function wireDocPaths() {
|
|
170
|
+
CLAUDE_DIR = path.join(path.dirname(DOC), ".claude");
|
|
171
|
+
SEL_FILE = path.join(CLAUDE_DIR, "mdinterface-selection.txt");
|
|
172
|
+
RUNTIME_FILE = path.join(CLAUDE_DIR, "mdinterface-runtime.json");
|
|
173
|
+
}
|
|
133
174
|
function writeRuntime() {
|
|
134
175
|
try {
|
|
135
176
|
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
@@ -341,18 +382,25 @@ function installMcpServer() {
|
|
|
341
382
|
}
|
|
342
383
|
}
|
|
343
384
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
385
|
+
// Per-document session setup: anchor the support files + hooks to the doc's folder, register
|
|
386
|
+
// the MCP server, and seed the runtime/selection files. Runs at boot when a file was given on
|
|
387
|
+
// the command line, or on the first openDoc when launched with no file.
|
|
388
|
+
function initDocSession() {
|
|
389
|
+
wireDocPaths();
|
|
390
|
+
installHooks();
|
|
391
|
+
installMcpServer();
|
|
392
|
+
writeRuntime(); // mode + callback info for the MCP server
|
|
393
|
+
writeSelection("", []); // start clean
|
|
394
|
+
}
|
|
395
|
+
if (DOC) initDocSession();
|
|
348
396
|
|
|
349
397
|
for (const sig of ["exit", "SIGINT", "SIGTERM"]) {
|
|
350
398
|
process.on(sig, () => {
|
|
351
399
|
try {
|
|
352
|
-
fs.writeFileSync(SEL_FILE, "");
|
|
400
|
+
if (SEL_FILE) fs.writeFileSync(SEL_FILE, "");
|
|
353
401
|
} catch {}
|
|
354
402
|
try {
|
|
355
|
-
fs.unlinkSync(RUNTIME_FILE);
|
|
403
|
+
if (RUNTIME_FILE) fs.unlinkSync(RUNTIME_FILE);
|
|
356
404
|
} catch {} // gone → MCP server defaults back to auto
|
|
357
405
|
if (sig !== "exit") process.exit(0);
|
|
358
406
|
});
|
|
@@ -405,13 +453,65 @@ function onPtyData(d) {
|
|
|
405
453
|
flushTerm();
|
|
406
454
|
}, TERM_FLUSH_MS);
|
|
407
455
|
}
|
|
456
|
+
// pnpm, Yarn PnP, CI caches, and Docker COPY frequently strip the +x bit from node-pty's
|
|
457
|
+
// prebuilt spawn-helper, which makes pty.spawn die — the single most common runtime failure.
|
|
458
|
+
// Restore it in-process, since a postinstall script can't help the people who hit this most
|
|
459
|
+
// (--ignore-scripts / pnpm users disable postinstall). Returns true if it actually fixed a bit.
|
|
460
|
+
function ensureSpawnHelperExecutable() {
|
|
461
|
+
let fixed = false;
|
|
462
|
+
try {
|
|
463
|
+
const root = path.dirname(require.resolve("node-pty/package.json")); // works wherever it's hoisted
|
|
464
|
+
for (const base of [path.join(root, "prebuilds"), path.join(root, "build", "Release")]) {
|
|
465
|
+
let ents;
|
|
466
|
+
try {
|
|
467
|
+
ents = fs.readdirSync(base, { withFileTypes: true });
|
|
468
|
+
} catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const files = [];
|
|
472
|
+
for (const ent of ents) {
|
|
473
|
+
if (ent.isDirectory()) files.push(path.join(base, ent.name, "spawn-helper"));
|
|
474
|
+
else if (ent.name === "spawn-helper") files.push(path.join(base, ent.name));
|
|
475
|
+
}
|
|
476
|
+
for (const f of files) {
|
|
477
|
+
try {
|
|
478
|
+
if (!(fs.statSync(f).mode & 0o111)) {
|
|
479
|
+
fs.chmodSync(f, 0o755);
|
|
480
|
+
fixed = true;
|
|
481
|
+
}
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} catch {}
|
|
486
|
+
return fixed;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Turn a spawn failure into a specific, actionable message instead of a generic one.
|
|
490
|
+
function diagnosePtyFailure(e) {
|
|
491
|
+
const msg = e?.message || String(e);
|
|
492
|
+
let hint;
|
|
493
|
+
if (/NODE_MODULE_VERSION|compiled against|different Node/i.test(msg))
|
|
494
|
+
hint = "node-pty was built for a different Node version — rebuild it:\n npm rebuild node-pty";
|
|
495
|
+
else if (/spawn-helper|EACCES|ENOENT|permission/i.test(msg))
|
|
496
|
+
hint =
|
|
497
|
+
"node-pty's spawn-helper is missing or not executable:\n" +
|
|
498
|
+
" chmod +x node_modules/node-pty/prebuilds/*/spawn-helper\n npm rebuild node-pty";
|
|
499
|
+
else hint = "Reinstall the native module:\n npm rebuild node-pty";
|
|
500
|
+
return (
|
|
501
|
+
`Embedded terminal could not start (${msg}).\n${hint}\n` +
|
|
502
|
+
`The canvas and selection bridge still work — run \`${CLAUDE_CMD}\` in your own terminal beside this window.`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
408
506
|
function spawnPty(cols = 100, rows = 32) {
|
|
507
|
+
if (!pty) return null; // optional dependency absent — startShell shows the fallback message
|
|
508
|
+
ensureSpawnHelperExecutable(); // proactively fix a stripped +x bit before we spawn
|
|
409
509
|
const env = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
|
|
410
510
|
const cwd = path.dirname(DOC);
|
|
411
511
|
const sh = process.env.SHELL || (os.platform() === "win32" ? "powershell.exe" : "/bin/bash");
|
|
412
512
|
const opts = { name: "xterm-256color", cols, rows, cwd, env };
|
|
413
|
-
// Launch through the user's interactive login shell so PATH, rc files,
|
|
414
|
-
//
|
|
513
|
+
// Launch through the user's interactive login shell so PATH, rc files, and aliases apply —
|
|
514
|
+
// this is how `claude` is normally found.
|
|
415
515
|
try {
|
|
416
516
|
return pty.spawn(sh, ["-ilc", CLAUDE_CMD], opts);
|
|
417
517
|
} catch (e) {
|
|
@@ -419,18 +519,18 @@ function spawnPty(cols = 100, rows = 32) {
|
|
|
419
519
|
`Could not start "${CLAUDE_CMD}" via ${sh} (${e.message}); opening a plain shell.`
|
|
420
520
|
);
|
|
421
521
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return null;
|
|
522
|
+
// Plain-shell fallback. If even this fails, a stripped +x bit on spawn-helper is the usual
|
|
523
|
+
// cause: self-heal it and retry once before giving up with a diagnosis.
|
|
524
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
525
|
+
try {
|
|
526
|
+
return pty.spawn(sh, [], opts);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
if (attempt === 0 && ensureSpawnHelperExecutable()) continue; // fixed a bit → retry
|
|
529
|
+
console.error(diagnosePtyFailure(e));
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
433
532
|
}
|
|
533
|
+
return null;
|
|
434
534
|
}
|
|
435
535
|
|
|
436
536
|
// Start the PTY and wire BOTH its data and exit handlers (the earlier version reattached
|
|
@@ -445,7 +545,13 @@ let rapidExits = 0;
|
|
|
445
545
|
function startShell() {
|
|
446
546
|
shell = spawnPty(ptyCols, ptyRows); // respawn at the current size, not the 100x32 default
|
|
447
547
|
if (!shell) {
|
|
448
|
-
|
|
548
|
+
// Two cases, both non-fatal: node-pty isn't installed at all, or it is but the spawn
|
|
549
|
+
// failed (details already in the server console via diagnosePtyFailure). Either way the
|
|
550
|
+
// canvas + selection bridge work, so point the user at running claude in their own terminal.
|
|
551
|
+
const data = pty
|
|
552
|
+
? "\r\nEmbedded terminal could not start (see the server console). The canvas and selection still work — run claude in your own terminal beside this window and it gets the same context.\r\n"
|
|
553
|
+
: "\r\nEmbedded terminal disabled: node-pty isn't installed. The canvas and selection still work — run claude in your own terminal beside this window (it gets the same context via the hooks). To enable the in-window terminal: npm i node-pty\r\n";
|
|
554
|
+
broadcast({ type: "term", data });
|
|
449
555
|
return;
|
|
450
556
|
}
|
|
451
557
|
shellSpawnAt = Date.now();
|
|
@@ -479,17 +585,24 @@ const clients = new Set();
|
|
|
479
585
|
wss.on("connection", (ws) => {
|
|
480
586
|
clients.add(ws);
|
|
481
587
|
let reconnect = false;
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
588
|
+
if (DOC) {
|
|
589
|
+
if (!shell) {
|
|
590
|
+
termBuffer = "";
|
|
591
|
+
rapidExits = 0; // a fresh manual connect (e.g. reload) earns a clean slate of retries
|
|
592
|
+
startShell();
|
|
593
|
+
} else if (termBuffer) {
|
|
594
|
+
// Reconnect (e.g. page reload): replay the current screen for instant content, and
|
|
595
|
+
// flag for a forced repaint once this client's real size lands (see resize handler).
|
|
596
|
+
ws.send(JSON.stringify({ type: "term", data: termBuffer }));
|
|
597
|
+
reconnect = true;
|
|
598
|
+
}
|
|
599
|
+
ws.send(JSON.stringify({ type: "doc", content: read(), missing: !fs.existsSync(DOC) }));
|
|
600
|
+
} else {
|
|
601
|
+
// No document yet (launched with no file): don't spawn Claude — its working directory is
|
|
602
|
+
// unknown until the first pick. Tell the client to show the picker instead of a terminal.
|
|
603
|
+
// The message handler below still runs, so the client's "open" pick is honored.
|
|
604
|
+
ws.send(JSON.stringify({ type: "nodoc" }));
|
|
491
605
|
}
|
|
492
|
-
ws.send(JSON.stringify({ type: "doc", content: read(), missing: !fs.existsSync(DOC) }));
|
|
493
606
|
ws.send(JSON.stringify({ type: "history", canUndo: history.length > 0 }));
|
|
494
607
|
|
|
495
608
|
ws.on("message", (raw) => {
|
|
@@ -582,7 +695,7 @@ function broadcast(obj) {
|
|
|
582
695
|
// and which would otherwise silence a file-level watch. A slow polling watch stays on
|
|
583
696
|
// as a safety net; the `last` check keeps either path from double-broadcasting.
|
|
584
697
|
let last = read();
|
|
585
|
-
let lastMissing = !fs.existsSync(DOC);
|
|
698
|
+
let lastMissing = Boolean(DOC) && !fs.existsSync(DOC); // no doc yet → not "missing", just unset
|
|
586
699
|
let updateTimer = null;
|
|
587
700
|
// Undo history: prior document contents, newest last. Every observed change (from Claude,
|
|
588
701
|
// the canvas, or an external editor) pushes the previous version, so the doc-side Undo
|
|
@@ -634,7 +747,7 @@ function startWatcher() {
|
|
|
634
747
|
}
|
|
635
748
|
fs.watchFile(DOC, { interval: 1000 }, maybeUpdate); // fallback safety net
|
|
636
749
|
}
|
|
637
|
-
startWatcher();
|
|
750
|
+
if (DOC) startWatcher(); // with no file yet, the watcher starts when the first doc is opened
|
|
638
751
|
|
|
639
752
|
// Switch the active document at runtime (toolbar file picker) WITHOUT restarting the
|
|
640
753
|
// Claude session. Only the content document changes; the support files (selection,
|
|
@@ -680,18 +793,32 @@ function openDoc(rawPath) {
|
|
|
680
793
|
};
|
|
681
794
|
if (resolved === DOC) return { error: "That document is already open." };
|
|
682
795
|
|
|
796
|
+
const firstDoc = DOC === null; // launched with no file → this pick initializes the session
|
|
683
797
|
DOC = resolved;
|
|
684
798
|
history.length = 0;
|
|
685
799
|
historyBytes = 0;
|
|
686
800
|
last = read();
|
|
687
801
|
lastMissing = !fs.existsSync(DOC);
|
|
688
|
-
|
|
689
|
-
|
|
802
|
+
if (firstDoc) {
|
|
803
|
+
// Anchor support files + hooks to this folder and register the MCP server, exactly as a
|
|
804
|
+
// file-on-launch start does at boot. The session's working directory is fixed from here.
|
|
805
|
+
initDocSession();
|
|
806
|
+
} else {
|
|
807
|
+
writeRuntime(); // runtime.doc → new path, so canvas_edit edits the right file
|
|
808
|
+
writeSelection("", []); // clear the (now-irrelevant) selection
|
|
809
|
+
}
|
|
690
810
|
startWatcher(); // watch the new file/folder
|
|
691
811
|
|
|
692
812
|
broadcast({ type: "opened", path: DOC, name: path.basename(DOC) });
|
|
693
813
|
broadcast({ type: "doc", content: read(), missing: lastMissing });
|
|
694
814
|
broadcastHistory();
|
|
815
|
+
// First document: now that the working directory is known, start Claude. Connected clients
|
|
816
|
+
// receive its output via broadcast. (Switching docs later preserves the session — no respawn.)
|
|
817
|
+
if (firstDoc && !shell) {
|
|
818
|
+
termBuffer = "";
|
|
819
|
+
rapidExits = 0;
|
|
820
|
+
startShell();
|
|
821
|
+
}
|
|
695
822
|
return { ok: true }; // no shell.kill() — the Claude session is preserved
|
|
696
823
|
}
|
|
697
824
|
|
|
@@ -707,7 +834,7 @@ function onServerError(e) {
|
|
|
707
834
|
` mdinterface ${JSON.stringify(path.basename(DOC))} --port 8001\n`
|
|
708
835
|
);
|
|
709
836
|
} else {
|
|
710
|
-
console.error(`\n mdinterface could not start: ${
|
|
837
|
+
console.error(`\n mdinterface could not start: ${e?.message || e}\n`);
|
|
711
838
|
}
|
|
712
839
|
process.exit(1);
|
|
713
840
|
}
|
|
@@ -716,7 +843,7 @@ wss.on("error", onServerError);
|
|
|
716
843
|
|
|
717
844
|
server.listen(PORT, "127.0.0.1", () => {
|
|
718
845
|
const url = `http://localhost:${PORT}/?t=${TOKEN}`;
|
|
719
|
-
console.log(`mdinterface ▸ ${path.basename(DOC)} ▸ ${url}`);
|
|
846
|
+
console.log(`mdinterface ▸ ${DOC ? path.basename(DOC) : "(pick a document)"} ▸ ${url}`);
|
|
720
847
|
const opener =
|
|
721
848
|
os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
|
|
722
849
|
require("node:child_process").exec(`${opener} "${url}"`);
|